Skip to content

Component Library

Ankit Upadhyay edited this page Aug 13, 2026 · 1 revision

Component library

This page covers @openrunic/ui: the 22 components it exports, how they consume design tokens, and what adding a new one involves. It is for anyone building a screen or extending the library.

For the visual language the library implements, see Design system.

The package

packages/ui/
├── src/
│   ├── index.ts                  the barrel, alphabetical
│   ├── types.ts                  the shared prop vocabulary
│   ├── components/
│   │   ├── _index.css            one @import per component, alphabetical
│   │   └── <Name>/
│   │       ├── <Name>.tsx
│   │       ├── <Name>.css
│   │       ├── <Name>.stories.tsx
│   │       ├── <Name>.test.tsx
│   │       └── index.ts
│   ├── lib/                      cx, lucide resolution, useFieldId
│   └── styles/
│       ├── index.css             the fixed import order
│       └── tokens/               fonts, colors, typography, spacing,
│                                 radius, elevation, motion, base
└── .storybook/

Published shape: ESM, sideEffects: ["**/*.css"], two export entries (. and ./styles.css), React 19 as a peer dependency, and lucide-react as the only runtime dependency.

The 22 components

Actions

Component Notes
Button Five variants: primary, secondary, ghost, inverse, danger. Three sizes. Renders an anchor when href is set, a button otherwise.
IconButton Square, icon-only. label is required and becomes both the accessible name and the tooltip.

Forms

Component Notes
Input Label, hint, error, leading icon, suffix, and a monospace option for identifiers.
Select A native select in brand chrome. Accepts strings or option objects.
Checkbox Multi-select or consent. The real input stays in the accessibility tree.
Radio One choice of two to five, grouped by shared name, using native arrow-key roving.
Switch An immediate-effect setting. A button with role="switch", not a checkbox, because it takes effect on toggle rather than on save.

Surfaces

Component Notes
Card A raised section with four surface tones and a configurable heading level, labelled by its own title.
Modal A centred dialog over an espresso scrim, with focus trap, Escape close, and focus restoration. Supports the alert-dialog role for required decisions.

Data

Component Notes
Table Horizontal scroll, sticky header, sticky first column below medium widths.
Badge Status pill. The tone selects an icon automatically, so a badge is never colour alone.
Tag Neutral metadata chip with a smaller radius. Badge says how something is going; Tag says what something is.
VitalStat A single vital or lab readout: value, unit, and an explicitly worded range state.

Feedback

Component Notes
Toast Transient confirmation on espresso. Danger takes role="alert"; everything else takes role="status".
Tooltip Short clarification on hover and focus. Never the only place information lives.
EmptyState State the fact, then offer the next action. title is required.

Navigation

Component Notes
SideNav The product shell rail. Persistent from large widths, off-canvas drawer with focus trap below.
NavBar Marketing and docs top bar, collapsing to a menu button below medium widths.
Footer The closing espresso band, with columns and notes.

Brand

Component Notes
Icon Any interface icon, resolved from the icon set at the brand stroke width of 1.75. An unknown slug degrades to an empty box rather than crashing.
Logo The shipped lockup files in three variants and three themes. Never redrawn in code.
Glyph The brand mark alone. Its animate prop draws six strokes 60 ms apart as a loading affordance.

Also exported: cx for class composition, resolveLucideIcon and ICON_STROKE_WIDTH, and useFieldId for label and description wiring.

The shared prop vocabulary

src/types.ts defines the union types every component draws from, which is what keeps the API consistent:

Size              = 'sm' | 'md' | 'lg'          // 32 / 40 / 48px control heights
ButtonVariant     = 'primary' | 'secondary' | 'ghost' | 'inverse' | 'danger'
IconButtonVariant = 'primary' | 'secondary' | 'ghost'
SurfaceTone       = 'cream' | 'bone' | 'white' | 'inverse'
StatusTone        = 'success' | 'neutral' | 'danger'
BadgeTone         = StatusTone | 'accent' | 'ink'
ToastTone         = 'info' | 'success' | 'danger'
BandTone          = 'bone' | 'espresso'
Align             = 'left' | 'right' | 'center'
Side              = 'top' | 'bottom' | 'left' | 'right'

Every props interface extends the matching HTML element attributes, so anything the platform supports still passes through.

The token contract

