Examples
Product-neutral React primitives and blocks for Fungi interfaces
Examples
Section titled “Examples”Button
Section titled “Button”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 with an async submit
Section titled “Composer with an async submit”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.
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.
Window
Section titled “Window”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> );}App bar
Section titled “App bar”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.
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 beads and dashboard controls
Section titled “Status beads and dashboard controls”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.
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.
Status tray
Section titled “Status tray”@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.
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:
StatusTrayRowis one line: the App’s ownicon(a Sigil, a Lucide glyph or aStatus), theprimarytitle, a mutedsecondarydetail that truncates, thenactions.- Actions are quiet text buttons:
<Button variant="ghost" size="xs">. A destructive one such as Stop usesvariant="ghost-destructive", which turns red on hover or focus. Give a row one action where you can. - Precede a
StatusTrayListwith aDataListHeadingfor a small group label. StatusTrayNoteis the empty state. It reads “All caught up.” unless you pass other text. For an unavailable feed, give it atitle; 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-trackingand--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 pieces
Section titled “Game pieces”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.
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} /> </> );}Keycapis a nativekbd.KeycapButtonis a native button whoseshortcutonly labels the key. Passingpressedmakes it a toggle with a lamp andaria-pressed. Your app owns the key binding.toneis a role:primaryandaccenttint the face from the theme colour,inverseis the dark key, anddestructivestays neutral until hovered or focused.label={null}makes an icon-only key that needs anaria-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}.GameMeterpairs a labelled native progress bar with the current and maximum values. The game supplies both values and the tone.PixelNumberdraws a 3×5 face as SVG at integer scales and keeps the plain text for assistive tech.outlinerings the lit pixels in the ink line so the number reads over any world.ItemGridis 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.ItemSlotis 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.
Editorial pieces
Section titled “Editorial pieces”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:
import { enhanceCigaretteCards } from "@fungi.computer/caps/enhance/cigarette-card";import { sketch } from "@fungi.computer/caps/enhance/sketch";
enhanceCigaretteCards();sketch();