Skip to content

Examples

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. Its input and action are your controls, and so are their labels, disabled state, keyboard behaviour and effects. An avatar or other leading content goes in the leading slot as any React node.

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 join-item 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 joins the controls.

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.

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.

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 a ghost, circular, extra-small control. It keeps its grid size on phones and touch screens, and an invisible hit area reaches --caps-touch-target instead. A disabled ghost button stays faceless and dims to --caps-disabled-ink. Buttons with a face keep the outlined plate. ScrollToBottomBlob observes a column-reverse scroller you supply, and you own the jump callback.

@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>
),
},
]}
/>
);
}

The tray is built from Caps parts. A Popover is the housing, a Display is the inset screen, Status is each LED and row actions are KeycapButtons (tone="primary" for the one accented key, tone="destructive" for a quiet Stop that turns red on hover or focus). StatusTrayList puts one Display behind its rows and seats each row’s keys on the bezel beside it. StatusTrayNote is a short sentence on its own Display. Give it a title for an unavailable feed (“No signal”), which is not a zero count.

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. Themes can set --caps-status-tray-chip-*, --caps-status-tray-count-bg, --caps-status-tray-title-{case,tracking}, --caps-status-tray-width, --caps-status-tray-key-{face,tint,height}, --caps-status-tray-readout-{font,divider}, --caps-status-tray-row-padding, --caps-status-tray-action-gap, --caps-status-tray-row-wrap, --caps-status-tray-chips-display, --caps-status-tray-note-{height,tracking}, --caps-status-tray-muted and --caps-status-tray-screen-{bg,fg,bg-active,fg-active}. Defaults use Stipe tokens.

Game UI uses the same plastic material as the rest of Caps, plus the ink line that pixel art is drawn with. --caps-ink resolves to a theme’s --stipe-ink, or to a near-black line in light themes and the deepest base in dark ones.

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, ink 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.
import { Keycap, StatusLed } from "@fungi.computer/caps/components/field-guide";
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.{" "}
<StatusLed active 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();