Skip to content
Examples

Examples

Product-neutral React primitives and blocks for Fungi interfaces

TSX
import { useRef } from "react";
import { Button } from "@fungi.computer/caps/components/button";
export function SaveButton({
pending,
onSave,
}: {
pending: boolean;
onSave: () => void;
}) {
const ref = useRef<HTMLButtonElement>(null);
return (
<Button
ref={ref}
type="submit"
aria-label="Save changes"
disabled={pending}
loading={pending}
variant="outline"
size="sm"
className="transition-none rounded-full"
onClick={onSave}
>
Save
</Button>
);
}

The caller’s transition-none replaces the base transition utility, and rounded-full adds a local treatment. daisyUI’s btn-outline and color classes compose, so color belongs in variant or an extra daisyUI class.

Composer is a layout block: one surface with optional attachments on top, your input typed straight onto the host region, and a bottom row of toolbar controls with the action at its end. It draws no box of its own; the host region is the field. Every slot is your control, and so are its labels, disabled state, keyboard behaviour and effects. A composer’s send key is SendAction, which turns into a stop key while work runs.

TSX
import { useState, type FormEvent } from "react";
import { Button } from "@fungi.computer/caps/components/button";
import { Composer } from "@fungi.computer/caps/blocks/composer";
export function PromptComposer({
submit,
}: {
submit: (value: string) => Promise<void>;
}) {
const [value, setValue] = useState("");
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent) {
event.preventDefault();
if (!value.trim() || pending) return;
setPending(true);
try {
await submit(value);
setValue("");
} finally {
setPending(false);
}
}
return (
<form onSubmit={onSubmit}>
<Composer
input={
<input
className="input w-full"
value={value}
onChange={(event) => setValue(event.target.value)}
aria-label="Prompt"
/>
}
action={
<Button
type="submit"
size="sm"
disabled={pending || !value.trim()}
loading={pending}
>
Send
</Button>
}
/>
</form>
);
}

submit is your effect. It can call an API, enqueue work or do something local. Caps only lays out the controls.

TSX
import type { ReactNode } from "react";
import {
WindowActions,
WindowBody,
WindowFrame,
WindowHeader,
} from "@fungi.computer/caps/components/window";
export function NotesWindow({
active,
close,
children,
}: {
active: boolean;
close: () => void;
children: ReactNode;
}) {
return (
<WindowFrame aria-label="Notes" active={active}>
<WindowHeader
title="Notes"
actions={<WindowActions title="Notes" onClose={close} />}
/>
<WindowBody>{children}</WindowBody>
</WindowFrame>
);
}

AppBar keeps running apps visible to the left of an anchored launcher. Clicking the launcher reveals available apps to the right and an optional primary action above. You own app identity, icons, selected state and callbacks. Caps owns disclosure, Anime.js springs, overflow and keyboard focus.

TSX
import { AppBar } from "@fungi.computer/caps/blocks/app-bar";
export function Dock({
openWindow,
openSettings,
createChat,
}: {
openWindow: (id: string) => void;
openSettings: () => void;
createChat: () => void;
}) {
return (
<AppBar
windows={[
{
id: "editor",
label: "Editor",
active: true,
onSelect: () => openWindow("editor"),
},
]}
launchers={[
{ id: "settings", label: "Settings", onSelect: openSettings },
]}
launcher={{ label: "Open apps" }}
primaryAction={{
label: "New chat",
icon: <span aria-hidden="true">+</span>,
onSelect: createChat,
}}
/>
);
}

Icons are React nodes. Narrow screens keep the same two scrollable trays. Escape closes the disclosure and returns focus to the launcher. Reduced motion settles immediately. Available-app and primary-action buttons do not claim toggle state. alwaysExpanded keeps the disclosure open for dock layouts.

