Interactive
Component gallery
These are not screenshots. Each example is the reference implementation in components/ui/ rendering live, so what you see is what you get when you copy the code. Written rules for each component are in the Components chapter.
Anything here is safe to lift into sincelabs/app as-is. Where the app currently differs, the app audit says so and why.
Buttons
Variants
Primary for the one action the page is for. Secondary for everything alongside it. Ghost for tertiary controls in dense chrome. Destructive only where something is actually destroyed.
<Button variant="primary">Save changes</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="ghost">Dismiss</Button>
<Button variant="destructive">Delete organization</Button>Sizes
lg (48px) on forms, auth and marketing. md (38px) inline in page chrome. sm (32px) inside table rows and toolbars only.
<Button size="lg">Large — forms</Button>
<Button size="md">Medium — inline</Button>
<Button size="sm">Small — table rows</Button>Disabled and link-as-button
A link that looks like a button must still be an anchor — it navigates. Use buttonVariants() rather than wrapping a Button in a Link.
<Button disabled>Signing in…</Button>
<Link href="/login" className={buttonVariants({ variant: "secondary" })}>
Back to sign in
</Link>Name the action. The label completes “I want to…”.
“Submit”, “OK” and “Yes” tell the reader nothing about what is about to happen.
Status
Status chips
Six tones, each a background, a text colour and a dot. The dot carries the meaning when colour cannot — greyscale, colour blindness, a bad projector.
<StatusChip status="active" />
<StatusChip status="pending" />
<StatusChip status="rejected" />
<StatusChip tone="warning" label="Stale data" />
<StatusChip status="internal" />
<StatusChip status="retired" />Alerts
An inline message about the surface it sits on. error and warning carry role="alert"; the quieter tones do not, so a page of notes does not shout on load.
That invite link has expired.
Ask your administrator to send a new one.Three fee schedules need review.
Ingestion finished, but the extractor was not confident about these.<Alert tone="error" title="That invite link has expired.">
Ask your administrator to send a new one.
</Alert>Forms
Text input
The label is always present and always tied to the input by id. A placeholder is not a label — it vanishes the moment someone types.
<Input label="Work email" type="email" placeholder="[email protected]" />
<Input label="Company name" hint="As it appears on your invoices." />
<Input label="Work email" error="Enter a valid email address." />Visible label, placeholder shows the format. Survives typing, autofill and a screen reader.
Placeholder-as-label. Disappears on input, fails at 4.5:1, and is invisible to assistive tech.
Containers
Card and Panel
Card is a bare container. Panel adds the titled header rail — use it whenever the content needs a name, which is most of the time.
A bare card. Border first, shadow barely there — surfaces are separated by a hairline, not by floating.
Members
Everyone with access to this workspace.
Panel body.
<Card className="p-5">…</Card>
<Panel title="Members" description="Everyone with access." actions={<Button size="sm">Invite</Button>}>
…
</Panel>Page header
Every page opens with exactly this. Breadcrumb, optional eyebrow, serif title, description capped at 62ch, actions right.
Review queue
Fee schedules the extractor was not confident about. Approving one publishes it to the register immediately.
<PageHeader
breadcrumbs={[{ label: "Apps", href: "/apps" }, { label: "Sora" }]}
eyebrow="Operator console"
title="Review queue"
description="Fee schedules the extractor was not confident about."
actions={<Button size="md">Run ingestion</Button>}
/>Data
Stat tiles
dt/dd pairs, so a screen reader reads the label with its value. Figures are tabular so a column of them lines up. At most one accent tile per grid.
- Municipalities
- 309
- Current schedules
- 212
- In review
- 14
- Median fee
- €1,840
Full national register
68% coverage
<StatGrid columns={4}>
<StatTile label="Municipalities" value="309" accent />
<StatTile label="Current schedules" value="212" hint="68% coverage" />
</StatGrid>Table
Wide tables scroll inside their own box rather than pushing the page sideways. The caption is visually hidden but tells a screen reader what the table is. Numbers right-aligned and tabular.
| Company | Status | Requester | Members |
|---|---|---|---|
| Maanrakennus Virtanen Oy | Pending | [email protected] | 12 |
| Oteran Infra Oy | Active | [email protected] | 147 |
<DataTable caption="Recent access requests" head={<><Th>Company</Th><Th numeric>Members</Th></>}>
<Tr><Td>Maanrakennus Virtanen Oy</Td><Td numeric>12</Td></Tr>
</DataTable>Empty state
Title says what is not here. Description says why or what to do. Action if there is one. Never a shrug.
No access requests yet
Requests submitted through the public form land here for review.
<EmptyState
title="No access requests yet"
description="Requests from the public form land here for review."
action={<Button size="md">Invite someone</Button>}
/>Identity
Eyebrow
The one place the brand uses uppercase. Always tracked out, always muted, always above a heading. The // device is optional and limited to one per surface.
//AI Native Lab
Operator console
<Eyebrow slash>AI Native Lab</Eyebrow>
<Eyebrow>Operator console</Eyebrow>Avatar
Initials from a name, falling back to an email. Terracotta-ink fill so white initials clear AA; mist for a quieter, secondary context.
<Avatar name="Yuehan John" />
<Avatar name="Jaakko Mäkipelto" tone="mist" />
<Avatar email="[email protected]" size={44} />