Staffa
A small, opinionated TypeScript component library for the Aberdeen reactive UI library.
import A from "aberdeen";import * as S from "staffa";
const $user = A.proxy({ name: "", email: "" });
S.main({ title: "Sign up", maxWidth: "40rem", content: () => { S.form({ submit: () => S.dialog({ header: "Submitted", content: () => A.dump($user) }), content: () => { S.textline({ label: "Name", required: true, bind: A.ref($user, "name") }); S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") }); }, actions: () => S.button({ content: "Create account", type: "submit" }), }); },});Staffa is made to look decent out of the box, but easily customizable at runtime.
Screenshot
Section titled “Screenshot”
Install
Section titled “Install”npm install staffa aberdeenAberdeen is a peer dependency. Staffa is published as ESM with TypeScript types.
How it works
Section titled “How it works”Components are functions
Section titled “Components are functions”Every component takes a single typed options object and draws DOM via Aberdeen. No classes, no web components. The S object collects all component functions:
S.button({ content: "Save", disabled: false });S.box({ header: "Settings", content: () => { ... } });Options objects are typed and can be reactive
Section titled “Options objects are typed and can be reactive”All components get their options in a typed object. The object may be an Aberdeen proxy, if you want to update the component in-place.
const $btn = A.proxy({ content: "Save", disabled: false });S.button($btn);setTimeout(() => // Later.. $btn.disabled = true; // button updates instantly}, 3000);Rich text slots
Section titled “Rich text slots”Anywhere a component takes content, a label, header, button text, dialog body, etc, you can pass either a string or a () => void draw function. Strings render as rich text: *italic*, **bold**, `code`, [link](https://github.com/vanviegen/staffa/blob/main/path). All text is safely escaped.
S.button({ content: "Save **now**" });S.box({ header: "See the [docs](https://github.com/vanviegen/staffa/blob/main/docs)", content: () => { ... } });Surfaces
Section titled “Surfaces”Staffa builds on surfaces: elements marked with .s-s that have their own background and derived text/border tokens. There are two families:
- Neutral surfaces —
.neutral(and the implicit page at:root). A calm neutral whose shade steps automatically with nesting depth (page → panel → raised, capped). Use them for cards, bars, popovers — anything that just holds content. No variants. - Accent surfaces —
.primary,.danger,.success,.warning,.link(a bare.s-sdefaults to primary). A bright fill with white ink, painted as a subtle single-colour gradient. They take a variant:.filled(default),.tonal, or.outlined. A surface nested inside an accent surface is always rendered filled, so it can’t bleed into the vivid parent.
Components are built from these (S.button is a .s-s.primary, S.box a .s-s.neutral, etc.). Because component options include an optional attrs string, which has Aberdeen A() string semantics, you can easily override it:
S.button({ content: "Delete", attrs: ".danger" });S.button({ content: "Cancel", attrs: ".neutral" }); // neutral buttonS.box({ attrs: ".primary", content: () => { ... } });Inside any surface (including :root), CSS variables are defined for the background and a set of safe foreground colors: $s-bg, $s-text (also applied as color), $s-muted, $s-accent (the surface’s “pop” — the brand primary on neutral surfaces, the ink on accent surfaces), and $s-faint. By using these, components adapt to wherever they’re nested.
The colour tokens are mode-independent and settable: $s-primary (the one brand colour — it tints the neutrals and defines .s-s.primary), $s-danger, $s-success, $s-warning, and $s-link (the link colour, which is also the fill of the .s-s.link surface). Links render in $s-link on neutral surfaces and in the ink on accent surfaces.
Borders & shadows. Neutral surfaces carry a subtle hairline border on their own (so a card looks like a card without any component help). Any surface can be lifted with .shadow or .extra-shadow: on a neutral surface that’s a neutral drop shadow, on an accent surface it’s a self-coloured glow (a lit button is just a .primary surface with .shadow), and on .tonal/.outlined it’s ignored. .no-shadow removes a component’s built-in shadow:
S.box({ attrs: ".extra-shadow", content: () => { ... } }); // a more raised cardS.button({ content: "Quiet", attrs: ".no-shadow" }); // drop the button glowDark and light modes
Section titled “Dark and light modes”Dark/light mode is detected from OS preference by default. If you want to override this (based on user preferences), use:
S.setDarkMode(true); // force darkS.setDarkMode(false); // force lightS.setDarkMode(undefined); // follow OSHint: A buttonChooser is probably the right component for a color scheme selector.
CSS reset
Section titled “CSS reset”Staffa includes a lightweight CSS reset that makes bare semantic HTML look a bit better but unsurprising without additional styling.
Theming
Section titled “Theming”The first step in theming is just setting some CSS variables. Everything derives from a single brand colour, s-primary (the neutral surface shades are tinted toward it too), so often that’s all you need. This can be done through CSS directly, or using Aberdeen:
A.cssVars["s-primary"] = "#fdda58";A.cssVars["s-danger"] = "#ee4422";A.cssVars["s-radius"] = "4px";See src/theme.ts for what other CSS variables are being used.
If you need further customization, just add some CSS to override the default styling. For instance, to add your own accent surface, set its background (and, if needed, its ink) — the subtle gradient and the rest of the tokens follow automatically:
A.insertGlobalCss({".s-s.my-surface": "--s-bg:#ef6b00 --s-text:#fff"});
S.button({ content: "You'll want to click me", attrs: ".my-surface", click: () => S.alert("Good work!", {attrs: ".my-surface"})});Custom surface class names may be anything (other than the built-in modifiers .tonal, .outlined, .small, .large). The .tonal and .outlined variants work on your surface for free.
Note that when changing CSS like this, things may break if you upgrade Staffa. The recommended update strategy is therefore: don’t!
If you want to make changes that are dependent upon the current light/dark mode setting, rely on Aberdeen reactivity:
A(() => { if (S.getDarkMode()) { A.cssVars["s-primary"] = "#aa9944"; A.insertGlobalCss({".s-s.my-surface": "--s-bg:#444444 --s-text:#fff"}); } else { A.cssVars["s-primary"] = "#fdda58"; A.insertGlobalCss({".s-s.my-surface": "--s-bg:#cccccc --s-text:#000"}); }});Components
Section titled “Components”Components share naming conventions for options: attrs (outermost element), contentAttrs (children-holding element), inputAttrs (form control element), and <region>Attrs (sub-regions like headerAttrs/footerAttrs). Form components consistently support label, help, error, disabled, required, name through the drawField() helper.
Layout & containers
Section titled “Layout & containers”S.main(opts): app shell, a sticky header withicon,title,subtitle,menu; scrollable content area; footer. SetmaxWidthto center the content.S.box(opts | content): surface with optionalheader/footerand padded body. Pass a function for shorthand{ content }.S.tabs(opts): tablist with live panels and keyboard navigation.S.form(opts | content): form aligning fields in a column or responsive grid, with anactionsbar. Prevents the default page reload.
Form fields
Section titled “Form fields”S.textline(opts): single-line input (text,password,email,number,tel,url,search, dates, …).S.textarea(opts): multi-line input.S.checkbox(opts): labelled checkbox.S.select(opts): single-select dropdown backed by native<select>(styled control, OS dropdown).S.autocomplete(opts): type-ahead combobox withmulti(chips),allowCustom(free text),required, and dynamicoptions.
Dialogs
Section titled “Dialogs”S.dialog(opts): modal dialog with backdrop and fade transition. Thecontentslot receives aclose()function. Lifecycle is tied to the calling scope (disappears when cleaned up). Nesting stacks correctly.S.alert(msg)/S.confirm(msg)/S.prompt(msg, initial?): promise-returning shortcuts.
Actions
Section titled “Actions”S.button(opts | text): button surface; restyle viaattrs(e.g..danger,.outlined), plussize,disabled,icon,href(renders<a role=button>). Defaults to filled.primary.S.buttonGroup(opts): groups buttons,attached(segmented) orspaced.S.buttonChooser(opts): single-select segmented control bound to a value.
Staffa ships the full Lucide icon set as named exports. Import only the ones you use, so a bundler tree-shakes the rest (the whole set is ~82 kB gzipped):
Each icon is a draw function usable anywhere a slot is accepted (e.g. a button icon), or called directly. Customize per call, or globally via setDefaults():
import * as S from "staffa";import { sparkles, bell } from "staffa/icons";S.button({ content: "Save", icon: bell });sparkles({ size: "1.5em", color: "var(--s-primary)", strokeWidth: 1.5 });Options: size, color (defaults to currentColor), strokeWidth, cap, join, attrs.
S.menuButton(opts)/S.addContextMenu(opts)/S.showFloatingMenu(opts): dropdown menus from a button, right-click/long-press context menus, and the underlying floating menu primitive — with keyboard navigation.S.toast(opts): transient notification at the bottom of the viewport.S.addTooltip(el, opts): tooltip on hover, attached to an existing element.
Two-way binding uses Aberdeen proxies: pass bind: A.ref($obj, "key") to form fields.
Browser (no bundler)
Section titled “Browser (no bundler)”staffa/all.js is a pre-built ESM bundle. Use an import map:
<script type="importmap">{ "imports": { "aberdeen": "https://cdn.jsdelivr.net/npm/aberdeen/dist/src/aberdeen.js", "staffa/all.js": "https://cdn.jsdelivr.net/npm/staffa/dist/staffa.esm.js" }}</script><script type="module"> import A from "aberdeen"; import * as S from "staffa/all.js"; // ...</script>It includes all components, but not the icons.
Extending Staffa
Section titled “Extending Staffa”Staffa is designed for extension. A component is simply a plain function taking a typed options object and drawing Aberdeen DOM. This section explains the philosophy so extensions follow the same patterns.
Design principles
Section titled “Design principles”-
Components are functions. They take one typed options object, emit Aberdeen DOM, and usually return nothing.
-
Reuse option types. Define options by extending
ContentOptions(for layout components) orFieldOptions(for form controls) fromsrc/core.tsandsrc/components/field.ts. Don’t reinvent fields likeattrs,label,help, etc. -
Reach for reactivity deliberately. Pass option strings straight to
Aas positional args (the caller’s scope). Only wrap a dedicatedA(() => ...)scope where it matters: input elements (recreation loses focus), or large subtrees you don’t want to redraw. UseA.peek(() => ...)when you need a value but must not subscribe. -
Build on surfaces. Mark elements
.s-sand add.neutralor an accent role (.primary,.danger, …) plus an optional variant. Inside them, use the contextual CSS variables ($s-text,$s-bg,$s-muted,$s-accent,$s-faint, …) so components adapt to wherever they’re nested. Hard-coding colors in components shouldn’t be needed, but if you must, make sure you set both foreground and background. -
No outer margins. Components don’t margin themselves; spacing is the parent’s job. Content components set default
paddingon the content element;contentAttrsoverrides it. -
Make everything styleable. Provide
attrs,contentAttrs,inputAttrs, and<region>Attrshooks so callers can customize. Applyattrslast so it can override component classes. -
Use semantic HTML and ARIA. Prefer native elements (
<button>,<label>,<form>,<section>) and native behaviour. Add ARIA only where semantics fall short (e.g. tabs, combobox). -
Use CSS. Use
A.insertGlobalCss({...})at module top level to provide (nested) CSS styling for your component. Give your top-level element thes-<component-name>class. Avoid inventing further classes; lean on nesting (&for the element, bare key for descendants) and element/structural selectors. -
Reuse form controls. Use
drawField()and callapplyControlAttrs(). -
Function over form. Provide enough contrast. Stick to UI conventions to help users; buttons have a rounded border, links are underlined, text input background is white, etc.
Adding a component to Staffa
Section titled “Adding a component to Staffa”The previous section is good advice for any project-specific custom, but should definitely be followed for any new components to be included in Staffa. In addition, you’d want to:
- Create
src/components/<name>.ts. - Define
<Name>OptionsextendingContentOptions,FieldOptions, or a plain interface. Add TSDoc on every option. - Add a TSDoc
@exampleon the function. - Register in
src/index.ts(theSobject + type re-export). - Extend
smoke.mjsto render it. Runnpm run smokeandnpm run build.
See src/components/button.ts and src/components/dialog.ts for examples.
Commands
Section titled “Commands”npm run build # compile TypeScript to dist/npm run typecheck # check typesnpm run smoke # render every component in jsdomnpx http-server # allows demo to be viewed at http://localhost:8080/demonpx shotest test # visual tests: click through the demo, screenshotting every stepnpx shotest review # review/accept the visual changes against the baselineThe visual tests (tests/*.spec.ts) need a build first (npm run build); they serve the repo root themselves and click through every demo page. Accepted baselines live in test-accepted/.
AI skill
Section titled “AI skill”If you use Claude Code, GitHub Copilot or another AI agents that supports Skills, Staffa includes a skill/ directory that provides specialized knowledge to the AI about how to use the library effectively.
To use this, it is recommended to symlink the skill into your project’s .claude/skills directory:
mkdir -p .claude/skillsln -s ../../node_modules/staffa/skill .claude/skills/staffaBreaking changes
Section titled “Breaking changes”-
0.7 — the surface model was reduced to two families: neutral (
.neutral) and accent (.primary/.danger/.success/.warning/.link). Apps that only use the high-levelS.*components need no changes. Code that uses surface classes or tokens directly must update:- Surface levels gone. Replace
.base/.panel/.raised/.neutral/.nestwith the single.neutralclass. .secondaryand.gradientgone. Drop anys-secondarycolour override; there’s nos-secondaryanymore. The default button is now.primary.- Tokens renamed.
--s-fg→--s-text,--s-fg-muted→--s-muted,--s-border→--s-faint. Removed:--s-fg-faint,--s-border-strong,--s-ink,--s-on-accent,--s-page/--s-panel/--s-raised,--s-neutral,--s-tint,--s-glow,--s-shadow,--s-gradient-surface. A custom surface now sets--s-bg/--s-text(was the--s-a/--s-banchors). - Borders/shadows moved onto surfaces. Components no longer draw their own border/shadow. If you relied on
S.box/S.dialog/etc. elevation, it now comes from the surface; pass.no-shadowto drop it, or.shadow/.extra-shadowto add it on any surface.
- Surface levels gone. Replace
-
0.6: None.
-
0.5
- Surfaces (
.s-s) now applyborder-radiusand — for.tonaland.outlinedvariants —borderautomatically. Custom surfaces or components that previously set these manually may see doubled or conflicting styles; remove the manual declarations. border:0is now applied to.s-btnby default (overriding the browser’s 2px button border). Custom button-like components built on.s-btnthat relied on the browser default border should add an explicit border.
- Surfaces (
-
0.4
- There is no default export anymore: replace
import S from "staffa"withimport * as S from "staffa". S.buttonno longer has atextoption: usecontentinstead (it accepts a string or a draw function).- The
Contenttype is gone: useSlotinstead. TheStylingtype alias is now exported asAttributes. S.buttonChooserusesundefinedinstead ofnullfor “nothing selected” (inbindand withallowDeselect).
- There is no default export anymore: replace