Components read CSS custom properties and never literal values. There is no JavaScript tokens module, no theme object, and no styling library. Components emit block-element-modifier classes such as or-btn, or-btn--primary, or-btn--md, and the stylesheets read var(--token).

Token sources are eight files under src/styles/tokens/, imported by src/styles/index.css in a fixed order:

fonts -> colors -> typography -> spacing -> radius -> elevation -> motion -> base
-> components/_index.css

Component rules come last so they win ties against the element selectors in base.css.

Three custom properties are set inline at runtime rather than in a stylesheet, because they are asset paths a stylesheet cannot know: the glyph source, the logo sources, and the per-navigation logo sources. Everything else is static.

The exact token names and values are on Design system.

Accessibility conventions

Accessibility here is enforced by tests, the Storybook addon, and review, rather than by a lint rule. The ESLint config carries no accessibility plugin, which is worth knowing so you do not assume a rule will catch a mistake.

The patterns the library holds to:

  • A global focus ring. :focus-visible gives a 2 px terracotta outline at 2 px offset, defined once in base.css and reinforced per component where the default would be clipped.
  • Real controls stay in the accessibility tree. Checkbox and Radio visually hide the native input and mark the painted box as decoration, rather than rebuilding the control from divs.
  • Switch is a button with role="switch", named through aria-labelledby, because an HTML label cannot name a button.
  • Hints and errors are descriptions, not names. Input wires them through aria-describedby so they do not pollute the accessible name.
  • Focus is trapped and restored in Modal and in the SideNav drawer, with Escape closing both and focus returning to the opener.
  • Never colour alone, enforced structurally rather than by convention. Badge attaches a per-tone icon automatically. VitalStat drops the entire state row when no state label is given, rather than leaving a bare colour. SideNav marks the active row with aria-current and a heavier weight, not only a fill.
  • Touch targets of at least 44 px below medium widths, across every interactive component. Switch grows a transparent pseudo-element because its visible track is under the floor.
  • Hover styles are wrapped in @media (hover: hover), so a tap never leaves a control stuck in hover skin.
  • Reduced motion is honoured globally in tokens/motion.css, with local handling in Glyph and Modal.

Several stories exist purely to document doctrine, including a badge story showing what colour-alone would look like and an icon story showing the unknown-slug degradation.

Storybook

Storybook 9 on the Vite builder, with the accessibility and docs addons. Prop tables are generated from the real exported interfaces.

pnpm --filter @openrunic/ui storybook        # port 6007
pnpm --filter @openrunic/ui build-storybook  # storybook-static/

143 stories across 22 files, grouped as Actions, Brand, Data, Feedback, Forms, Navigation, and Surfaces. Every component has a Default and a Responsive story. Backgrounds offer bone, cream, and espresso; viewports offer mobile, tablet, and desktop.

Adding a component

There is no generator script. The conventions are written as comments at the two registry files, and following them is what keeps parallel work from colliding.

  1. Create src/components/<Name>/ with five files: the component, its CSS, its stories, its tests, and a folder barrel.
  2. Put every visual rule in <Name>.css. Never import CSS from a .tsx file; only src/index.ts does a side-effect CSS import.
  3. Draw props from the shared vocabulary in types.ts rather than inventing a parallel size or tone union.
  4. Read tokens. No hex values, no off-scale spacing, no invented shadows.
  5. Add one line to src/index.ts, alphabetical by component name, value export then type export. The file is organised one export per line specifically so parallel agents never touch the same line.
  6. Add one @import to src/components/_index.css, alphabetical, one line. Same reason.
  7. Write stories including a Default and a Responsive, and a doctrine story if the component has a way to misuse it.
  8. Write tests. The suite has 242 cases across 25 files and the floors are strict, so a thin test will not pass.

Tests and coverage

pnpm --filter @openrunic/ui test

jsdom environment, Testing Library, and the v8 coverage provider, with thresholds of 95 on all four of statements, branches, functions, and lines. Barrels, stories, tests, and types.ts are excluded from coverage.

Like the web app, the vitest config deliberately omits the React plugin. Its second transform pass double-instruments files under coverage and roughly halves reported numbers. The plugin is used only for the Vite build and for Storybook.

One CI nuance worth knowing: the merged coverage gate in CI covers web and api only. packages/ui runs its suite in CI but does not report into that gate, so its 95 percent floors are enforced by its own vitest config on every local and CI run of the package suite.

Related pages

Clone this wiki locally