Adding a component

This page is for working on the library itself. If you only want to use a component in your own project, the installation page is what you want.

1. The catalogue

scripts/catalogue.mjs is the list of what this library is, independent of how any style draws it. Adding a line there is what makes the component appear in the sidebar, the registry, the ⌘K palette and the CLI.

scripts/catalogue.mjs
// scripts/catalogue.mjs — the list every style must implement
export const COMPONENTS = [
	// …
	['Callout', 'feedback', 'Short aside that sits inside running copy.'],
	// …
];

Write the description in terms of what the component does, never what it looks like — every style shares this one line.

2. Behaviour goes to core

Anything with no opinion about looks — keyboard handling, focus containment, parsing, sorting, validation — belongs in src/lib/core/. There is no matte version of a focus trap, and writing one per style is how the styles quietly drift apart in quality.

src/lib/core/callout.ts
// src/lib/core/callout.ts — anything with no opinion about looks
export function calloutRole(tone: Tone): 'alert' | 'status' {
	return tone === 'danger' ? 'alert' : 'status';
}

3. One file per style

Svelte 5 runes, a Props interface extending the matching HTML attributes, class merged last so callers can override, and ...rest spread onto the root element. The props and the behaviour must match across styles; the markup and the classes are free to differ completely.

src/lib/styles/matte/Callout.svelte
<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLAttributes } from 'svelte/elements';
	import { toneSoft, type Tone } from '../../core/tones.js';

	interface Props extends HTMLAttributes<HTMLDivElement> {
		tone?: Tone;
		children: Snippet;
	}

	let { tone = 'brand', class: className = '', children, ...rest }: Props = $props();
</script>

<div class="p-4 font-sans {toneSoft[tone]} {className}" {...rest}>
	{@render children()}
</div>

Take colours from the tone maps, never as raw hex. Any user-visible string the component renders on its own — a label, an aria-label, an empty-state line — is written inline in English, and exposed as a prop when a caller would plausibly want to change it.

4. A demo

Write it once, against matte. It is rendered live on the component's page and shown as the code sample, so the two can never drift apart. bun run demos copies it into every other style with the import path rewritten — which is why the /paper page really mounts paper's component rather than repainting matte's.

src/site/demos/callout.svelte
<script lang="ts">
	import Callout from '$lib/styles/matte/Callout.svelte';
</script>

<Callout tone="accent">Something worth noticing.</Callout>

5. Regenerate

terminal
bun run index        # regenerates every style's barrel
bun run registry     # fails if a style is missing the component
bun run exports      # adds the subpath for each style
bun run lint         # fails if any of the three is stale
bun run build        # rebuilds dist/

Every style's barrel is generated, so there is no export list to keep in step by hand. After this the component appears in the nav, the ⌘K palette and the CLI without further wiring.

Traps that fail silently

Interpolated class names

Tailwind scans source text. A class it never sees written out is never generated — no error, just an unstyled element.

never do the first
<!-- Tailwind only sees literal class strings -->
<div class="bg-{tone}">…</div>          <!-- generates nothing -->
<div class={toneFill[tone]}>…</div>     <!-- correct -->

Two colours for one property

Which wins is decided by the order the rules appear in the stylesheet, not the order in your class attribute. Set the colour once per variant, or use a side-specific utility.

the fix is on the right
<!-- two colours for the same property: CSS order decides, not class order -->
border border-hairline border-brand     <!-- unpredictable -->
border border-hairline border-l-brand   <!-- fine -->

The guards catch the structural half

