Tollerud

tollerud-ui

Use @tollerud/ui (Tollerud User Interface / Tollerud UI) correctly — components, props, Tailwind tokens, aesthetic rules, and known gotchas (Server Component imports, Button/Link composition). Trigger whenever a project imports from @tollerud/ui or @tollerud/footer, or when building UI that should match the Tollerud noir aesthetic.

Tollerud 0 Updated 1w ago

Resources

25
GitHub

Install

npx skillscat add tollerud/ui

Install via the SkillsCat registry.

SKILL.md

@tollerud/ui — Tollerud UI Skill

Dark, monochrome + single yellow-accent UI library ("noir" aesthetic). v4.8.39 — Combobox dropdown search focus fully fixed inside Dialog (focusout capture-phase interception). This skill documents the package's actual current exports (verified against components/index.ts in the source repo) — not aspirational docs. If you see a component referenced elsewhere that isn't listed below, it does not exist yet; don't import it.


Install & setup

npm install @tollerud/ui clsx tailwind-merge tailwindcss@4 \
  @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-progress \
  @radix-ui/react-slot @radix-ui/react-tabs @radix-ui/react-tooltip \
  lucide-react framer-motion sonner
# Optional — only if using NoirGlowBackground
npm install @paper-design/shaders-react

As of v4.0.1, install is npm-onlyimport { Button } from '@tollerud/ui' or subpaths like @tollerud/ui/button. Copy-via-shadcn registry CLI is unsupported. As of v4.0.0, @tollerud/ui/globals-v4.css is removed — use globals.css only. Brand assets live at @tollerud/ui/brand/*. As of v3.1.1, .tollerud-display-shimmer ships in globals.css for animated hero accent text. As of v3.1.0, Monogram ships as an inline SVG component (color: yellow | black | white). As of v3.0.0, the package is ESM-only (no CJS require entry). As of v2.0.0, Radix, Lucide, Framer Motion, and Sonner are required peers (not bundled).

Apply the Tailwind preset when you need extra utilities from @tollerud/ui/presetglobals.css already includes tokens and component layers for v4:

/* app/globals.css — Tailwind v4 (default) */
@import "@tollerud/ui/globals.css";
@import "@tollerud/ui/source.css";

As of v1.4.0, charts (BarChart, AreaChart, Donut, Sparkline) and marketing blocks (HeroBlock, FeatureCard, CTABand) ship in the package. As of v1.3.0, @tollerud/ui/globals.css is the Tailwind v4 entry. Tailwind colors are under tollerud.* only (text-tollerud-yellow, bg-tollerud-surface-raised, etc.) — not tia-*.

Tailwind v3 legacy: @import "@tollerud/ui/globals-v3.css" after preflight/utilities, with tailwind.config.ts preset + content paths.

Subpath imports (tree-shaking): @tollerud/ui/button, @tollerud/ui/dialog, @tollerud/ui/utils. The main @tollerud/ui barrel still works. Import cn from @tollerud/ui by default; use @tollerud/ui/utils only when tree-shaking without the barrel.

Greenfield setup: copy-paste agent prompts (new Next.js project, add to existing app, footer-only) live in GETTING_STARTED.md → Start with an AI agent. After agent setup, run npx tollerud-ui-audit.


Consumer styling policy

Tailwind is part of @tollerud/ui on purpose. Use it freely inside this package to implement components, variants, layout primitives, focus states, tokens, and docs-only demos.

In consumer apps, use Tailwind as a small escape hatch, not as the primary design language. The default order is:

  1. Use exported @tollerud/ui components.
  2. Use exported @tollerud/ui layout primitives or screen patterns when available.
  3. Use Tailwind only for small local glue, such as one-off margins, alignment, or responsive visibility.
  4. If branded structure repeats, add a component to @tollerud/ui or create a local semantic feature component; do not rebuild a parallel design system with raw utility classes.

Allowed consumer Tailwind:

<div className="mt-6">
  <Button variant="primary">Deploy</Button>
</div>

Discouraged consumer Tailwind:

<button className="rounded-lg bg-yellow-400 px-4 py-2 text-black">
  Deploy
</button>
<section className="min-h-screen bg-black px-6 py-24">
  <div className="mx-auto grid max-w-6xl gap-6 md:grid-cols-3">
    {/* branded cards built by hand */}
  </div>
</section>

Consumer apps must import the package CSS for Tailwind v4:

@import "@tollerud/ui/globals.css";
@import "@tollerud/ui/source.css";

globals.css provides Tailwind, tokens, and component layers. source.css makes Tailwind scan the installed package so utility classes used only inside @tollerud/ui are generated.

Use cn from @tollerud/ui or @tollerud/ui/utils; do not reimplement a local cn() helper in consumer projects.


Critical gotchas (read before writing code)

1. Server Components — just import normally (≥ 1.0.8)

@tollerud/ui ships as a single bundled file marked 'use client'. As of v1.0.8, importing anything from it — components, hooks, or plain helpers like buttonVariants and cn — works fine from a Server Component file; the import itself doesn't force your file to become a Client Component, since you're just pulling in already-client-bundled code or plain functions.

  • If you're on < 1.0.8, upgrade first — older versions crash on any import from a Server Component (the bundle wasn't marked 'use client', despite containing hook-based components).

2. Tailwind v4 — Alert tone colors missing (@source path issue)

Alert uses raw Tailwind color classes (bg-red-500/5, border-red-500/30, text-red-400, etc.) for its error, info, and success tones. In Tailwind v4, if your @source path doesn't correctly resolve to node_modules/@tollerud/ui/dist, these classes are never scanned and the colors silently don't apply.

Fix (≥ 1.1.4): globals.css now ships these classes in @layer utilities so they're always present regardless of your @source config. Just make sure you're importing globals.css:

@import "@tollerud/ui/globals.css";

If you're on < 1.1.4, add this to your own CSS as a workaround:

@layer utilities {
  .bg-red-500\/5   { background-color: rgb(239 68 68 / 0.05); }
  .bg-blue-500\/5  { background-color: rgb(59 130 246 / 0.05); }
  .bg-green-500\/5 { background-color: rgb(34 197 94 / 0.05); }
  .border-red-500\/30   { border-color: rgb(239 68 68 / 0.3); }
  .border-blue-500\/30  { border-color: rgb(59 130 246 / 0.3); }
  .border-green-500\/30 { border-color: rgb(34 197 94 / 0.3); }
  .text-red-400   { color: rgb(248 113 113); }
  .text-blue-400  { color: rgb(96 165 250); }
  .text-green-400 { color: rgb(74 222 128); }
}

3. <Button> only renders a native <button> — use asChild for links (≥ 1.0.7)

Button extends ButtonHTMLAttributes<HTMLButtonElement> and has no href. Never nest <a> inside <button> or vice versa — it's invalid HTML and breaks accessibility. Two ways to style a <Link>/<a> like a Button:

// Option A — asChild (Radix Slot merges Button's classes/props onto the child)
import { Button } from '@tollerud/ui'
import Link from 'next/link'

<Button asChild variant="primary">
  <Link href="/deploy">Deploy</Link>
</Button>
// Option B — buttonVariants() when wrapping is awkward
import { buttonVariants } from '@tollerud/ui'
import Link from 'next/link'

<Link href="/deploy" className={buttonVariants({ variant: 'primary', size: 'md' })}>
  Deploy
</Link>

For a real <button> (form submit, logout, toggle, dialog/menu trigger), just use <Button> directly — no asChild needed.

4. cn is exported — use it, don't reimplement

