@5voltfx/design-system
v0.6.1
Published
A shared React component library and token system for the three 5VoltFX/Matt Welker sites — `song-chart`, `mattwelker.com`, and `5voltfx.com`. Plain React 19 and plain CSS custom properties, no UI framework.
Readme
@5voltfx/design-system
A shared React component library and token system for the three 5VoltFX/Matt Welker
sites — song-chart, mattwelker.com, and 5voltfx.com. Plain React 19 and plain
CSS custom properties, no UI framework.
The system has a point of view: it is drawn as a drafting document. Components
are specimens on a ruled ground, named and specified. Two of the eight themes
(instrument, instrument-day) lean all the way into that; the other six are
painted themes that inherit the same structure with a softer finish.
Seeing it
The playground/ app is the live style guide. It consumes the package exactly the
way the three sites do, so what you see there is what you get.
cd playground
npm install # first time only
npm run dev # http://localhost:5173/Theme switcher is in the footer — all eight themes, live.
| Route | What it shows |
|---|---|
| / | Tokens — color families, type scale, spacing, elevation, radius, motion |
| /primitives | Button, Card, Badge, Heading, Text, Container/Stack/Grid |
| /plates | Plate anatomy — Plate, Specs, Caption, Readout, Chapter |
| /forms | FormField, Label, Input, Textarea, Select, Checkbox, Radio, Switch |
| /overlays | Modal, Menu, Tabs, Accordion |
| /navigation | SlideRuleNav and DialNav, all six variants |
Working on the library and the playground at once
The playground depends on the package via file:.. and resolves to dist/, not
src/. So run the library's watch build in a second terminal:
npm run dev # in the repo root — tsup --watch, rebuilds dist/The playground's Vite config uses the watchDesignSystem() plugin (exported from
@5voltfx/design-system/vite-plugin) to watch dist/ and force a full reload.
Without both halves running, source edits will not appear.
Other scripts:
npm run build # tsup + tsc — bundles dist/ and emits the .d.ts files
npm run typecheck # tsc, no emit
npm test # vitestnpm run dev does not regenerate type declarations. It runs tsup only;
the .d.ts files come from the tsc half of npm run build. Watch mode leaves
the existing declarations in place rather than deleting them, so editor
autocomplete keeps working — but it goes stale the moment you change a
component's props. Run npm run build before publishing, or whenever you want
types that match the source.
Blank page? Clear the Vite cache
rm -rf playground/node_modules/.vite && cd playground && npm run devVite pre-bundles dependencies into node_modules/.vite/deps and stamps every
dep URL with a browserHash recorded in deps/_metadata.json. That cache is
keyed off the lockfile and config — not off the contents of a file:-linked
package. So when dist/ gains a new export, the cache doesn't notice, the
browser links against the old module, and you get a blank page with one console
error naming the missing export:
SyntaxError: The requested module '/node_modules/@5voltfx/design-system/dist/index.js?v=<hash>'
does not provide an export named 'Badge'Misleading, because the export is there — grep 'Badge' dist/index.js finds it,
and so does curling the URL. Only the browser's module graph is behind. A hard
reload does not clear it; deleting .vite does.
Expect this in the consuming sites too, for the same reason. Suspect it whenever a component that exists everywhere on disk is "not exported."
Installing in a site
npm install @5voltfx/design-systemimport "@5voltfx/design-system/styles.css"; // tokens + all component CSS
import { ThemeProvider, Header, Button } from "@5voltfx/design-system";ThemeProvider must wrap the app — it sets data-theme on <html>, which is
what every token resolves against. Components that link (Header, Nav,
SlideRuleNav, DialNav with to) need a react-router-dom Router above them.
Fonts are not injected. The package deliberately ships no font-loading side
effects. Add the <link> yourself:
<!-- Space Grotesk — the body face for the six painted themes -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap">
<!-- Only if you use the instrument themes -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo+Narrow:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Source+Serif+4:ital,wght@0,400;0,600;1,400;1,600&display=swap">Peer dependencies: react ^19, react-dom ^19, react-router-dom ^7.
Vocabulary
Words this system uses in a specific way. Using them precisely is the fastest way to be understood — by a person or by Claude.
Plate — a documented specimen. A Card with a head (number, name, kind), a
stage (the thing itself), and a foot (specs, caption). Rendered as a <figure> so
the specification is bound to the specimen, not floating after it.
Ground — the ruled desk a plate sits on. Painted from --ds-color-ground-grid,
which is zero-alpha in every theme that doesn't want one. Lands on html/body
automatically, or on any .ds-ground container.
Finish — the shape and shadow a theme is machined to: corner radius, shadow, registration marks. The instrument themes override it to hard 3px corners and a hard offset shadow. Palette alone can't get there — a 16px-rounded card under a blurred shadow reads as soft whatever colors are in it.
Rule — the component tokens for the two slide-rule navigations (--ds-rule-*).
The three voices — a drafting document doesn't set everything in one face:
| Token | Role |
|---|---|
| --ds-font-display | names things — headings, control labels |
| --ds-font-mono | reads values — specs, readouts, numerals |
| --ds-font-note | explains — body prose, captions |
All three default to --ds-font-body, so the six painted themes pay nothing for
them. The instrument themes opt in: Archivo Narrow / JetBrains Mono / Source Serif 4.
The accent's two jobs — --ds-color-bg-2 is the accent as a fill (something
must be readable on it, via --ds-color-on-accent); --ds-color-font-2 is the
accent as text (it must be readable on the surface behind it). These pull in
opposite directions. Never substitute one for the other — say which you mean.
Station — one selectable position on SlideRuleNav or DialNav. Between 2 and
8; past 8 the extras aren't rendered and the nav warns in dev.
Detent — the notched travel between stations, at quarter-tick intervals.
Tokens
All tokens are CSS custom properties prefixed --ds-, defined in src/tokens/
and imported in that order by src/tokens/index.css.
Color
Every theme overrides the full set inside its own [data-theme="…"] block. The
:root values are the default (coral) theme, so components render sensibly before
ThemeProvider runs.
| Family | Meaning |
|---|---|
| --ds-color-bg-0 … bg-4 | Surfaces. bg-0 is the page ground, bg-2 is the accent as fill |
| --ds-color-font-0 … font-4 | Text. font-0 is primary copy, font-1 muted, font-2 the accent as text |
| --ds-color-bw-0 … bw-4 | Neutral ramp — borders, rules, dividers |
| --ds-color-on-accent | Text that sits on bg-2. Chosen per theme, not pinned to font-0 |
| --ds-color-url, --ds-color-visited | Link states |
| --ds-color-highlight | Selection/emphasis |
| --ds-color-ground-grid | The ruled grid. Zero-alpha unless a theme opts in |
Status colors (semantic.css) are theme-agnostic: --ds-color-danger, -success,
-warning, -info, each with a matching --ds-color-on-*. All four fills carry
white. instrument-day darkens all four, because it's the one genuinely light
surface and they landed between 2.0:1 and 3.3:1 on it.
Form chrome is single-sourced so every control matches: --ds-color-field-bg,
--ds-color-field-border, --ds-color-focus-ring.
Typography
Modular scale, ~1.25 ratio, base 1rem:
--ds-font-size-100 (0.8rem) → --ds-font-size-700 (3.052rem).
Weights --ds-font-weight-regular|medium|semibold|bold. Line heights
--ds-line-height-tight|normal|relaxed. Display tracking via
--ds-tracking-display (normal by default; 0.06em on the instrument themes,
because condensed faces want air).
Spacing
--ds-space-1 (0.25rem) through --ds-space-8 (4rem):
0.25 / 0.5 / 0.75 / 1 / 1.5 / 2 / 3 / 4 rem.
Stack and Grid take the bare number as their gap prop — gap="5" resolves to
var(--ds-space-5).
Radius, elevation, motion, z-index
| Group | Tokens |
|---|---|
| Radius | --ds-radius-sm 6px, -md 10px, -lg 16px, -pill 999px |
| Elevation | --ds-shadow-sm|md|lg — tinted with the theme accent at low opacity, not flat black |
| Motion | --ds-motion-duration-fast 120ms, -base 200ms, -slow 320ms; --ds-motion-ease-standard |
| Z-index | --ds-z-base 0, -dropdown 1000, -sticky 1100, -overlay 1200, -modal 1300, -popover 1400, -toast 1500 |
The instrument themes reduce radius to 2–3px and swap the diffuse shadow for a hard
offset one. --ds-radius-pill is deliberately left alone — a caller asking for a
pill is asking for a specific geometry, not the theme's default corner.
Themes
Eight, applied by ThemeProvider setting data-theme on <html>.
| id | Character |
|---|---|
| coral | Creative warm — the default |
| ink | Dark indigo |
| sage | Muted olive |
| midnight | High contrast |
| amber | Golden |
| harvest | Earthy warm |
| instrument | Drafting, dark stock |
| instrument-day | Drafting, celluloid — the one genuinely light theme |
The first six are painted themes: palette only, soft finish. The two instrument themes additionally take the three type voices, hard corners, an offset shadow, corner registration marks, and the ruled ground.
All eight are measured at zero WCAG AA failures across tokens, primitives,
plates, forms, overlays, and navigation. src/tokens/themes.test.js asserts this —
if you add or move a color, that suite is the gate.
<ThemeProvider storageKey="myapp.theme" defaultTheme="instrument">| Prop | Default | Notes |
|---|---|---|
| storageKey | "ds.theme" | localStorage key; syncs across tabs |
| defaultTheme | "coral" | |
| onThemeChange | — | Called with the new theme id |
useTheme() returns { theme, setTheme, themes } and throws outside a provider.
THEMES, THEME_IDS, and DEFAULT_THEME are exported for building your own picker.
Components
Everything imports from the package root. All components take className and
forward unknown props to their root element. Many take as to change the tag.
Primitives
| Component | Key props |
|---|---|
| Button | variant primary|secondary|ghost · size sm|md|lg · shape sharp|soft|rounded|pill · icon · as |
| Card | as |
| Badge | variant solid|outline|accent|brass|good|warn|quiet · as |
| Heading | level 1–6 · size 200–700 (defaults from level) · decorative |
| Text | as · size 100|200|300 (default 200) · tone default|muted |
Heading is set in the display voice and Text in the note voice, so the
instrument themes get the type split for free without any component change.
Button is flat and 2D on purpose — no elevation, no hover lift. Hover and press
are communicated with color only. Its icon wrapper is aria-hidden, so the label
text must carry the meaning.
Layout
| Component | Key props |
|---|---|
| Container | as — centered 1200px max-width, responsive gutters |
| Stack | gap (space step, default "4") · direction (default column) · align · as |
| Grid | columns (default 2) · gap (default "4") · as |
Plate anatomy
The system's distinctive layer — a documented specimen and the parts that describe it.
<Plate
number="L-I"
name="Cursor rule"
kind="Top nav"
specs={[["Stations", "5"], ["Travel", "Detented"]]}
caption="A fixed scale with a cursor that travels over it."
>
<SlideRuleNav variant="cursor" items={items} />
</Plate>| Component | Key props |
|---|---|
| Plate | number · name · kind · specs (array of [key, value]) · caption · as (default figure) |
| Specs | items — array of [key, value]; renders a <dl> |
| Caption | as (default p) — the explaining voice, in prose |
| Readout | fields [{label, value, kind}] · status {text, tone: "lock"\|"drift"} |
| Chapter | as (default h2) — a section rule with a label set into it |
Every part of Plate is optional. With none of them it is a Card, which is what
it's built on — so it inherits the theme's finish, registration marks included.
Readout is presentational and announces nothing. A component whose value changes
during a drag owns its own live region, so only committed changes are announced.
Chrome
| Component | Key props |
|---|---|
| Header | brand · brandHref (default /) · links · actions |
| Nav | links — [{ to, label, end }], active state via NavLink |
| Footer | brand · showThemeSwitcher (default true) · left · right |
| ThemeSwitcher | — swatch row, one per theme |
Header and Footer are shells with slots. Each site's bespoke chrome —
song-chart's auth-aware menu, mattwelker.com's audio toggle and Venmo link,
5voltfx.com's logo fallback — gets passed in rather than encoded here.
Forms
| Component | Key props |
|---|---|
| FormField | label · help · error · required · htmlFor · single control as child |
| Label | required |
| Input | size sm|md|lg · invalid · all native <input> props |
| Textarea | rows (default 4) · invalid |
| Select | size · invalid · <option> children |
| Checkbox | label |
| Radio | label · value |
| RadioGroup | name · value · onChange · options [{value, label}] or Radio children |
| Switch | label |
FormField does the accessibility wiring: generates an id, links the label via
htmlFor, connects help/error through aria-describedby, and sets
aria-invalid plus the control's invalid styling when error is present. Prefer
it over hand-wiring.
<FormField label="Email" help="We never share it." error={err} required>
<Input type="email" value={v} onChange={onChange} />
</FormField>Every control is a styled native element — Checkbox, Radio, and Switch put a
styled indicator over a real <input>, and Select stays a real <select> — so
keyboard, focus, and toggle behavior are native and free.
Overlays
| Component | Key props |
|---|---|
| Modal | open · onClose · title · size sm|md|lg |
| Menu | trigger · items [{label, onSelect, disabled}] · align start|end |
| Tabs | items [{id, label, content, disabled}] · value/defaultValue · onChange |
| Accordion | items [{id, title, content}] · multiple · defaultOpen |
Modal portals to document.body and handles focus trap, Escape, backdrop click,
scroll lock, and focus restore. Menu uses roving focus with arrow keys and closes
on outside click or Escape. Tabs is uncontrolled unless you pass value; arrows
move selection, Home/End jump to the ends.
Slide-rule navigation
The signature pieces — navigation drawn as instrument mechanisms, with detented travel between stations.
<SlideRuleNav variant="vernier" items={items} readout label="Main" />
<DialNav variant="volvelle" items={items} label="Sections" />| Component | Variants | Key props |
|---|---|---|
| SlideRuleNav | cursor · slide · vernier (default) | items · value/defaultValue · onChange · readout · label |
| DialNav | volvelle (default) · concentric · fan | items · value/defaultValue · onChange · expanded/defaultExpanded · onExpandedChange · label |
Items are { id, label, to?, end?, onSelect?, value? }. With to they render
NavLinks and need a Router; without, they call onSelect.
SlideRuleNav is the linear form, meant for a top nav. cursor and vernier hold
a fixed scale and travel a cursor over it; slide inverts that — the index is
fixed and the slide itself travels, carrying its stations with it.
DialNav is the circular form, meant for a sidebar that extends on hover. volvelle
spins its rail under a fixed index; concentric is two-level; fan holds its
spokes still and sweeps a pawl, righting only the indexed label.
Both clamp to 2–8 stations and respect prefers-reduced-motion. An unknown
variant falls back to the default and warns in dev rather than throwing.
Conventions
Class names are BEM-ish and ds--prefixed: ds-button, ds-button--primary,
ds-button__icon. Block, --modifier, __element.
Every component takes className and merges it after its own classes, so a
consuming site can always override.
Polymorphism via as where the tag is a legitimate choice (Button as="a",
Chapter as="div"). Not everywhere — Plate's foot switches between figcaption
and div based on as, because figcaption is only valid inside a figure.
Structure over decoration. Specs is a <dl> because each key names a property
and each value gives its measurement. Plate is a <figure>. Chapter is a
heading by default, because a document divider that isn't in the outline is a
decoration.
No side effects beyond CSS. No font loading, no global listeners at import time.
sideEffects is declared as **/*.css only, so tree-shaking works.
Contrast is a gate, not a preference. All eight themes are at zero AA failures
and themes.test.js enforces it.
Layout of the repo
src/
index.js # the public surface — every export, grouped
tokens/ # CSS custom properties, imported in order by index.css
colors themes base ground typography spacing radius
elevation motion zindex semantic rule finish
theme/ # ThemeProvider, useTheme, themeConstants
components/<Name>/ # Name.jsx, Name.css, index.js, Name.test.jsx
_shared/ # useDetentTravel, useReducedMotion, validateStationCount
playground/ # the live style guide (see "Seeing it")
vite-plugin.js # watchDesignSystem() for local cross-repo HMRComponent tokens live with their components' concern — the slide-rule navs read
--ds-rule-* from tokens/rule.css, whose defaults are expressions over the
bg/font/bw families, so both navs re-skin under every theme with no per-theme work.
Releasing
npm publish requires a manual version bump in package.json first — there is no
version script. prepublishOnly runs the build.
