Interactive
Component gallery
All 37 components in the Since Labs system, rendered live from components/ui/. These are not screenshots — what you see is what you get when you copy the code. Every example uses placeholder data; nothing here describes a real person or company.
Everything here is safe to lift into a Since Labs product as-is. The whole set imports from one path:
import { Button, Panel, StatusChip } from "@/components/ui";The written rules for each component are in the Components chapter; the same information as data is at /components.json, generated from the same registry this page renders from — so the page and the JSON cannot describe different systems. Where the product currently differs, the app audit says so and why.
Every component below works in both themes. Use the toggle in the sidebar to check the one you are about to ship — the dark theme is not a variant, it is half the brand.
Layout
The frame a page sits in.
AppShell
clientThe product frame: a recessed nav rail, a content column, and the same rail behind a drawer on a phone.
Agents
Content column on paper; rail on base.
<AppShell
brand={<Wordmark className="h-5" />}
currentPath="/agents"
sections={[{ title: "Workspace", items: [
{ label: "Overview", href: "/", icon: "layout-dashboard" },
{ label: "Agents", href: "/agents", icon: "grid" },
]}]}
renderLink={({ href, ...rest }) => <Link href={href} {...rest} />}
>
<PageHeader title="Agents" />
</AppShell>| Prop | Type | Default | What it does |
|---|---|---|---|
| brand* | ReactNode | — | The lockup at the top of the rail. |
| sections* | ShellNavSection[] | — | Nav rows, optionally grouped under titles. |
| currentPath* | string | — | Matched against each href to mark the current row. |
| actions | ReactNode | — | Top-right of the content column: account menu, search. |
| renderLink | (props) => ReactNode | — | Supply your router's Link. Defaults to a plain anchor. |
Rules
- Use it rather than rebuilding the layout — it carries the skip link, the main landmark and the rail recession.
- The rail is --sl-paper-base and the content is --sl-paper. That difference is the recession; do not add a shadow.
Accessibility
- Renders the skip link and <main id="content"> it targets.
- Marks the active row with aria-current="page", not colour alone.
PageHeader
Breadcrumb, eyebrow, h1, description and actions. Every page opens with exactly this.
Organizations
Every workspace on the platform.
<PageHeader
breadcrumbs={[{ label: "Admin", href: "/admin" }, { label: "Organizations" }]}
eyebrow="Directory"
title="Organizations"
description="Every workspace on the platform."
actions={<Button size="md">New organization</Button>}
/>| Prop | Type | Default | What it does |
|---|---|---|---|
| title* | ReactNode | — | Rendered as the page's only h1. |
| breadcrumbs | Crumb[] | — | Ancestors. The last item is the current page and is never a link. |
| eyebrow | ReactNode | — | Tracked-out uppercase label above the title. |
| actions | ReactNode | — | Page-level buttons, right-aligned. |
Rules
- Never hand-roll a heading beside it — that is how two pages end up with two title sizes.
- One per page. A second PageHeader means a second h1.
Accessibility
- Supplies the page's h1. The app audit found eleven pages with no h1 at all; this closes that.
Card / Panel
The default container. Panel adds a titled header rail, and is the one you usually want.
Usage
This billing period
391 runs across 12 agents.
A bare Card — for content that is already labelled by something above it.
<Panel title="Usage" description="This billing period" actions={<Button size="sm" variant="ghost">Export</Button>}>
<p>391 runs across 12 agents.</p>
</Panel>| Prop | Type | Default | What it does |
|---|---|---|---|
| title | ReactNode | — | Panel only. Renders in the header rail. |
| headingLevel | "h2" | "h3" | "h4" | "h2" | Caller-set so the document outline stays correct. |
| actions | ReactNode | — | Controls in the header rail, right-aligned. |
| bodyClassName | string | "p-5" | Set to "" for a flush table or list. |
Rules
- White on paper, one hairline border, the barely-there raised shadow. Not two of the three.
- Content that needs a name gets a Panel. A bare Card is for content that is already labelled.
Separator
A hairline rule, decorative by default, optionally labelled.
<Separator />
<Separator label="or" />
<Separator orientation="vertical" />| Prop | Type | Default | What it does |
|---|---|---|---|
| orientation | "horizontal" | "vertical" | "horizontal" | — |
| semantic | boolean | false | Announce it. Only where the rule divides two genuinely different groups. |
| label | ReactNode | — | Renders as a labelled divider, e.g. "or". Implies semantic. |
Rules
- Decorative by default: a rule between two cards is not information.
Eyebrow
The tracked-out uppercase section label. The one place the brand uses capitals.
//Command center
Directory
<Eyebrow slash>Command center</Eyebrow>
<Eyebrow>Directory</Eyebrow>| Prop | Type | Default | What it does |
|---|---|---|---|
| slash | boolean | false | Prefix with the // mark. One per surface. |
Rules
- Always above a heading, never alone, never a button.
Actions
Things a person presses.
TextLink
A link in running text, in the one terracotta that passes AA.
The rule that catches people is in the colour chapter — and the marks themselves live at sincelabs.com (opens in a new tab).
<TextLink href="/guide/color/">the colour chapter</TextLink>
<TextLink href="https://sincelabs.com">sincelabs.com</TextLink>| Prop | Type | Default | What it does |
|---|---|---|---|
| external | boolean | — | Inferred from the href. Adds rel=noopener noreferrer and says so. |
| underline | boolean | true | Drop it only in chrome where position marks the link. |
Rules
- Links are --sl-accent-ink (#A34A1F). --sl-accent (#C4602A) is 4.16:1 and fails AA — the app audit counted 62 of these.
- Underlined in prose: colour alone is not a distinguishing feature (WCAG 1.4.1).
Forms
Things a person fills in.
Field
The label / hint / error scaffold every control shares. Use it to build a control this set lacks.
const ids = useFieldIds({ id, hint, error });
<Field ids={ids} label="Region" hint="Where runs execute." required>
<select id={ids.controlId} aria-describedby={ids.describedBy} className={controlClasses()}>…</select>
</Field>| Prop | Type | Default | What it does |
|---|---|---|---|
| ids* | FieldIds | — | From useFieldIds({ id, hint, error }). |
| required | boolean | — | Renders the asterisk and the screen-reader-only "(required)". |
Rules
- An error replaces the hint rather than stacking under it.
Accessibility
- useFieldIds wires hint and error into aria-describedby, so they are read as part of the field.
Input
A labelled single-line text field.
<Input label="Work email" type="email" placeholder="[email protected]" hint="We only use this to sign you in." />
<Input label="Workspace" defaultValue="since" error="That name is taken." />| Prop | Type | Default | What it does |
|---|---|---|---|
| label | ReactNode | — | Always rendered. A placeholder is not a label. |
| hint | ReactNode | — | Guidance before typing. |
| error | string | — | Sets aria-invalid and replaces the hint. |
Rules
- Never use a placeholder as the label: it disappears exactly when it is needed.
Textarea
Multi-line text. Four rows by default, vertical resize only.
<Textarea label="What should this agent do?" hint="Plain language. The model reads this verbatim." />| Prop | Type | Default | What it does |
|---|---|---|---|
| rows | number | 4 | The browser's 2 invites a two-line answer. |
Rules
- Horizontal resize is disabled — dragging it wide breaks the page column.
Select
A native select, deliberately. The OS picker beats a custom listbox on a phone.
<Select
label="Model"
options={[{ value: "opus", label: "Opus" }, { value: "sonnet", label: "Sonnet" }]}
/>| Prop | Type | Default | What it does |
|---|---|---|---|
| options | { value, label, disabled? }[] | — | Convenience alternative to children. |
Rules
- Reach for a custom control only when the options need search, multi-select or rich rows — and then it is a DropdownMenu.
Checkbox / CheckboxGroup
A real input under a styled box, and the fieldset that makes a set of them one question.
<CheckboxGroup legend="Notify me when" hint="Applies to this workspace only.">
<Checkbox label="A run fails" defaultChecked />
<Checkbox label="A run finishes" hint="Can be noisy on hourly schedules." />
</CheckboxGroup>Rules
- A checkbox states an intention a Save button will commit. If it takes effect immediately, it is a Switch.
- Two or more related boxes go inside a CheckboxGroup, which renders the fieldset and legend.
Accessibility
- Native input, so it survives Windows High Contrast Mode, where a styled div disappears.
Radio / RadioGroup
One of several mutually exclusive options.
<RadioGroup legend="Visibility">
<Radio name="vis" label="Private" defaultChecked />
<Radio name="vis" label="Workspace" hint="Everyone in Since Labs can run it." />
</RadioGroup>Rules
- Never ship a lone radio: unlike a checkbox it cannot be unset by the person who set it.
- Every radio in a group shares one name — that is what makes the arrow keys move between them.
Switch
clientAn on/off control that takes effect immediately.
<Switch
checked={enabled}
onCheckedChange={setEnabled}
label="Pause on failure"
hint="Stops the schedule after two consecutive errors."
/>| Prop | Type | Default | What it does |
|---|---|---|---|
| checked* | boolean | — | Controlled. |
| onCheckedChange* | (next: boolean) => void | — | — |
| label* | ReactNode | — | Required. A switch with no label is a mystery toggle. |
Rules
- If the page has a Save button, the control is a Checkbox. A switch beside Save asks the person to guess.
Accessibility
- role="switch", so it is announced as on/off rather than checked.
Tabs
clientTabbed sections with roving tabindex and arrow-key movement.
Panel for runs. Arrow keys move between tabs; only the selected one is in the tab order.
<Tabs
items={[{ id: "runs", label: "Runs" }, { id: "logs", label: "Logs" }]}
value={tab}
onValueChange={setTab}
>
{tab === "runs" ? <RunsTable /> : <Logs />}
</Tabs>| Prop | Type | Default | What it does |
|---|---|---|---|
| items* | TabItem[] | — | id, label, optional badge. |
| value* | string | — | Controlled selection. |
Rules
- Only the selected panel renders. If a hidden panel holds a form, render all of them and toggle hidden instead.
- Tabs switch views of one thing. Different things are different pages.
Accessibility
- Roving tabindex: only the selected tab is in the tab order. Without it, eight tabs cost eight Tab presses.
- Left/Right move, Home/End jump, and selection follows focus.
SegmentedControl
clientTwo to four short, mutually exclusive options that change a view.
<SegmentedControl
label="Range"
value={range}
onValueChange={setRange}
options={[{ value: "7d", label: "7 days" }, { value: "30d", label: "30 days" }]}
/>Rules
- Five or more options, or anything longer than two words, wants a Select.
- It sets a value; it does not control a labelled panel. That is Tabs.
Accessibility
- A radiogroup, so state is announced as "selected, 2 of 3".
Pagination
Page controls that state the range in words: 1–25 of 340.
<Pagination page={page} pageSize={25} total={340} onPageChange={setPage} />| Prop | Type | Default | What it does |
|---|---|---|---|
| hrefFor | (page: number) => string | — | Renders links instead of buttons, keeping back, middle-click and sharing. |
Rules
- "Page 2 of 14" does not tell anyone whether the row they want is ahead or behind. State the range.
Disclosure
A section that opens and closes, built on details/summary.
What counts as a run?
Where does the data live?
<DisclosureGroup>
<Disclosure summary="What counts as a run?">A run is one execution…</Disclosure>
</DisclosureGroup>Rules
- Not an accordion: opening one does not close its siblings, so two sections can be compared.
- details/summary means find-in-page opens a closed section containing the match. A div-and-state version hides it from Ctrl+F.
Data
Records, figures and tables.
DataTable
The table shell: a scroll box, a required caption, and the header and cell styles.
| Agent | Status | Duration |
|---|---|---|
| Invoice filer | Active | 1.4s |
| Lead scorer | Pending | 12.8s |
| Weekly digest | Failed | 0.3s |
<DataTable caption="Recent runs" head={<><Th>Agent</Th><Th>Status</Th><Th numeric>Duration</Th></>}>
<Tr><Td>Invoice filer</Td><Td><StatusChip status="active" /></Td><Td numeric>1.4s</Td></Tr>
</DataTable>| Prop | Type | Default | What it does |
|---|---|---|---|
| caption* | string | — | Visually hidden. Says what the table is before anyone arrows into it. |
| numeric | boolean | — | On Th/Td: right-aligns and applies tabular-nums. |
Rules
- The overflow-x-auto wrapper is not optional: without it a wide table pushes the whole page sideways on a phone.
- Figures are tabular-nums so a column of them lines up.
Accessibility
- Caption is required by the API. The app audit found 16 tables and 0 captions.
DescriptionList
Label-and-value pairs for a detail panel or a record summary.
- Plan
- Team
- Region
- Europe (Helsinki)
- Workspace id
- ws_9f2a4c1e
- Created
- 2026-02-11T09:14:00Z
<DescriptionList columns={2}>
<DescriptionItem term="Plan">Team</DescriptionItem>
<DescriptionItem term="Workspace id" mono>ws_9f2a4c1e</DescriptionItem>
</DescriptionList>Rules
- A real dl, so "Plan: Team" is read as one thing rather than two unrelated cells.
StatTile / StatGrid
A headline figure with its label, and the grid they sit in.
- Runs
- 391
- Active agents
- 12
- Failure rate
- 1.8%
Last 7 days
Down from 4.1%
<StatGrid columns={3}>
<StatTile label="Runs" value="391" hint="Last 7 days" accent />
<StatTile label="Agents" value="12" />
</StatGrid>| Prop | Type | Default | What it does |
|---|---|---|---|
| accent | boolean | false | The terracotta wash. One per grid, on the figure that matters most. |
Rules
- Rendered as a dt/dd pair, so the label and its value are read together.
EmptyState
What a surface says when it has nothing to show.
No agents yet
An agent is a job you describe once and never do again.
<EmptyState
title="No agents yet"
description="An agent is a job you describe once and never do again."
action={<Button>Create an agent</Button>}
/>Rules
- Three parts: what is not here, why or what to do, and the control that does it.
- The title is never a joke. It states what is missing.
Status and feedback
What is happening, and what happened.
Alert
An inline message about the surface it sits on. Six tones.
Workspace created
You can invite people from Settings.Two runs failed
Both hit the same timeout. The schedule is paused until you resume it.Billing failed
The card on file was declined. Runs are queued, not lost.<Alert tone="warning" title="Two runs failed">
Both hit the same timeout. The schedule is paused.
</Alert>| Prop | Type | Default | What it does |
|---|---|---|---|
| tone | StatusTone | "info" | — |
Rules
- It stays on the page. A message that needs a decision is never a Toast.
- error and warning announce themselves; the quieter tones do not, so a page of notes does not shout on load.
StatusChip
State as a chip, with a dot that carries the meaning without colour.
<StatusChip status="active" />
<StatusChip status="pending" />
<StatusChip tone="neutral" label="Archived" />| Prop | Type | Default | What it does |
|---|---|---|---|
| status | string | — | Mapped to a tone by toneForStatus, so one value cannot render two ways. |
| tone | StatusTone | — | Override the mapping. |
Rules
- The dot is not decoration: it is the non-colour channel. Never ship a status pill that is a bare colour swatch.
Badge
A short static label: a count, a plan name, a version.
<Badge>12</Badge>
<Badge tone="accent">Team</Badge>
<Badge tone="ink">Beta</Badge>
<Badge mono tone="outline">v2.3.0</Badge>| Prop | Type | Default | What it does |
|---|---|---|---|
| mono | boolean | false | For identifiers, versions and SHAs. |
Rules
- Not a StatusChip. If the thing can be "active" or "failed", it is a chip.
- Never interactive. A clickable badge is a button that looks like a label, and nobody clicks it.
Progress
How far along something is. Determinate whenever you can say.
<Progress label="Importing records" value={62} showValue />
<Progress label="Waiting for the provider" value={null} />| Prop | Type | Default | What it does |
|---|---|---|---|
| value* | number | null | — | null when the total is genuinely unknown. |
| label* | string | — | Required: a bar with no label describes nothing. |
Rules
- The fill animates with scaleX, never width — animating width re-lays out on every frame.
Accessibility
- Indeterminate is a real state: progressbar with no aria-valuenow, not a styling variant.
Spinner
The busy indicator. One second, linear, forever.
<Spinner />
<Button disabled><Spinner size={14} label={null} /> Saving…</Button>Rules
- Prefer a Skeleton for content that has a shape, and prefer nothing at all under ~300ms.
- Under prefers-reduced-motion it stops dead at 65% opacity — a 0.01ms infinite spin strobes.
Skeleton
A placeholder in the shape of what is coming, so nothing jumps when it lands.
<Skeleton className="h-4 w-40" />
<SkeletonText lines={3} />Rules
- Shape it honestly. A 200px block where three lines will appear is worse than a spinner.
Accessibility
- aria-hidden. The loading state belongs to the live region around the fetch, not to a description of grey boxes.
ToastProvider / useToast
clientConfirmation that the thing you just did worked.
// Wrap the app once:
<ToastProvider>{children}</ToastProvider>
// Then, anywhere inside it:
const toast = useToast();
toast({ tone: "success", title: "Agent created", description: "It runs hourly." });| Prop | Type | Default | What it does |
|---|---|---|---|
| duration | number | 5000 | Errors ignore it and stay until dismissed. |
Rules
- Reports the result of an action the person just took. Not a notification channel.
- Never the only record of anything important — it disappears fastest for the people reading slowest.
- Something that needs a decision is an Alert, on the surface, where it stays.
Accessibility
- The region is polite; only the error tone uses role="alert" and interrupts.
Overlays
Surfaces that sit above the page.
Modal / ConfirmDialog
clientA native dialog in the browser's top layer, plus the destructive-action confirmation built on it.
<ConfirmDialog
open={open}
onClose={() => setOpen(false)}
onConfirm={remove}
title="Delete this agent?"
description="Its history goes with it. This cannot be undone."
confirmLabel="Delete agent"
/>| Prop | Type | Default | What it does |
|---|---|---|---|
| open* | boolean | — | Controlled. The element is opened by method, not by attribute. |
| confirmLabel | string | — | ConfirmDialog. Names the action — "Delete organization", never "OK". |
Rules
- The top layer is why this is a <dialog>: no ancestor's overflow can clip it. A popover inside an overflow-hidden Card is cut off at the card edge.
- It appears instantly. A modal is a mode change; a fade makes the whole application feel slow.
Accessibility
- The element supplies the focus trap, Escape, aria-modal and inert-ing of the page behind it.
Drawer
clientA panel that slides in from an edge. 200ms, with the backdrop fading over the same duration.
<Drawer open={open} onClose={() => setOpen(false)} title="Filters" side="right">
<CheckboxGroup legend="Status">…</CheckboxGroup>
</Drawer>| Prop | Type | Default | What it does |
|---|---|---|---|
| side | "left" | "right" | "left" | Sets --sl-slide-from; one keyframe serves both. |
Rules
- The only full-distance translate in the system, and the only sanctioned use of translateX.
Accessibility
- Focus moves to the panel on open, Tab cycles inside it, and focus returns to the trigger on close.
Tooltip / InfoTip
clientA short hint attached to a control, on hover and on focus.
<Tooltip content="Runs are billed per execution.">
<Button variant="ghost" size="sm">Runs</Button>
</Tooltip>
<span>Monthly runs <InfoTip>Resets on the first of the month.</InfoTip></span>Rules
- Never the only place the information exists: a tooltip is unreachable on touch and invisible in print.
- It describes, it does not label — the trigger keeps its own accessible name.
Accessibility
- Opens on focus as well as hover, and Escape closes it while the trigger keeps focus.
Identity
Marks, icons and the theme.
Avatar
Initials on terracotta, from a name, falling back to an email.
<Avatar name="Sample User" />
<Avatar email="[email protected]" tone="mist" size={28} />Rules
- aria-hidden: the person's name is already beside it, and "AL" read aloud is noise.
Icon
The icon set. One 24×24 grid, 2px stroke, round caps, no fills.
<Icon name="search" />
<Icon name="shield" size={20} />
// Every name: ICON_NAMES| Prop | Type | Default | What it does |
|---|---|---|---|
| name* | IconName | — | See ICON_NAMES for the full set. |
| title | string | — | Only for a standalone glyph with no control around it. |
Rules
- Decoration by default. When an icon is a control's only content, the name goes on the control.
- Never scale a 16px icon up — the stroke thickens with it. Never mix in a filled or two-tone glyph.
ThemeToggle
clientLight / system / dark. Three states, because "system" is a real choice and the default.
// In <head>, before first paint:
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
// Anywhere in the chrome:
<ThemeToggle />Rules
- Requires THEME_SCRIPT in <head> — see lib/theme.ts. Without it the page paints in the wrong theme first.
- "system" removes data-theme rather than writing a resolved value, so the page tracks the OS live.
Accessibility
- A radiogroup: "System, selected, 2 of 3", not three unrelated toggles.