bun run lint fails when a style is missing a component from the catalogue, and when the barrels, the exports map or the registry are stale — so a component cannot ship in one style and not another. The parity suite then mounts every style and asserts they behave the same. None of that can catch a missing Tailwind class; only looking at the result does.
esc
Accordion Wrapper that stacks collapsible items.
AccordionItem A single collapsible row, built on <details> so it works without JS.
Alert Inline message in any tone, optionally dismissible.
Avatar Image or initials, circular or squared, in five sizes.
AvatarGroup Overlapping row of avatars, each ringed in the background.
Badge Pill label for status and counts. Solid, soft or outline.
Breadcrumb Trail of links to the current page.
Button Five variants and four sizes, with a loading state and an href mode.
Calendar Month grid with keyboard navigation, bounds and disabled days.
Card Surface with an optional header and footer, in three variants.
Checkbox Single checkbox with a label, description and indeterminate state.
CheckboxGroup A set of checkboxes bound to a string array, with an optional cap.
ColorInput Hex field paired with the native swatch and fixed choices.
Combobox Single-select with a text filter and keyboard navigation.
CommandPalette Cmd+K launcher with grouping, shortcuts and hidden keywords.
ConfirmDialog Small dialog that stays busy until an async confirm settles.
ContextMenu Right-click menu placed at the pointer, flipped near the edges.
CTASection Closing call to action, in three variants.
CurrencyInput Amount field that groups on blur and binds a number.
DatePicker Text field plus a calendar popover. Value is a YYYY-MM-DD string.
Divider Rule, horizontal or vertical, with an optional caption.
Drawer Panel that slides in from any edge, on the native dialog top layer.
Dropdown Menu anchored to a trigger, with focus trap and viewport flipping.
Dropzone Drag-and-drop file input with type, size and count validation.
EmptyState Placeholder for an empty list, with a glyph and an action slot.
FeatureCard One feature: glyph, heading, copy. Optionally a whole link.
FeatureGrid Section wrapper that lays feature cards out in 2, 3 or 4 columns.
Field Wraps any control with a label, hint and error message.
FileInput One-line file picker with type, size and count validation.
Footer Site footer with link columns and a bottom bar.
HeroSection Page opener — eyebrow, headline, copy, actions, optional media column.
Input Text field with prefix and suffix slots, sizes and an invalid state.
InputAddon Fixed label joined to an input inside an InputGroup.
InputGroup Joins an input to a button or addon with a single shared border.
Kbd Keyboard key.
Label Form label with an optional required marker.
Link Anchor in any tone, with an external variant.
LogoCloud Row of client or partner marks, dimmed until hovered.
MenuItem Row inside a Dropdown or ContextMenu, with a shortcut hint.
MenuSeparator Rule between menu groups, with an optional caption.
Modal Centred dialog on the native top layer, with a footer slot.
MultiSelect Multi-select with chips, grouping and a selection cap.
Navbar Top bar that collapses into a drawer below the md breakpoint.
Pagination Page numbers that collapse to an ellipsis around the current one.
PinInput One box per character, with paste spreading and arrow keys.
Popover Anchored panel that opens on click or hover.
PricingCard One plan: price, period, ticked feature list, optional featured styling.
Progress Determinate or indeterminate bar, in three sizes.
Radio Single radio bound to a shared group value.
RadioGroup A set of radios, optionally rendered as selectable cards.
SearchInput Search field with a clear button and a debounced callback.
SegmentedControl Exclusive choice as one joined row, with arrow-key movement.
Select Native select with a chevron and data-driven options.
Sidebar Nested, collapsible navigation rail for an app shell.
Skeleton Pulsing placeholder in text, block or circle form.
Slider Range input with ticks and a printed value.
Spinner Rotating ring that inherits the surrounding colour.
Stat Figure with a label, delta and trend arrow.
StatsBand A row of figures, built on Stat.
Steps Progress through a flow, horizontal or vertical, with a failed state.
Switch On/off toggle with a label and description.
Table Data-driven table with sorting, row selection and a cell snippet.
Tabs Underline, pill or segmented tabs bound to a value.
TagsInput Free-text tokens committed on Enter, deduplicated and capped.
Testimonial A quote with an attributed author.
Textarea Multi-line field that can grow with its content.
ThemeToggle Light, dark and system, remembered in localStorage.
TimeInput HH:MM field with steppers, bounds and a minute step.
Toaster Renders the toast queue. Mount once, near the root.
Tooltip CSS-only label on hover and focus, in four placements.