Components
The kit — Tailwind + the Lumen tokens, distributed the shadcn way.
Built on Tailwind + the Lumen tokens, distributed the shadcn way — source you own, not an opaque
dependency. Everything below is rendered live from the lumen package (import … from 'lumen/react') —
the docs show exactly what apps ship. Every component documents all of its states.
Live, from the package
These previews import the real components. Toggle Day/Night (bottom-left) and they re-theme with the rest of the system — they reference semantic tokens, never hard-coded color.
Button
The primary action. primary wears the signature Solar gradient; all variants share one focus ring, disabled,
and press treatment.
Variants
Sizes
States
The full matrix, rendered — hover/focus/active are shown with their real classes forced on, so the matrix is visible without interaction (they're also live: hover and tab to any other button on this page).
import { Button } from 'lumen/react';
<Button variant="primary" size="md" onClick={log}>Log event</Button>
<Button loading>Saving…</Button>Badge
A status pill. Pairs a -text color with an icon so state survives the grayscale and color-blindness tests
— meaning never rests on color alone.
import { Badge } from 'lumen/react';
<Badge variant="warning">Needs attention</Badge>
<Badge variant="neutral" icon={false}>28w 3d</Badge>Input
A text field on the muted ground, with a Solar focus ring, a clear disabled state, and an invalid state
driven by aria-invalid. Pair with a <label> — or let Field wire everything.
import { Input } from 'lumen/react';
<label htmlFor="q" className="sr-only">Search</label>
<Input id="q" placeholder="Search spans…" />Field
The form unit — label, control, help, and the failure path. Field wires htmlFor/id,
aria-describedby, and aria-invalid for you; the error renders with an icon (state never rests on color
alone) and is announced via role="alert". Error microcopy follows the Voice rule:
number, then meaning, then the next step.
From your latest lab panel.
9.1 is below the expected range (9.5–15) — re-enter, or flag it for Dr. Reyes.
Locked by your plan.
import { Field, Input } from 'lumen/react';
<Field label="Hemoglobin (g/dL)" error={error}>
<Input value={value} onChange={onChange} />
</Field>Tabs
Radix underneath (arrow keys, roving tabindex, ARIA for free); Lumen on top — a quiet muted rail, the active tab lifted onto the card with calm Solar text.
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'lumen/react';
<Tabs defaultValue="spans">
<TabsList>
<TabsTrigger value="spans">Spans</TabsTrigger>
…
</TabsList>
<TabsContent value="spans">…</TabsContent>
</Tabs>Avatar
One rule, no drift: people wear the warm accent (Dawn ground, rust initials); the Agent alone wears the Solar gradient — it's the system's living presence, and the gradient is its badge. Photos fall back to initials.
import { Avatar } from 'lumen/react';
<Avatar name="Amara Okafor" src={photoUrl} />
<Avatar name="Halo Agent" kind="agent" />The loading kit
Five components and one hook, all made of the same light and all sitting over one timing contract. Which rung to reach for — and why a 90ms wait must render nothing at all — is Content → Loading; this is the API.
Spinner
The compact wait, for inside controls — the beam's channel looped: the same light bent into a
ring, its tail fading into the track, the glow head as the light-front. Pure CSS (conic gradient +
radial mask), so it stays crisp from 12px to 48px. tone="current" inherits the label's color —
that's what <Button loading> uses.
import { Spinner } from 'lumen/react';
<Spinner /> {/* decorative — aria-hidden */}
<Spinner size={24} label="Signing in…" /> {/* the whole message — role="status" */}Skeleton
A placeholder in the shape of what's coming, with the wait-light sweeping through it. SkeletonText
gives a paragraph its ragged last line; SkeletonGroup carries the one announcement for the whole
region (the shapes themselves are aria-hidden by contract). Stagger the sweep with delay and the
light reads down a list instead of every row flashing in unison.
import { Skeleton, SkeletonText, SkeletonGroup } from 'lumen/react';
<SkeletonGroup label="Loading your biograph">
<Skeleton shape="circle" size={40} />
<Skeleton shape="line" width="42%" height={11} />
<SkeletonText lines={3} />
<Skeleton height={72} /> {/* a chart well */}
</SkeletonGroup>Beam
The wait as a channel — light running the lumen of a vessel, for when you know neither the shape nor
the duration. RouteProgress is this beam pinned to the top of the viewport, with the timing contract
already applied.
import { Beam, RouteProgress } from 'lumen/react';
<Beam thickness={2} />
// React Router v7 — the page you're leaving stays legible
const nav = useNavigation();
<RouteProgress active={nav.state !== 'idle'} />LoadingOverlay
For content that is stale, not absent: the answer already on screen stays readable under a dim veil while a fresher one lands. First load gets skeletons; every load after gets this.
Resting heart rate
54 bpm — down 3 from your 30-day median, and the lowest reading since the Tuesday you started the iron protocol.
import { LoadingOverlay } from 'lumen/react';
<LoadingOverlay active={revalidating} label="Refreshing your timeline">
<Timeline events={events} />
</LoadingOverlay>Loader
The presence scale — for a wait that owns the screen. Deliberately not the logo, so the identity
mark isn't welded to every wait: five cells on the vessel line, beating an interior rhythm. rhythm
picks which of the six channel motions (heartbeat · pulse · perfuse · peristalsis · breath · sleep),
presence picks how much the system is showing up, and the vital driver gives the loop a wander so
it never ticks like a clock. At urgent the light scatters into a field around the cells.
import { Loader } from 'lumen/react';
<Loader label="Syncing Oura data…" />
<Loader rhythm="perfuse" presence="resting" />
<Loader size={48} presence="urgent" /> {/* the cell field comes along */}
<Loader size={48} cycle /> {/* heartbeat → breath → perfuse */}→ Vital — the motion language: one lumen, one tempo, six rhythms, the
presence dial, and why variance is what makes it read alive. wave still works as an alias of
perfuse.
Arrival
The wait's ending, designed: while pending it shows the placeholder; when the wait resolves, a
Loader in the placeholder glides its cells to rest (the field draws its light home), and only then
does the content rise in on the exhale (lumen-arrive, ×0.32 of the tempo each way). Placeholders
with nothing to land — skeletons, spinners — skip straight to the rise, and content that was never
waited for never animates. Custom placeholders join the handshake with useSettle.
Oura synced
312 new samples — resting heart rate 54 bpm.
import { Arrival } from 'lumen/react';
<Arrival pending={pending} placeholder={<Loader size={40} />}>
<Reading data={data} />
</Arrival>usePending
The contract itself: 180ms before a wait is admitted, 420ms minimum once it is, 6s before the copy
escalates. RouteProgress, LoadingOverlay, and the Arrival recipes ride it; everywhere else, you
call it yourself.
import { usePending, WAIT } from 'lumen/react';
const { pending, long } = usePending(fetcher.state !== 'idle');HaloMark
The identity itself — five dots holding the resting Vitruvian pose (head, two hands, two feet),
breathing at rest, dancing the logo-lab cycle
(body → hand → pulse → constellation) while Halo works. Fills currentColor. Reserved for
presence and brand moments — it is deliberately not the Loader (loading has its own
language), and never a bullet. Under reduced motion every pose settles to rest.
import { HaloMark } from 'lumen/react';
<HaloMark /> {/* resting, 28px */}
<HaloMark pose="breathe" /> {/* calm presence */}
<HaloMark pose="dance" size={40} /> {/* Halo is working */}Card
The surface family — full shadcn anatomy (CardHeader / CardTitle / CardDescription /
CardAction / CardContent / CardFooter) on the Lumen skin: warm card ground, hairline linen
border, the resting shadow.
Hemoglobin 11.2 g/dL, up from 9.8 — the supplement is working.
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter } from 'lumen/react';
<Card>
<CardHeader>
<CardTitle>Iron protocol</CardTitle>
<CardDescription>Week 3 of 8</CardDescription>
<CardAction><Badge variant="success">On track</Badge></CardAction>
</CardHeader>
<CardContent>…</CardContent>
</Card>Separator
The hairline seam. Radix underneath — decorative by default; pass decorative={false} when it
separates real content regions.
Dialog
Radix underneath (focus trap, Escape, aria wiring); Lumen on top — the warm --overlay wash on
the modal layer, a raised card panel that arrives with the gentle entrance and leaves
instantly. The close target clears the 24px floor.
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from 'lumen/react';
<Dialog>
<DialogTrigger asChild><Button>Flag for review</Button></DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Flag this reading?</DialogTitle>
<DialogDescription>…</DialogDescription>
</DialogHeader>
<DialogFooter>…</DialogFooter>
</DialogContent>
</Dialog>Sheet
A Dialog that enters from an edge — same modal lifecycle, a side-keyed entrance
(right default · left · bottom · top). The bottom sheet wears the grabber bar.
import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle } from 'lumen/react';
<Sheet>
<SheetTrigger asChild><Button>Open</Button></SheetTrigger>
<SheetContent side="bottom">…</SheetContent>
</Sheet>States
The failure paths as first-class components — one calm scaffold, the mark in three expressions:
rest (empty), breathe (loading), and the alert (error). Copy carries the meaning; the
visual is hidden from assistive tech. Loading announces politely; Error announces via
role="alert" and offers the retry.
No readings yet
Log your first blood pressure to light the halo.
Syncing Oura data…
Sync failed
Oura didn't answer. Your readings are safe.
import { EmptyState, LoadingState, ErrorState } from 'lumen/react';
<EmptyState title="No readings yet" actionLabel="Log a reading" onAction={openLog} />
<LoadingState label="Syncing Oura data…" />
<ErrorState title="Sync failed" body="Oura didn't answer." onRetry={retry} />Checkbox
Radix underneath (keyboard, aria-checked, indeterminate); the Solar fill when checked. Use for
consent and multi-select — immediate effects get a Switch. Pair with a label so the
target grows past the 24px floor.
import { Checkbox } from 'lumen/react';
<label className="flex items-center gap-2 text-sm">
<Checkbox defaultChecked /> Share readings with Dr. Reyes
</label>RadioGroup
One choice from a short list, all options visible. Radix roving arrows move selection and focus together; the chosen ring holds the Solar dot.
Switch
An immediate effect, on or off — linen track, warm thumb, Solar when on. Snaps under reduced motion. Consent belongs to Checkbox.
SegmentedControl
A few mutually-exclusive choices, all visible — choosing a value, not switching a view (views are Tabs). The selected segment lifts onto the card with quiet Solar text, the same calm-active rule as Tabs.
import { SegmentedControl, SegmentedControlItem } from 'lumen/react';
<SegmentedControl value={range} onValueChange={setRange} aria-label="Range">
<SegmentedControlItem value="day">Day</SegmentedControlItem>
<SegmentedControlItem value="week">Week</SegmentedControlItem>
</SegmentedControl>Select
Pick one from many — collapsed until asked. The trigger matches Input on the muted ground; options open on the popover layer with the Solar check on the chosen one. A few always-visible choices read better as a SegmentedControl.
Slider
A value on a continuous range — quiet muted track, Solar range, warm thumb. The interactive band is taller than the 6px line, so fingers have something to hit. Known small steps read better as a Stepper.
Stepper
A small count adjusted in known steps — role="spinbutton", arrows on the readout, −/+ that
disable at the bounds, tabular numerals on a fixed-width readout so digits don't shift the buttons.
import { Stepper } from 'lumen/react';
<Stepper value={doses} onChange={setDoses} min={0} max={10} unit="mg" label="Iron dose" />Tooltip
A hint on hover/focus — the inverted surface (ink ground, paper text), a transient whisper on the top layer. Never essential content: touch has no hover, so anything that matters must also exist on the surface.
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from 'lumen/react';
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild><Button>Setpoint?</Button></TooltipTrigger>
<TooltipContent>The target your plan steers toward.</TooltipContent>
</Tooltip>
</TooltipProvider>Banner
A persistent inline notice. Tone is carried three ways — accent bar + glyph + wording — so it
survives grayscale; the body stays neutral ink. Warning/danger announce assertively
(role="alert"), info/success politely. Transient confirmation belongs to Toast.
import { Banner } from 'lumen/react';
<Banner tone="warning" title="9.1 g/dL is below the expected range (9.5–15)"
body="Re-enter the value, or flag it for your care team."
actionLabel="Flag it" onAction={flag} onDismiss={dismiss} />Toast
Transient confirmation — sonner wearing the Lumen skin, mounted
once (<Toaster />) near the app root, fired with toast(…). Sonner brings the live region,
hover-pause, and reduced-motion handling. A notice that must persist is a Banner.
import { Toaster, toast } from 'lumen/react';
// once, near the root:
<Toaster />
// anywhere:
toast.success('Plan updated', { description: 'Step moved to Thursday.' });DropdownMenu
Actions behind a trigger — Radix typeahead/arrows/submenus, the popover-layer card. Items are
actions; picking a value is Select's job. variant="destructive" marks the
irreversible one.
Chip
A small labeled token — a filter that toggles (aria-pressed), a removable tag, or both.
Selected wears the Dawn tint with Solar ink; the remove × is its own ≥24px target with its own
focus ring.
import { Chip } from 'lumen/react';
<Chip selected={on} onClick={toggle}>Sleep</Chip>
<Chip selected onRemove={remove}>Fatigue</Chip>Metric
One vital, read at a glance — label · tabular value · unit · optional status Badge (triple-encoded). Tabular numerals keep metric grids column-aligned. Display-only: charts add the trend, blocks add the panel.
SearchInput
The Input grammar with a magnifier, a clear × that appears only with text (a ≥24px
labelled target), and a real Enter action (onSubmit). Always render the no-match state in
words — never an empty void.
- Dr. Reyes — OB-GYN
- Dr. Okafor — Primary care
- Nadia Osei — Doula
import { SearchInput } from 'lumen/react';
<SearchInput value={q} onChange={(e) => setQ(e.target.value)} onSubmit={run} placeholder="Search…" />Calendar & DatePicker
Calendar is react-day-picker fully re-skinned on the tokens. The two markers coexist, distinct
on purpose: today wears a Solar ring (where you are), selected wears the Solar fill (what
you chose). DatePicker puts it behind an Input-matched trigger on the popover layer — a date is
one gesture, so it commits on day tap and closes.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
Commits on day tap.
The platform's own picker.
import { Calendar, DatePicker, TimePicker } from 'lumen/react';
<DatePicker value={date} onChange={setDate} label="Next visit date" />
<TimePicker value="08:30" onChange={setTime} aria-label="Reminder time" />
<Calendar mode="range" selected={range} onSelect={setRange} />TimePicker
The native time input wearing the Input grammar with mono tabular digits. On web the platform's own picker wins — keyboard-native, screen-reader-native, and mobile browsers surface their wheel for free. (The hand-rolled column picker stays a native-app pattern.)
What's here / what's next
The kit now covers the foundational families end to end — actions (Button), forms (Input,
Field, Checkbox, RadioGroup, Switch, SegmentedControl, Select, Slider, Stepper, SearchInput,
DatePicker, TimePicker), surfaces (Card, Separator, Dialog, Sheet, Popover), the whole
loading ladder (Spinner, Skeleton, Beam, LoadingOverlay, Loader, Arrival, usePending),
feedback (Badge, Banner, Toast, Tooltip, the States), menus (DropdownMenu), identity
(Avatar, HaloMark), and data (Metric; Trend/Ring/Progress live with Charts —
Progress runs indeterminate on the same beam with value={null}) — all the same pattern: tokens →
cva variants → cn merge → all states rendered → a11y baked in. The Halo compositions (headers,
entity cards, the Timeline and Steps rails) live in Blocks. Coming next: a
registry.json so everything installs via the shadcn CLI, and the remaining app patterns (the Ask
surface, the hero motifs, the Notice register) promoted as their APIs settle.
→ The retired v0 screen mock shows the kit's ancestors assembled.