@tollerud/ui exports cn (clsx + tailwind-merge). Use it for conditional/merged class names instead of template strings or writing your own helper.


Aesthetic rules (never violate)

  • Dark surfaces only. Page background #0A0A0A / bg-tollerud-noir-950. Never white or light-gray backgrounds.
  • Yellow is for meaning, not decoration — CTAs, focus rings, active states, key data points (text-tollerud-yellow, #FFFF00).
  • Never put yellow text on white — contrast ratio is ~1.7:1, fails WCAG.
  • Borders over shadows. Use border-tollerud-noir-600 / border-tollerud-noir-700 for separation; reserve shadows/glow for overlays (drawers, popovers, dialogs).
  • Strict monochrome + single accent. No blues, greens, purples, or brand gradients — yellow is the only chromatic color (status colors like success/error/info exist as semantic exceptions inside Badge, IncidentCard, Alert, etc.).

Color tokens

Token Value Use
tollerud-yellow #FFFF00 Accent, CTA, focus ring, key data
tollerud-yellow-warm #E8D500 Secondary yellow, gradients, warm states
tollerud-noir-950 #0A0A0A Page background
tollerud-noir-900 #111111 Card / surface
tollerud-noir-800 #1A1A1A Elevated surface
tollerud-noir-700 #222222 Hover states
tollerud-noir-600 #333333 Borders
tollerud-text-primary #F5F5F5 Body text
tollerud-text-secondary #AAAAAA Secondary text / labels
tollerud-text-muted #666666 Placeholders, hints

Standard focus ring (apply to every interactive element): focus-visible:outline-2 focus-visible:outline-tollerud-yellow focus-visible:outline-offset-2


Component catalog (verified against actual exports)

Import everything as named exports from @tollerud/ui.

Core / forms

import {
  Button, buttonVariants, cn,
  Card, Badge, StatusDot, Kbd,
  Input, Textarea, Select, Checkbox, Switch, RadioGroup, Radio,
  PasswordInput, Combobox, TagInput, Slider, FormRow,
  Container, CodeBlock, StatCard, ActionRow, CommandMenu,
} from '@tollerud/ui'

Layout primitives

import {
  PageShell, Section, Stack, Cluster,
  Grid, CardGrid, ScrollRail, Split, MainContent,
} from '@tollerud/ui'

PageShellas?: 'div' | 'main', background?: 'plain' | 'grid' | 'glow', density?: 'comfortable' | 'compact', contentClassName?: string. Full-page dark shell. The inner content wrapper is always flex flex-col flex-1 so a flex flex-col min-h-screen outer shell correctly stretches content to fill the viewport. Use contentClassName to add extra classes to the inner wrapper. The root uses overflow-clip (≥ 4.8.41) so position: sticky works for all descendants — before 4.8.41, overflow-hidden silently disabled sticky for everything inside the shell.

Sectionas?: 'section' | 'div' | 'article' | 'header' | 'footer', size?: 'sm' | 'md' | 'lg' | 'hero', width?: 'narrow' | 'default' | 'wide' | 'full'. Consistent vertical rhythm and width constraints.

Stack — vertical layout. gap?: 'xs' | 'sm' | 'md' | 'lg' | 'xl', align?: 'start' | 'center' | 'end' | 'stretch'.

Cluster — wrapping horizontal layout for actions, badges, and toolbars. gap?: 'xs' | 'sm' | 'md' | 'lg', align?, justify?: 'start' | 'center' | 'end' | 'between'.

Grid / CardGrid — responsive grid primitives. columns?: 1 | 2 | 3 | 4 | 'auto' for Grid; columns?: 2 | 3 | 4 | 'auto' for CardGrid.

ScrollRail — horizontal scroll rail for card rows, image strips, and wide content (≥ 4.9.0). visibleCount?: number (≥ 4.9.2) — N items fill the row; extras scroll with viewport-relative sizing (no consumer @container hacks). peek?: false | 'sm' | 'md' | 'lg' (default 'md', applied only when overflowing), gap?: 'xs' | 'sm' | 'md' | 'lg', controls?: boolean | 'auto', fadeEdges?: boolean (default true), itemWidth?: number | string (fixed pixels; % is track-relative — use visibleCount for column layouts), ariaLabel?: string, trackClassName?: string. Scrollport exposes --scroll-rail-gap, --scroll-rail-peek, --scroll-rail-peek-inset.

Split — responsive two-column layout. ratio?: 'equal' | 'content' | 'sidebar', gap?: 'md' | 'lg' | 'xl', align?: 'start' | 'center' | 'stretch', reverse?: boolean.

MainContentas?: 'main' | 'div', width?: 'narrow' | 'default' | 'wide' | 'full', spacing?: 'none' | 'sm' | 'md' | 'lg', density?: 'comfortable' | 'compact'.

<PageShell background="grid">
  <Section size="hero">
    <Stack gap="lg">
      <h1>Build with components first.</h1>
      <Cluster>
        <Button variant="primary">Start</Button>
        <Button variant="secondary">Read policy</Button>
      </Cluster>
    </Stack>
  </Section>
</PageShell>

Screen patterns

import {
  PageHeader, TopNav, TopNavAction, DashboardShell, SettingsLayout,
  FormPanel, ResourceList, DetailPage, EmptyPage,
  FeatureSection, StatsSection,
} from '@tollerud/ui'

Use these before rebuilding common pages with raw Tailwind:

  • PageHeader — title block with eyebrow, description, actions, meta, align, size. Use shimmer (or titleAccent) to shimmer one word mid-sentence when title is a string — e.g. title="Keep beer prices honest." shimmer="honest". Use titleShimmer for a second full shimmer line, or PageHeaderShimmer inside title for full control.
  • TopNav — branded monogram lockup with projectName, navItems, actions, sticky, maxWidth (default | wide | full | false). Below lg, nav links and menu actions open in a modal overlay (backdrop, focus trap, Esc to close). Wrap actions in TopNavAction with mobile?: 'inline' | 'menu' | 'hidden' (default menu) to keep a primary CTA inline next to the menu toggle. Use mobileMenuExtra?: ReactNode to inject content at the bottom of the mobile sheet, below all nav items and actions, separated by a divider — consumer controls all markup.
  • SidebarNav — sidebar brand lockup with projectName, projectSubtitle, groups / items, icons, and active states.
  • DashboardTopBar — context top bar with breadcrumb, pageTitle, actions, mobile menu toggle. showMobileLogo?: boolean (default true) hides the mobile monogram when the consumer renders its own.
  • DashboardShell — docs-aligned app frame (default variant="sidebar") with sidebarGroups, sidebarItems, pageTitle, topActions, header, contentWidth, density, showMobileLogo. Use variant="topnav" for horizontal TopNav layout. The sidebar sticks on lg+ (≥ 4.8.41 — earlier versions scrolled it away with the page).
  • SettingsLayout — settings page with title, description, actions, navItems, activeId, onNavSelect, and optional tone="danger" on nav items.
  • FormPanel — titled form surface with description, actions, footer, children.
  • ResourceList — list/table page wrapper with title, description, actions, filters, count, emptyState.
  • DetailPage — detail header + primary content + optional aside.
  • EmptyPage — full-page empty state using EmptyState on a PageShell.
  • FeatureSection — section header + features rendered with FeatureCard.
  • StatsSection — section header + stats rendered with StatCard.
<ResourceList
  title="Hosts"
  description="Machines connected to Tollerud."
  actions={<Button variant="primary">Connect host</Button>}
  count="3 hosts"
>
  <CardGrid columns={3}>
    <Card><StatusDot status="online" label="emma" /></Card>
    <Card><StatusDot status="warning" label="iris" /></Card>
  </CardGrid>
</ResourceList>

Agent-safe recipes

Copy-paste screen compositions live on the docs site at Recipes (/recipes/). Each recipe is a code-only file snippet — live component demos live on Screen patterns; fuller product screens link to Examples.

Recipe Primary components Live demo / example
Marketing landing PageShell, HeroBlock, FeatureSection, CTABand, Footer Screen patterns → FeatureSection; Blocks
Dashboard overview DashboardShell, StatsSection, HostCard Screen patterns → DashboardShell; Mission Control
Settings SettingsLayout, FormPanel Screen patterns → SettingsLayout; Settings example (polished demo)
Auth PageShell, FormPanel Screen patterns → FormPanel; Sign in
Empty state EmptyPage Components → EmptyState; Screen patterns → EmptyPage
Detail DetailPage Screen patterns → DetailPage
List / table ResourceList, DataTable Screen patterns → ResourceList; Data Table

Reserve Tailwind for small local glue (mt-6, flex justify-end) — not for rebuilding page structure. See the escape-hatch recipe and Getting started → Consumer styling policy.

Consumer guardrails

Self-audit from a consumer app root:

npx tollerud-ui-audit
# monorepo: npx tollerud-ui-audit ./apps/web
# without npx: node node_modules/@tollerud/ui/scripts/audit-consumer-styling.mjs
# advisory CI (exit 0 even with errors): npx tollerud-ui-audit --warn-only

Checks: missing @tollerud/ui dependency, missing globals.css / source.css, components/ui clones, tollerud-* classes without package imports, hardcoded #FFFF00 / #0A0A0A / #E8D500, local cn(), <Button><Link> nesting, and local components/ui re-export shims.

Error codes: findings print ERROR [code] or WARN [code] — full code→fix table in GETTING_STARTED.md → Consumer project checklist (missing-ui-dep, missing-source-css, local-ui-clone, button-link-nesting, ui-reexport-shim, etc.).

Anti-patterns: copied Button.tsx, bg-yellow-400 CTAs, hand-built min-h-screen bg-black page grids, missing <Toaster />.

When a pattern is missing: compose a local feature component under src/features/… that wraps @tollerud/ui exports — do not fork primitives into components/ui. See GETTING_STARTED.md → Consumer project checklist.

Buttonvariant: primary · secondary · ghost · ghost-destructive · ghost-success · ghost-warning · ghost-info · destructive · terminal. size: sm · md · lg. asChild?: boolean. loading?: boolean — shows an inline Spinner, disables the button, and sets aria-busy. Each size uses a fixed height so text and icon-only buttons align in the same row. Primary and terminal support pointer-following glow when you call initButtonGlow() once at the app root (requires globals.css; respects prefers-reduced-motion). Ghost semantic variants match Badge tones — ghost at rest, semantic tint on hover/focus. Use ghost-destructive for archive/deactivate, ghost-success for approve/enable, ghost-warning for pause/caution, ghost-info for details/view.

<Button variant="primary" size="md">Deploy</Button>
<Button variant="ghost-destructive">Archive</Button>
<Button variant="ghost-success">Approve</Button>
<Button variant="destructive">Delete host</Button>
<Button variant="terminal" size="sm">start_building</Button>

// app/providers.tsx — optional, once near root
'use client'
import { useEffect } from 'react'
import { initButtonGlow } from '@tollerud/ui'

export function ButtonGlowRoot() {
  useEffect(() => initButtonGlow(), [])
  return null
}

Opt-in on any element: add class tollerud-btn-glow. Subpath: import { initButtonGlow } from '@tollerud/ui/button-glow'.

ButtonGroup — fused action buttons (independent clicks, not a toggle). Wrap <Button> children; size?: 'sm' | 'md' | 'lg', orientation?: 'horizontal' | 'vertical'. Use Segmented when one option should stay selected.

<ButtonGroup size="sm">
  <Button variant="secondary">Deploy</Button>
  <Button variant="secondary">Cancel</Button>
  <Button variant="ghost" aria-label="More"><MoreIcon /></Button>
</ButtonGroup>

Cardaccent?: boolean | 'filled', density?: 'comfortable' | 'compact'. accent={true} = yellow border + subtle yellow header/footer band tint on structured cards (≥ 4.9.8); accent="filled" = yellow border + stronger yellow fill on all regions. Plain <div> wrapper — safe to nest in <Link>. Compound parts: CardHeader (actions), CardTitle, CardDescription, CardContent, CardFooter, CardChange.

<Card accent>Highlighted with yellow border</Card>
<Card accent="filled">Callout with yellow fill</Card>
<Card>
  <CardHeader actions={<CardChange value="+12%" direction="up" />}>
    <CardTitle>Active sessions</CardTitle>
    <CardDescription>Last 24h</CardDescription>
  </CardHeader>
  <CardContent>…</CardContent>
  <CardFooter className="justify-end">
    <Button variant="primary" size="sm">Deploy</Button>
  </CardFooter>
</Card>

PriceDisplay — compact price block: primary: string (large value), secondary?: string (Badge below), highlight?: "cheapest" | false, align?: "left" | "right" (default "right"), size?: "sm" | "md" | "lg" (default "md"). Designed for list rows and table cells.

<PriceDisplay primary="58,0 kr/l" secondary="29,00 kr" />
<PriceDisplay primary="54,5 kr/l" secondary="27,25 kr" highlight="cheapest" />
<PriceDisplay primary="58,0 kr/l" secondary="29,00 kr" size="sm" />

ListCard — hover card shell: href?: string (renders as <a>), highlight?: "cheapest" | false (yellow border tint), external?: boolean. Children are consumer-controlled.

<ListCard href="/beers/1">
  <span>Hansa Pilsner</span>
  <PriceDisplay primary="58,0 kr/l" secondary="29,00 kr" />
</ListCard>

Badgevariant: default · accent · success · error · info · warning.

<Badge variant="success">Online</Badge>

StatusDotstatus: online · offline · warning · idle (exported as Status); label?, noPulse?.

<StatusDot status="online" label="SSH Connected" />

Input / Textarea / Select / Checkbox / Switch / RadioGroup

<Input label="Server Name" placeholder="e.g. emma.tollerud.no" error={errors.name} />
<Textarea label="Notes" rows={4} error={errors.notes} />
<Select label="Region" options={[{ value: 'eu', label: 'EU' }]} value={region} onChange={setRegion} />
<Checkbox label="Enable backups" checked={enabled} onChange={...} />
<Checkbox label="Select all" indeterminate={someSelected && !allSelected} onChange={...} />
<Switch label="Dark mode" defaultChecked />
<RadioGroup label="Target" error={error}>
  <Radio value="staging" label="Staging" name="target" />
  <Radio value="production" label="Production" name="target" />
</RadioGroup>

All form primitives require a label (renders an actual <label> for a11y) and accept error?: string.

Kbd — keyboard shortcut chip. keys: string | string[], size?: 'sm' | 'md'.

<Kbd keys={["⌘", "⇧", "S"]} size="sm" />

ActionRow / CommandMenu — Raycast-style command palette.

const [open, setOpen] = useState(false)
<CommandMenu
  open={open}
  onOpenChange={setOpen}
  groups={[{ label: 'Servers', items: [
    { id: 'emma', label: 'emma.tollerud.no', description: 'SSH · uptime 14d', onSelect: () => {} },
  ]}]}
  toggleShortcut="k"      // built-in ⌘K / Ctrl+K listener
/>

StatCardlabel, value, icon?: ReactNode, change?: { value?: string; direction: 'up' | 'down' | 'flat'; tone?: 'success' | 'error' | 'warning' | 'info' | 'accent' }, accent?. change.tone overrides the default color (up=success, down=error, flat=info). flat defaults label to when value is omitted.

<StatCard label="Active Sessions" value={42} icon={<Activity size={14} />} change={{ value: '+12%', direction: 'up' }} />
<StatCard label="Endring siste periode" value="-3.2%" change={{ value: '-3.2%', direction: 'down', tone: 'success' }} />
<StatCard label="Uptime" value="99.9%" change={{ direction: 'flat' }} />

CodeBlockcode?, promptPrefix?, showCopy?. Renders a <pre>.

<CodeBlock promptPrefix showCopy code={`systemctl status tollerud-agent`} />

Containeras?: 'div' | 'section' | 'article' | 'main' | 'header' | 'footer', capped width + padding.

PasswordInput — same API as Input (label, error, id, …) plus built-in show/hide toggle.

PasswordStrengthvalue, rules?: PasswordRule[]. Strength bar + rule checklist for signup/change-password flows. Compose below a PasswordInput. Default rules: min 8 chars, uppercase, lowercase, number, special character. Export passwordRules to extend the defaults.

const [pw, setPw] = useState('')
<PasswordInput label="New password" value={pw} onChange={e => setPw(e.target.value)} />
<PasswordStrength value={pw} />
// Custom rules:
<PasswordStrength value={pw} rules={[...passwordRules, { label: 'No spaces', test: v => !/\s/.test(v) }]} />
<PasswordInput label="Password" placeholder="Enter password" error={errors.password} />

Combobox — searchable single-select. Flat options: { value, label, disabled? }[] or grouped groups: { label, options }[] with section titles in the dropdown. value?, onChange?, placeholder?, filter?, label?, error?, searchPlacement?: 'trigger' | 'dropdown' (default 'trigger'; 'dropdown' moves search inside the popover, trigger looks like a Select button).

<Combobox label="Connect to host" value={host} onChange={setHost} options={hostOptions} />

<Combobox
  label="Command target"
  value={target}
  onChange={setTarget}
  groups={[
    { label: 'Servers', options: hostOptions },
    { label: 'Actions', options: actionOptions },
  ]}
/>

TagInput — chip-style multi-value input. value?: string[], onChange?, max?, placeholder?, label?, error?. Enter/comma to add, Backspace to remove last.

<TagInput label="Tags" value={tags} onChange={setTags} placeholder="Add tag…" max={10} />

Slider — native range input styled with yellow thumb. label?, showValue?, onChange?: (value: number) => void, plus all native <input type="range"> props.

<Slider label="Alert threshold" showValue value={threshold} onChange={setThreshold} min={0} max={100} />

FormRow — accessible field wrapper. label?, description?, error?, required?, htmlFor?. Wires aria-describedby automatically.

<FormRow label="Hostname" htmlFor="hostname" description="Unique within your network." required error={errors.hostname}>
  <Input id="hostname" placeholder="e.g. embla" />
</FormRow>

Navigation & layout primitives

import {
  Divider, Pill, Avatar, AvatarGroup,
  Breadcrumb, Pagination, Segmented, Stepper,
  Panel, Meter, Gauge, Heatmap, Accordion, AccordionItem, AccordionTrigger, AccordionContent,
  DatePicker, FileUpload, PricingCard,
} from '@tollerud/ui'

Dividerorientation?: 'horizontal' | 'vertical', label?: ReactNode.

<Divider />
<Divider label="or" />
<Divider orientation="vertical" className="h-6" />

Pillvariant?: 'outline' | 'solid' | 'accent'.

<Pill variant="accent">production</Pill>

Avatar / AvatarGroupsrc?, name? (derives initials), fallback?, size?: 'sm' | 'md' | 'lg'. AvatarGroup takes max? and shows a +N chip.

<Avatar name="Mathias Tollerud" size="md" />
<AvatarGroup max={3}><Avatar name="Emma" /><Avatar name="Iris" /></AvatarGroup>

Breadcrumbitems: { label, href?, onClick? }[], separator?.

<Breadcrumb items={[{ label: 'Servers', href: '/servers' }, { label: 'Embla' }]} />

Paginationpage (1-indexed), pageCount, onChange, siblingCount?.

<Pagination page={page} pageCount={20} onChange={setPage} />

Segmentedoptions: { value, label, disabled? }[], value, onChange, size?: 'sm' | 'md', collapseMobile?. Segment height is fixed per size so text and icon labels align.

<Segmented value={view} onChange={setView} options={[{ value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} />

<Segmented value={sort} onChange={setSort} options={SORTS} collapseMobile />

With collapseMobile, viewports below md show only the selected label (with chevron) until tapped; options open in a dropdown overlay and collapse again after a selection. Desktop is unchanged.

Steppersteps: { label, description? }[], current (0-indexed), orientation?: 'horizontal' | 'vertical'.

<Stepper steps={onboardingSteps} current={1} orientation="vertical" />

Paneltitle?, description?, actions?: ReactNode (renders in header), children (body with padding).

<Panel title="Overview" description="Live metrics" actions={<Button size="sm">Refresh</Button>}>…</Panel>

Metervalue, max? (default 100), label?, showValue?, tone?: 'default' | 'success' | 'warning' | 'error'.

<Meter value={72} label="RAM" showValue tone="warning" />

Gauge (≥ 4.8.50) — radial dial for a single metric. value, min?/max? (0–100), label?, formatValue?, tone? (same tones as Meter), size?, thickness?, fluid?. role="meter" with aria value range.

<Gauge value={72} label="Disk" formatValue={(v) => `${v}%`} tone="warning" />

Accordion — compound. multiple?: boolean, defaultOpen?: string | string[]. Items use value prop to identify.

<Accordion defaultOpen="faq-1">
  <AccordionItem value="faq-1">
    <AccordionTrigger>What is Tia?</AccordionTrigger>
    <AccordionContent>An infrastructure assistant for homelabs.</AccordionContent>
  </AccordionItem>
</Accordion>

DatePickervalue?: Date | null, onChange?, label?, error?, placeholder?, formatDate?.

<DatePicker label="Schedule deployment" value={date} onChange={setDate} />

FileUploadaccept?, multiple?, onFilesChange?, label?, description?, error?, clickLabel?, dragLabel?.

<FileUpload label="Upload config" accept=".yaml,.json" onFilesChange={handleFiles} />
<FileUpload clickLabel="Klikk for å laste opp" dragLabel="eller dra og slipp" />

PricingCardname, price, period?, description?, features?: ReactNode[], ctaLabel?, onCtaClick?, featured?, badge?.

<PricingCard name="Pro" price="$9" period="/month" features={['Unlimited servers']} featured badge="Most popular" />

Overlays (Radix-based, all need 'use client' boundary in the consumer component)

import {
  Dialog, DialogTrigger, DialogContent, DialogHeader, DialogBody, DialogFooter, DialogTitle, DialogDescription, DialogClose, DialogPanel,
  Tooltip, TooltipTrigger, TooltipContent, TooltipProvider,
  Tabs, TabsList, TabsTrigger, TabsContent,
  DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel,
  Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetClose,
  Drawer, Toaster, ToastProvider, useToast,
} from '@tollerud/ui'
  • Dialog / Sheet / DropdownMenu follow the standard shadcn/Radix composition pattern — Trigger wraps the activating element with asChild. DialogContent takes size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' (default md) and partitions DialogHeader / body / DialogFooter with bordered regions and a scrollable body. Prefer DialogPanel for controlled dialogs: open, onClose, title, description, footer, size (≥ 4.9.1, same ergonomics as Drawer). Sheet takes side?: 'left' | 'right'.
  • Drawer — controlled API: open, onClose, side, title, description, footer, width.
  • Tooltip requires a <TooltipProvider> ancestor.
  • Toaster — Sonner renderer; mount once, call toast() from sonner.
  • ToastProvider + useToast — context API: toast({ tone, title, message?, duration? }).

Empty states & loading

import {
  Empty, EmptyHeader, EmptyIcon, EmptyTitle, EmptyDescription, EmptyContent,
  EmptyState, Skeleton, Progress, Spinner,
} from '@tollerud/ui'

<Empty>
  <EmptyHeader>
    <EmptyIcon>{/* icon */}</EmptyIcon>
    <EmptyTitle>No hosts connected</EmptyTitle>
    <EmptyDescription>Connect your first machine and Tia will start watching it.</EmptyDescription>
  </EmptyHeader>
  <EmptyContent><Button variant="primary" size="sm">Connect a host</Button></EmptyContent>
</Empty>

EmptyState — prop-driven shortcut: icon (built-in name or custom element), title, description, action, secondaryAction, compact, accent.

<EmptyState icon="server" title="No hosts connected" description="Connect your first machine."
  action={<Button variant="primary" size="sm">Connect</Button>} />

Spinnersize?: number (px, default 16). Inline loading indicator.

Visual / decorative

import {
  GlowCard, NoirGlowBackground, BentoDashboard,
  BarChart, AreaChart, TimeSeriesChart, TIME_SERIES_PRESETS, Donut, Sparkline,
  HeroBlock, FeatureCard, CTABand,
} from '@tollerud/ui'
  • GlowCardchildren, className?, glowColor?, intensity?: number. Mouse-tracked glow card.
  • NoirGlowBackground — animated WebGL shader background (needs @paper-design/shaders-react). shape?: 'corners' | 'wave' | 'dots' | 'truchet' | 'ripple' | 'blob' | 'sphere', intensity?: 'subtle' | 'medium' | 'loud', speed?: 'still' | 'slow' | 'medium' | 'fast', grain?: 'none' | 'soft' | 'high', colors?: string[], forceCssFallback?: boolean.
  • BentoDashboard — composed dashboard shell taking arrays of HostCardProps, StatCardProps, ServiceHealthCardProps, incidents, BackupJob[].

Charts — palette-aware SVG; yellow highlights one series. No Recharts. Full usage + prop tables: COMPONENTS.md · design.tollerud.dev/charts.

import {
  TimeSeriesChart,
  TIME_SERIES_PRESETS,
  formatChartDecimal,
  BarChart,
  AreaChart,
  Donut,
  Sparkline,
} from '@tollerud/ui'

<TimeSeriesChart
  data={[
    { date: '2026-03-23', value: 13999, label: 'Bygghjemme', meta: ['Vaskemaskin Miele WWR860'] },
    { date: '2026-04-06', value: 14250, label: 'Obs BYGG' },
  ]}
  curve="step"
  height={300}
  ranges={TIME_SERIES_PRESETS}
  range="3m"
/>

// Norwegian unit rate — dates via locale, numbers via formatValue
<TimeSeriesChart
  data={ratePoints}
  curve="step"
  locale="nb-NO"
  formatValue={(v) => formatChartDecimal(v, 'nb-NO', { suffix: ' kr/l' })}
/>

<Sparkline data={[17200, 16800, 13999]} curve="step" fill interactive width={160} height={36} />

TimeSeriesChart — stepped/linear curves, hover crosshair + tooltip, ranges + TIME_SERIES_PRESETS (English labels), locale (default en-US, dates only), valuePrefix / valueSuffix or formatValue for numbers, formatChartDecimal helper for rates, toolbarLeft, renderTooltip(point, index, formattedValue). Norwegian apps: custom ranges + locale="nb-NO". Keyboard accessible (≥ 4.8.42): Tab focuses the chart and activates the latest point, ←/→ step, Home/End jump, Esc clears (consumed only while a point is active — safe inside Dialogs); keyboard selections are announced via a polite live region. Sparklinecurve, fill, interactive for table-row micro charts; interactive mode (≥ 4.8.43) adds a tooltip, the chart keyboard contract, formatValue, and ariaLabel. AreaChart — static by default; interactive (≥ 4.8.43) adds crosshair + tooltip + keyboard nav, data accepts number[] or { value, label }[], plus formatValue / renderTooltip / ariaLabel.

Marketing blocks

<HeroBlock eyebrow="homelab" title="Run your stack" intense actions={<Button>Deploy</Button>} />
<FeatureCard icon={<Zap size={20} />} title="Instant deploys" description="…" />
<CTABand title="Ship it" actions={<Button variant="primary">Get started</Button>} />
<PromoSection
  eyebrow="Feature"
  title="Two-column callout"
  description="Body text here."
  actions={<Button variant="primary">Get started</Button>}
  visual={<img src="/preview.png" />}
  visualPlacement="right"
  background="raised"
  textWidth="wide"
/>

HeroBlock.intense mounts NoirGlowBackground (needs @paper-design/shaders-react). PromoSectionshimmer accents word(s) in title (string or string[]); same API on PageHeader/titleAccent; eyebrow is mono uppercase yellow matching PageHeader; visualPlacement?: "right" | "left", background?: "default" | "raised" (raised uses border-y for full-bleed), textWidth?: "narrow" | "balanced" | "wide", contentWidth?: "sm" | "md" | "lg" | "xl" | "full" (default "xl"). Collapses to single column on mobile; visual always renders below text on mobile. Falls back to centered single-column when no visual prop is passed.

Data & tables

import { DataTable } from '@tollerud/ui'

// Simple: sort + per-column text filters
<DataTable
  columns={[
    { key: 'hostname', label: 'Host', sortable: true, filterable: true },
    { key: 'status', label: 'Status', render: (_v, row) => <Badge variant={row.status === 'online' ? 'success' : 'error'}>{row.status}</Badge> },
  ]}
  data={hosts}
  rowKey="id"
  onRowClick={(row) => {}}
  emptyMessage="No hosts found"
/>

// Rich: search, segmented filter, selection, bulk actions, row menus, pagination
<DataTable
  columns={[
    { key: 'hostname', header: 'Host', sortable: true, render: (_v, row) => row.hostname },
    { key: 'status', label: 'Status', render: (_v, row) => <Badge>{row.status}</Badge> },
  ]}
  data={hosts}
  rowKey="id"
  searchable
  searchKeys={['hostname', 'ip']}
  filter={{ key: 'region', allLabel: 'All regions' }}
  // filter={{ key: 'region', allLabel: 'All regions', variant: 'combobox', placeholder: 'Filter region…' }}
  selectable
  pageSize={10}
  pageSizeOptions={[10, 25, 50]}
  striped
  pinColumns
  bulkActions={[{ label: 'Restart', variant: 'ghost', onRun: (ids, clear) => { clear() } }]}
  // Two or more bulk actions render fused in ButtonGroup automatically
  rowMenu={(row) => [{ label: 'View logs', onSelect: () => {} }]}
  toolbarRight={<Button size="sm">Add host</Button>}
  emptyState={<Empty>...</Empty>}
/>

Column headings use label or header. render is always (value, row) => … (≥ 4.8.40; value is row[key] — write (_v, row) => … when you only need the row). The pre-4.8.40 single-parameter (row) => … form is gone: it silently receives the cell value now, so migrate by prepending _v, to the parameter list. Sortable headers render a real <button> (keyboard-operable, aria-sort on the <th>). If rows are selectable, give them a stable id/key field or pass rowKey — index-fallback keys collide across pages.

Pagination — pass pageSize (fixed) or add pageSizeOptions={[10, 25, 50]} for a compact inline footer Rows Select. Page state is internal. Footer shows Showing 1–5 of N; controls appear when pageCount > 1. Search/filter resets page to 1. Selection spans pages when selectable is set.

Infra / homelab set

import {
  HostCard, ServiceHealthCard, DockerStackCard, IncidentCard, ApprovalCard,
  ActionDiff, LogViewer, AlertInbox, Timeline, RollbackPlan, BackupStatusPanel,
} from '@tollerud/ui'
import type { IncidentSeverity } from '@tollerud/ui'
<HostCard hostname="emma" ip="10.0.10.10" status="online" cpu="23%" memory="6.2/16 GB" disk="45%" uptime="14d" containers={4} />
<ServiceHealthCard service="emma.tollerud.no" status="online" uptime="14d 3h" responseTime="23ms" />
<DockerStackCard name="hermes" services={[{ name: 'web', status: 'online' }]} composePath="compose.yml" />
<IncidentCard title="High CPU" severity="high" timestamp="2026-05-26 14:32" description="CPU at 92% for 5 min" service="emma" />
<ApprovalCard action="restart_container" description="Restart emma:hermes" state="pending" onApprove={() => {}} onReject={() => {}} />
<ActionDiff label="docker-compose.yml" lines={[{ text: 'image: nginx:1.27', type: 'add', newLine: 12 }]} />
<LogViewer lines={[{ text: 'Health check passed', level: 'info', timestamp: '14:32:01', source: 'hermes' }]} follow searchable showLineNumbers height="300px" />
<AlertInbox alerts={[{ id: '1', title: 'emma high CPU', severity: 'high', timestamp: '14:32', acknowledged: false }]} onAcknowledge={(id) => {}} />
<Timeline items={[{ id: '1', time: '14:32', title: 'Deploy started', status: 'online' }]} active loading={false} />
<RollbackPlan name="hermes-v2.1-rollback" steps={[{ id: '1', label: 'Stop container', status: 'success' }]} executing />
<BackupStatusPanel jobs={[{ name: 'nightly-db', status: 'online', lastRun: '02:00', nextRun: 'tomorrow 02:00', size: '4.2 GB' }]} totalSize="120 GB" />

IncidentSeverity = critical | high | medium | low | info · Status (StatusDot/HostCard/etc) = online | offline | warning | idle · LogLevel = debug | info | warn | error | trace · RollbackStepStatus = pending | running | success | failed | skipped · ApprovalState = pending | approved | rejected.

Footer & branding

import { Button, Footer, TopNav } from '@tollerud/ui'

<Footer layout="responsive" accent labels={{ tollerudProject: 'A Tollerud Project' }} />

The monogram must always sit left of the project name with gap-2. Never show the name without the monogram, or the monogram alone, in nav contexts.

<TopNav
  projectName="Project Name"
  navItems={[{ label: 'Overview', href: '/overview', active: true }]}
  actions={
    <>
      <TopNavAction mobile="inline">
        <Button variant="primary" size="sm">Get started</Button>
      </TopNavAction>
      <TopNavAction mobile="menu">
        <Button variant="secondary" size="sm">Sign in</Button>
      </TopNavAction>
    </>
  }
/>

TopNavAction mobile values: inline (header bar on mobile), menu (overlay panel, default), hidden (desktop only). Unwrapped actions children default to menu on mobile.

Monogramcolor?: 'yellow' | 'black' | 'white' (default yellow), size?: number, title?: string (default 'Tollerud'). Brand avatars: @tollerud/ui/brand/tollerud-avatar.svg, brand/tollerud-avatar-full.svg (+ .png variants).
Monogram sizing is handled automatically by TopNav and Footer; use Monogram directly only for custom brand moments.


Layout utility classes

These are low-level class references for package internals, docs demos, and custom cases. In consumer apps, prefer exported components and screen/layout primitives first; use these classes only when no component covers the need yet.

<section class="tollerud-grid-bg">…</section>
<h1 class="tollerud-display text-[70px]">Dark. Monochrome.</h1>
<h2 class="tollerud-display--secondary text-[40px]">
  <span class="tollerud-display-shimmer">Yellow where it counts</span>
</h2>
<div data-density="compact">…dense tables / forms…</div>

Shadow scale: --shadow-sm --shadow-md --shadow-lg --shadow-xl --shadow-glow. Drawers/Sheets use --shadow-xl; popovers/tooltips --shadow-lg. Borders are the default separator — only reach for shadows on overlays.


Copy & voice

  • Action-first labels: "Deploy", "View Logs", "Restart" — not "Click here to initiate deployment"
  • Terminal-style CTAs for technical actions: ❯ deploy --env production, $ init
  • Name the cause in errors: "Connection to emma.tollerud.no timed out" — not "Something went wrong"
  • No exclamation marks or corporate filler ("Oops!", "Great!", "Please try again later")

Accessibility checklist

  • Every interactive element gets a visible focus ring (see token section above, or class .tollerud-focus-ring)
  • Icon-only buttons need aria-label
  • Always pass label to Input / Select / Textarea / Checkbox / Switch / Radio — never rely on placeholder-as-label
  • Error text uses role="alert" or aria-live="polite"
  • Never convey state by color alone — pair StatusDot / Badge color with text/icon
  • Respect prefers-reduced-motion: reduce — disable shimmer/pulse/shader animations

What NOT to do

Don't Why
Use light/white backgrounds System is dark-only
Put yellow text on white Fails contrast at ~1.7:1
Recolor or add glow to the monogram Yellow-on-dark branding is non-negotiable; glow is for interactive UI only
Introduce non-system chromatic colors (blue, green, purple) for decoration Only the yellow accent + monochrome grays (status semantics are the lone exception)
Nest <a>/<Link> inside <Button> (or vice versa) Invalid HTML — use asChild or buttonVariants() instead
Import a component name you saw in older docs without checking it exists Verify against this catalog or components/index.ts. Common renames: EmptyStateEmpty, ToastToaster + sonner's toast(), DrawerSheet. Charts (BarChart, AreaChart, Donut, Sparkline) and marketing blocks (HeroBlock, FeatureCard, CTABand) ship since v1.4.0. Brand assets: @tollerud/ui/brand/* (not repo root).

Version notes

  • @tollerud/email footer polish (≥ 4.12.1)EmailFooter wordmark is right-aligned (monogram stays left), reads "A Tollerud Project." with a period, and the tollerud.no underline is 2px. No API change.
  • @tollerud/email overridable copy + style hatches (≥ 4.12.0) — templates take a copy prop (each exports a *Copy type; dynamic lines are functions, static lines strings) to reword or localize (e.g. Norwegian) without forking. Every primitive takes an optional style prop merged last, overriding token defaults for one-off tweaks. Both non-breaking; visual design stays token-locked by default.
  • @tollerud/email branded header + footer (≥ 4.11.0) — new EmailHeader (monogram + project name large, optional divider) and BrandMark (the monogram; inline SVG by default, logoSrc for a hosted image where Outlook/Gmail strip SVG). All templates take an optional header?: EmailHeaderProps. Breaking: EmailFooter is now the real Tollerud footer (monogram + "A Tollerud Project" → tollerud.no); its brandName prop was replaced by labels (tollerudProject/attribution/allRightsReserved) — address/unsubscribeUrl/links unchanged.
  • @tollerud/email package (≥ 4.10.0) — HTML email is a separate render target, so it ships as its own package, not through @tollerud/ui. It shares tokens (not components) via the generated lib/tokens.ts — token values are inlined as literals because var(--tollerud-*) doesn't resolve in mail clients. Built on React Email. Templates: WelcomeEmail, VerifyEmail, PasswordResetEmail, ReceiptEmail. Render with the re-exported render. See packages/email/README.md. Do not import web @tollerud/ui components into emails — they break in Outlook/Gmail.
  • asChild / buttonVariants require @tollerud/ui >= 1.0.7
  • Server Component import safety requires @tollerud/ui >= 1.0.8 (earlier versions crash when imported into a Server Component file — the bundle wasn't marked 'use client')
  • 19 new components (Divider, Pill, Avatar/AvatarGroup, Breadcrumb, Pagination, Segmented, Stepper, Panel, Meter, FormRow, Accordion, Slider, PasswordInput, Combobox, DatePicker, FileUpload, TagInput, PricingCard) require >= 1.0.9
  • Combobox + DatePicker close on window resize (≥ 1.1.0) — earlier versions left the popover open and misaligned after viewport changes
  • Segmented icon segments match text height (≥ 4.5.1) — earlier versions rendered icon-only segments ~4px shorter than text segments
  • Button icon-only matches text height per size (≥ 4.5.2) — earlier versions sized from padding + content, so icon-only buttons could render shorter than labeled buttons
  • Combobox searchPlacement (≥ 4.8.25)'dropdown' moves the search input inside the popover; the trigger becomes a button like Select. Default 'trigger' preserves prior behaviour.
  • Combobox input uses text-sm (≥ 4.8.14) — earlier versions used text-base (16px), mismatching the 14px dropdown items
  • StatCard change.tone (≥ 4.8.15) — optional 'success' | 'error' | 'warning' | 'info' | 'accent' overrides the default up=green / down=red coloring
  • SidebarNav scroll fix (≥ 4.8.16) — nav content area now scrolls when items overflow the viewport height (missing min-h-0 on the flex child prevented the scroll context from forming)
  • StatCard arrow direction fix (≥ 4.8.19)direction: 'up' now shows an up arrow (was showing down arrow due to inverted rotate-180 condition)
  • Form field height alignment (≥ 4.8.18) — all form field triggers (Input, PasswordInput, Combobox, DatePicker, Textarea, Select) use text-base py-2.5. Earlier versions mixed text-sm/text-base and py-2/py-2.5, causing height differences when combining field types.
  • Chart SR-table layout fix (≥ 4.8.52) — the visually-hidden data table (TimeSeriesChart/AreaChart/Heatmap) no longer inflates page height. It's wrapped in an sr-only div now (an sr-only <table> ignored height:1px and left a large empty area below the page). No API change.
  • Heatmap (≥ 4.8.51) — calendar activity grid (data of { date, value }): week columns × weekday rows, intensity-bucketed cells, hover tooltip, Less→More legend, and an SR data table. weekStartsOn, colors, startDate/endDate, formatValue/formatDate.
  • Gauge (≥ 4.8.50) — radial dial for a single metric (see the Gauge prop entry above).
  • Stacked/grouped BarChart (≥ 4.8.49) — pass series={[{ label, values, color? }]} + categories={[...]} for grouped bars, add stacked for stacked. Palette-cycled, legend, focusable bars/columns with tooltips. Single-series data unchanged.
  • All charts responsive (≥ 4.8.48)Sparkline and Donut gain an opt-in fluid prop (fill container width / scale ring to fit, both non-breaking); TimeSeriesChart, AreaChart, BarChart, Meter are already fluid-width. Sparkline fluid keeps dots round (pixel-plotted); Donut fluid stays circular (uniform scale, legend wraps).
  • AreaChart renders pixel-perfect (≥ 4.8.47) — it now measures its width and plots in pixels instead of stretching a fixed viewBox, so point markers stay round and strokes uniform at any container width. No API change.
  • Multi-series TimeSeriesChart (≥ 4.8.46) — pass series={[{ label, points, color? }]} instead of data for multiple lines: shared crosshair, stacked color-swatch tooltip, palette-cycled colors (--chart-1…5), legend, and a per-series column in the SR table. Series must be index-aligned (same dates by position). data (single series) is unchanged; area fill + latest badge are single-series only.
  • Chart screen-reader data tables (≥ 4.8.45)TimeSeriesChart (default on) and AreaChart (default = interactive) render a visually-hidden <table> of every point's value via the srTable prop, so SR users get the numbers, not just the chart label. BarChart/Donut are intentionally excluded (their per-bar aria-labels / legend list already expose the data).
  • BarChart + Donut interactivity (≥ 4.8.44)BarChart interactive makes bars focusable (roving tabindex: Tab, ←/→, Home/End, Esc) with tooltips and per-bar aria-labels; new formatValue/ariaLabel. Donut segment color is optional (cycles --chart-1…5, exported as CHART_SERIES_COLORS); interactive makes legend rows focusable — active row highlights its arc, dims others, reveals the raw value. BarChart/Meter transitions respect prefers-reduced-motion. Static usage unchanged.
  • AreaChart + Sparkline interactivity (≥ 4.8.43) — both support the shared chart keyboard contract (Tab, ←/→, Home/End, Esc) with tooltips and screen-reader announcements when interactive is set. AreaChart gains interactive / formatValue / renderTooltip / ariaLabel and labeled-point data ({ value, label }[]); Sparkline gains formatValue / ariaLabel. Static usage is unchanged and stays aria-hidden.
  • TimeSeriesChart keyboard navigation (≥ 4.8.42) — Tab focuses the chart (latest point active), ←/→ step through points with crosshair + tooltip, Home/End jump, Esc clears without closing a surrounding Dialog. Screen readers hear keyboard-selected points via a live region. Hover/touch behavior unchanged.
  • position: sticky works inside PageShell (≥ 4.8.41) — the shell root previously used overflow-hidden, which made it the sticky containing scroll ancestor and silently disabled sticky for every descendant (DashboardShell sidebar, TopNav sticky, DashboardTopBar, consumer sticky elements). Now overflow-clip. The DashboardShell sidebar wrapper also gained lg:self-start so it isn't flex-stretched to the full content height (a sticky element as tall as its containing block has zero travel room).
  • Keyboard & screen-reader pass (≥ 4.8.40)Combobox/Select/CommandMenu announce the arrow-key highlight via aria-activedescendant; Select's trigger is role="combobox" (query it with getByRole('combobox') in tests, not 'button'). DataTable sortable headers are real <button>s inside the <th>. Escape on an open Combobox/Select no longer closes a surrounding Dialog. CommandMenu Enter now runs the highlighted filtered result. Focus rings use var(--tollerud-yellow-warm) instead of hardcoded #E8D500. Checkbox gains an indeterminate prop (dash indicator + native mixed state); DataTable's select-all header checkbox uses it automatically for partial page selection. Breaking: DataTable Column.render is always (value, row) — the row-only (row) => … form was removed; migrate with (_v, row) => ….
  • Portalled dropdowns work inside Radix Dialog (≥ 4.8.36–4.8.39)Combobox, Select, and DatePicker dropdowns no longer close the dialog on click, and Combobox searchPlacement="dropdown" auto-focuses the search input even when used inside a Dialog. Four-part fix across 4.8.36–4.8.39: stop pointerdown/focusin from reaching Radix's document handlers; fix focus timing with callback ref + queueMicrotask; upgrade escape hatch to useLayoutEffect; add capture-phase focusout listener on document to stop Radix's second focus-redirection handler (which fires on focusout before focusin even gets a chance). No consumer API changes.
  • GlowCard visibility fix (≥ 4.8.35) — glow was invisible when wrapping solid-background surfaces. Overlay now renders above content with mix-blend-mode: screen, making the radial gradient visible through any child background.
  • First-class a11y + Button loading + Card asChild (≥ 4.8.34) — all form fields with error now wire aria-invalid + aria-describedby; all form fields accept required with visual * and aria-required. Button gains loading prop (Spinner + aria-busy). Tooltip opens/closes on keyboard focus/blur. Card gains asChild. FileUpload and Combobox are now wrapped with forwardRef.
  • Card density + GlowCard glow fix (≥ 4.8.33)density="compact" now works (reduces to p-3); [[data-density=compact]_&]:p-3 handles wrapper-based density. GlowCard intensity now controls overlay opacity (was incorrectly used as gradient color stop).
  • Docs demos translated to English (4.8.32) — all demo copy, labels, and values in docs-app/ are now in English.
  • Multi-word shimmer (≥ 4.8.31)shimmer on both PromoSection and PageHeader now accepts string | string[]. Array entries each get their own shimmer span; overlapping/non-matching entries are silently skipped.
  • PromoSection shimmer prop + PageHeader eyebrow (≥ 4.8.30)shimmer="word" accents one word in the title; eyebrow now matches PageHeader (mono uppercase yellow, not a Pill).
  • PromoSection mobile fix + contentWidth (≥ 4.8.29) — collapses to single column below sm:, text always first on mobile, overflow-hidden on visual slot. contentWidth?: "sm"|"md"|"lg"|"xl"|"full" (default "xl") caps inner content while the wrapper can go full-bleed.
  • PromoSection added (≥ 4.8.28) — two-column marketing block; visualPlacement, background, textWidth control layout.
  • PriceDisplay + ListCard added (≥ 4.8.27) — new consumer-facing components for list rows.
  • Card accent="filled" (≥ 4.8.27) — adds yellow fill in addition to border tint; existing accent={true} unchanged.
  • Sparkline SVG clip fix (≥ 4.8.27)overflow="hidden" prevents stroke bleed at small sizes with curve="step" and fill.
  • Portalled dropdown mobile scroll fix (≥ 4.8.26)Combobox, Select, DatePicker, and Segmented now close on outside scroll on touch devices instead of lagging to reposition.
  • iOS Safari Combobox fix (≥ 4.8.53)searchPlacement="dropdown" was unusable on iOS: the 14px auto-focused search input triggered auto-zoom, and the zoom's scroll event immediately dismissed the panel. The in-dropdown search input now renders ≥16px on coarse pointers (pointer-coarse:text-base, desktop unchanged), and portalled dropdowns only dismiss on a genuine touch drag. Also stops non-search dropdowns from closing when iOS scrolls a focused field above the keyboard.
  • Mobile dropdown float/flip fix (≥ 4.8.54) — follow-up to 4.8.53. Placement is sticky on portalled dropdowns (Combobox, Select, DatePicker, Segmented): filtering a Combobox down to one result no longer flips the panel from top to bottom.
  • Mobile dropdown detach fix (≥ 4.8.55) — follow-up to 4.8.54, which placed the panel once and never repositioned, leaving it stranded away from its trigger after iOS scrolled the focused search input above the keyboard. On touch the hook now separates a programmatic settle scroll (keyboard/address-bar/focus — repositions to stay glued to the trigger) from a user fling and its momentum tail (dismisses). Repositions are coalesced per animation frame and visualViewport resize is handled, so the panel tracks the field without floating. Desktop still tracks the anchor on scroll/resize.
  • Portalled dropdowns use Floating UI (≥ 4.8.56) — supersedes the bespoke positioning in 4.8.26–4.8.55. Combobox, Select, DatePicker, and Segmented are now positioned by Floating UI (the engine behind Radix Popover / shadcn) with autoUpdate + flip/shift/size, so they flip, shift, clamp height, and stay glued to the trigger across scroll, resize, and iOS keyboard/zoom/address-bar changes. On touch they now stay open and reposition on scroll instead of closing (the 4.8.26 close-on-scroll workaround is gone); outside-click and Escape still dismiss. The internal lib/dropdown-placement.ts helpers and the FloatingDropdownPortal onOutsideScroll prop were removed (internal only — no public component API changed).
  • DataTable touch scroll fix (≥ 4.8.57) — removed touch-pan-x from the horizontal scroll wrapper, which blocked vertical page scroll on mobile when a swipe started on table rows or headers. Horizontal scroll for wide tables is unchanged (overflow-x-auto + overscroll-x-contain).
  • ScrollRail (≥ 4.9.0) — horizontal scroll rail for card rows, image strips, and overflow content. visibleCount (≥ 4.9.2) for N-fill-then-scroll layouts; peek/controls/fadeEdges only when overflowing; itemWidth for fixed-pixel strips. Item wrappers stretch slot height (≥ 4.9.3) so h-full on children works without extra markup. Does not use touch-pan-x.
  • PriceDisplay size (≥ 4.9.4)size?: "sm" | "md" | "lg" scales primary value and secondary badge together. ≥ 4.9.5: primary size classes are literal (fixes production purge).
  • CardChange flat (≥ 4.9.9)direction: "flat" for unchanged: minus icon, default text-tollerud-info, label defaults to .
  • Card compound parts (≥ 4.9.4)CardHeader, CardTitle, CardDescription, CardContent, CardFooter with darker header/footer bands. Plain <Card> padding unchanged. ≥ 4.9.5: CardContent explicitly keeps the raised surface background. ≥ 4.9.6: header/footer bands use subtle color-mix on raised surface instead of noir-950. ≥ 4.9.7: CardChange up/down chip + CardHeader actions slot; StatCard uses CardChange internally. ≥ 4.9.8: accent={true} adds subtle yellow band tint on header/footer (body stays raised).
  • DataTable selection borders (≥ 4.9.4) — selected rows use a uniform background instead of per-cell inset rings (no doubled borders between adjacent cells).
  • Dialog layout + sizes (≥ 4.9.1)DialogContent size presets (smfull, default md / max-w-xl), bordered DialogHeader/DialogFooter, scrollable body, new DialogBody slot, and DialogPanel controlled helper (open/onClose/title/description/footer).
  • TopNav mobileMenuExtra slot (≥ 4.8.24) — inject arbitrary content at the bottom of the mobile nav sheet, separated by a divider.
  • Always pin to the latest patch and check CHANGELOG.md in the design-system repo for breaking changes (e.g. the 1.0.5 yellow token rename: tollerud-yellow-brighttollerud-yellow, old tollerud-yellow #E8D500tollerud-yellow-warm)

CHANGELOG.md format rules

The docs site at design.tollerud.dev parses CHANGELOG.md at runtime. Wrong formatting causes entries to render as a wall of text. Follow these rules whenever you write a changelog entry:

Entry heading: ## version — YYYY-MM-DD — Title

## 1.2.0 — 2026-07-01 — Add DataGrid component

Blank lines are required between every distinct block. The parser splits sections at blank lines — missing blank lines cause everything to merge into one paragraph.

Section headings inside an entry: ### heading or a **Bold line** on its own line, preceded by a blank line:

## 1.2.0 — 2026-07-01 — Add DataGrid component

Short summary.

### New components

- `DataGrid` — sortable, filterable grid ...

### Migration

Nothing breaking.

Never write bold inline mid-paragraph as a heading substitute with no blank line before it — it will merge with surrounding text into one unreadable block.