Status is a gooey SVG bead. Set glyph (dot, pill or bang), tone, and optional motion (none, pulse or bounce). Shape changes animate from the current geometry with Anime.js, and reduced motion settles immediately. You map your state to these props. An optional label renders beside the decorative bead.

TSX
import { Status } from "@fungi.computer/caps/components/status";
export function InboxBead({
unread,
selected,
}: {
unread: boolean;
selected: boolean;
}) {
return (
<>
<Status
glyph={unread ? "bang" : selected ? "pill" : "dot"}
tone="primary"
/>
<Status label="Working" tone="success" motion="pulse" />
</>
);
}

IconButton defaults to the Dashboard’s ghost, circular, extra-small control. It keeps its grid size on phones and touch screens; an invisible hit area reaches --caps-touch-target instead. A disabled ghost button stays faceless and dims to --caps-disabled-ink. Disabled filled buttons retain their color, material and shape at reduced opacity, including invalid and pending form submits. Checkbox, Switch, and Progress keep their native elements and existing animations. ScrollToBottomBlob stays mounted beside a supplied scroller and owns its visibility: show beyond 48px from the end, hide below 8px. It defaults to forward DOM order (direction="reverse" supports column-reverse). The host owns onJump and the appended-message unread count. focusAfterJump optionally returns keyboard focus to the composer; otherwise the scroller receives focus. Motion reads Stipe’s spring-duration, bounce, stretch and fast tokens. Reduced motion uses short opacity fades with no moving beads or stretch. The Feedback story provides real scroll, unread-arrival and jump scenarios.

@fungi.computer/caps/blocks/status-tray exports StatusTray, StatusTrayList, StatusTrayRow and StatusTrayNote. You supply readouts and content, own openId and handle actions. One tray opens at a time. Escape and the close control return focus to its chip. Outside pointer presses close it without stealing focus. Disabled items never open, and zero counts stay hidden.

TSX
import { useState } from "react";
import { StatusTray } from "@fungi.computer/caps/blocks/status-tray";
export function PortsTray({ ports }: { ports: readonly string[] }) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<StatusTray
openId={openId}
onOpenChange={setOpenId}
items={[
{
id: "ports",
label: "Ports",
count: ports.length,
tone: "success",
content: (
<ul>
{ports.map((port) => (
<li key={port}>{port}</li>
))}
</ul>
),
},
]}
/>
);
}

An open tray is a calm popover list, with nothing boxed:

  • StatusTrayRow is one line: the App’s own icon (a Sigil, a Lucide glyph or a Status), the primary title, a muted secondary detail that truncates, then actions.
  • Actions are quiet text buttons: <Button variant="ghost" size="xs">. A destructive one such as Stop uses variant="ghost-destructive", which turns red on hover or focus. Give a row one action where you can.
  • Precede a StatusTrayList with a DataListHeading for a small group label.
  • StatusTrayNote is the empty state. It reads “All caught up.” unless you pass other text. For an unavailable feed, give it a title; unavailable is not a zero count.
  • The tray’s close control is a plain IconButton. The host owns capability checks.

leading and trailing seat adjacent controls, and an item’s after places controls right after its chip in DOM and keyboard order. align chooses the panel edge; placement="screen" uses flat screen-native readouts instead of housing chips. The block has no product data, persistence or appearance variant.

Themes can set these tokens:

  • --caps-status-tray-chip-* and --caps-status-tray-count-bg
  • --caps-status-tray-title-{case,tracking} and --caps-status-tray-width
  • --caps-status-tray-key-{face,tint,height}, for KeycapButtons beside the chips
  • --caps-status-tray-row-{height,hover,wrap} and --caps-status-tray-icon-size
  • --caps-status-tray-chips-display
  • --caps-status-tray-note-tracking and --caps-status-tray-muted
  • --caps-status-tray-screen-{bg,fg,bg-active,fg-active}

Defaults use existing Stipe tokens. The Ladle Status tray stories show the maintained themes and a nested finish using the same props.

Game UI reads the enclosing theme’s --card-ink and --card-paper. The nested paper finish supplies window linework, hard shadows, and control depth while retaining that palette.

TSX
import { KeycapButton } from "@fungi.computer/caps/components/keycap";
import { GameMeter } from "@fungi.computer/caps/components/progress";
import { PixelNumber } from "@fungi.computer/caps/components/pixel-number";
import {
ItemGrid,
type ItemGridCell,
type ItemGridGhost,
type ItemGridItem,
} from "@fungi.computer/caps/components/item-grid";
export function Hud({
building,
toggleBuild,
items,
ghost,
pickUpOrDrop,
previewDrop,
}: {
building: boolean;
toggleBuild: () => void;
items: readonly ItemGridItem[];
ghost: ItemGridGhost | null;
pickUpOrDrop: (cell: ItemGridCell) => void;
previewDrop: (cell: ItemGridCell | null) => void;
}) {
return (
<>
<KeycapButton
label="Build"
shortcut="B"
pressed={building}
onClick={toggleBuild}
/>
<GameMeter label="Hull" value={7} max={10} tone="success" />
<PixelNumber text="08:00" size="lg" tone="primary" />
<PixelNumber value={3} signed outline tone="success" />
<ItemGrid
label="Backpack"
columns={8}
rows={6}
items={items}
ghost={ghost}
onCellActivate={pickUpOrDrop}
onCellHover={previewDrop}
/>
</>
);
}
  • Keycap is a native kbd. KeycapButton is a native button whose shortcut only labels the key. Passing pressed makes it a toggle with a lamp and aria-pressed. Your app owns the key binding. tone is a role: primary and accent tint the face from the theme colour, inverse is the dark key, and destructive stays neutral until hovered or focused. label={null} makes an icon-only key that needs an aria-label. The molded lip, sheen and press travel scale with --depth. Themes set --caps-keycap-{face,ink,edge,tint,width,min-width,height,justify,padding,font-size,font-weight}.
  • GameMeter pairs a labelled native progress bar with the current and maximum values. The game supplies both values and the tone.
  • PixelNumber draws a 3×5 face as SVG at integer scales and keeps the plain text for assistive tech. outline rings the lit pixels in the ink line so the number reads over any world.
  • ItemGrid is presentation. Cells carry the keyboard and pointer interface (arrows, Home and End, Enter and Space), and items and the drop ghost are drawn from your facts. It never moves, merges or validates an item. A refused ghost shows your reason under the tray and describes the grid with it. ItemSlot is one paper-doll pocket.

Game HUD pieces inherit their material and controls from a surrounding data-theme="paper" boundary. Nest it inside field-guide or field-guide-night, then optionally nest an ember or tide tint. Cards and plates do not reskin descendant Buttons. A complete nested palette can choose its own control finish.

TSX
import { Keycap } from "@fungi.computer/caps/components/field-guide";
import { Status } from "@fungi.computer/caps/components/status";
import {
Folio,
Masthead,
SectionBar,
} from "@fungi.computer/caps/components/technonomicon";
export function FrontPage() {
return (
<>
<Masthead
href="/"
wordmark="The Spore Print"
tagline="Notes from a very strange forest."
edition={["Saturday, September 26, 2026", "Issue I"]}
sections={
<SectionBar
aria-label="Sections"
sections={[{ href: "/", label: "Front Page", current: true }]}
/>
}
/>
<p>
Press <Keycap>J</Keycap> for the next story.{" "}
<Status tone="success" label="Online" />
</p>
<Folio items={["The Spore Print", "Page A1"]} />
</>
);
}

On a server-rendered page, turn on card flips and pencil marks once in the browser:

TS
import { enhanceCigaretteCards } from "@fungi.computer/caps/enhance/cigarette-card";
import { sketch } from "@fungi.computer/caps/enhance/sketch";
enhanceCigaretteCards();
sketch();