Skip to content
Brand guide

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

client

The 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>
AppShell props
PropTypeDefaultWhat 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.
actionsReactNode—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.

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>
Card / Panel props
PropTypeDefaultWhat it does
titleReactNode—Panel only. Renders in the header rail.
headingLevel"h2" | "h3" | "h4""h2"Caller-set so the document outline stays correct.
actionsReactNode—Controls in the header rail, right-aligned.
bodyClassNamestring"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.

Draft
Edited 3m ago
<Separator />
<Separator label="or" />
<Separator orientation="vertical" />
Separator props
PropTypeDefaultWhat it does
orientation"horizontal" | "vertical""horizontal"—
semanticbooleanfalseAnnounce it. Only where the rule divides two genuinely different groups.
labelReactNode—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>
Eyebrow props
PropTypeDefaultWhat it does
slashbooleanfalsePrefix with the // mark. One per surface.

Rules

  • Always above a heading, never alone, never a button.

Actions

Things a person presses.

Button

Four variants, three sizes. Primary is the one action the page exists for.

<Button variant="primary">Save changes</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="ghost">Dismiss</Button>
<Button variant="destructive">Delete organization</Button>

<Button size="lg">Large — forms</Button>
<Button size="md">Medium — inline</Button>
<Button size="sm">Small — table rows</Button>
Button props
PropTypeDefaultWhat it does
variant"primary" | "secondary" | "ghost" | "destructive""primary"—
size"sm" | "md" | "lg""lg"lg 48px on forms; md 38px inline; sm 32px in table rows only.
type"button" | "submit""button"Defaults to button, NOT the HTML default of submit.

Rules

  • One primary per surface. Two primaries is no primary.
  • The primary fill is --sl-accent-ink (#A34A1F), not --sl-accent (#C4602A): white on #C4602A is 4.16:1 and fails AA.
  • destructive only where something is actually destroyed.
  • Inside a <form action>, pass type="submit" explicitly — the default here is "button" and it fails silently.

Accessibility

  • Focus ring is instant and never transitioned.
  • Disabled buttons keep 4.5:1 label contrast at 50% opacity on paper.

CopyButton / CopyField

client

Copy a value and say so. CopyField pairs it with a read-only input.

<CopyButton value="sk_live_9f2a…" />
<CopyField label="Invite link" value="https://app.sincelabs.com/join/9f2a" />
CopyButton / CopyField props
PropTypeDefaultWhat it does
value*string—What lands on the clipboard.
labelstring"Copy"—

Rules

  • Clipboard access fails on an insecure origin; the failure surfaces as "Press ⌘C" rather than a false success.

Accessibility

  • The confirmation is announced in a polite live region, not only drawn.

Forms

Things a person fills in.

Field

The label / hint / error scaffold every control shares. Use it to build a control this set lacks.

Where agent runs execute. Cannot be changed later.
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>
Field props
PropTypeDefaultWhat it does
ids*FieldIds—From useFieldIds({ id, hint, error }).
requiredboolean—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.

We only use this to sign you in.
That name is taken.
<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." />
Input props
PropTypeDefaultWhat it does
labelReactNode—Always rendered. A placeholder is not a label.
hintReactNode—Guidance before typing.
errorstring—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.

Plain language. The model reads this verbatim.
<Textarea label="What should this agent do?" hint="Plain language. The model reads this verbatim." />
Textarea props
PropTypeDefaultWhat it does
rowsnumber4The 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" }]}
/>
Select props
PropTypeDefaultWhat 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.

Notify me when

Applies to this workspace only.

Can be noisy on hourly schedules.
Owners only.
<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.

Visibility
Only you.
Everyone in Since Labs can run it.
Anyone with the link.
<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

client

An on/off control that takes effect immediately.

Pause on failureStops the schedule after two consecutive errors.
<Switch
  checked={enabled}
  onCheckedChange={setEnabled}
  label="Pause on failure"
  hint="Stops the schedule after two consecutive errors."
/>
Switch props
PropTypeDefaultWhat 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.

Navigation

Moving between views and pages.

Tabs

client

Tabbed 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>
Tabs props
PropTypeDefaultWhat 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

client

Two 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} />
Pagination props
PropTypeDefaultWhat 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?
One execution of one agent, from trigger to final output. A retry after a failure is a new run.
Where does the data live?
In the region you chose when the workspace was created. It is not moved.
<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.

Recent runs
AgentStatusDuration
Invoice filerActive1.4s
Lead scorerPending12.8s
Weekly digestFailed0.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>
DataTable props
PropTypeDefaultWhat it does
caption*string—Visually hidden. Says what the table is before anyone arrows into it.
numericboolean—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

Last 7 days

Active agents
12
Failure rate
1.8%

Down from 4.1%

<StatGrid columns={3}>
  <StatTile label="Runs" value="391" hint="Last 7 days" accent />
  <StatTile label="Agents" value="12" />
</StatGrid>
StatTile / StatGrid props
PropTypeDefaultWhat it does
accentbooleanfalseThe 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.
Region cannot be changed after the workspace is created.
<Alert tone="warning" title="Two runs failed">
  Both hit the same timeout. The schedule is paused.
</Alert>
Alert props
PropTypeDefaultWhat it does
toneStatusTone"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.

ActivePendingWarningFailedDraftArchived
<StatusChip status="active" />
<StatusChip status="pending" />
<StatusChip tone="neutral" label="Archived" />
StatusChip props
PropTypeDefaultWhat it does
statusstring—Mapped to a tone by toneForStatus, so one value cannot render two ways.
toneStatusTone—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.

12TeamBetav2.3.0
<Badge>12</Badge>
<Badge tone="accent">Team</Badge>
<Badge tone="ink">Beta</Badge>
<Badge mono tone="outline">v2.3.0</Badge>
Badge props
PropTypeDefaultWhat it does
monobooleanfalseFor 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.

Importing records62%
Indexing100%
Waiting for the provider
<Progress label="Importing records" value={62} showValue />
<Progress label="Waiting for the provider" value={null} />
Progress props
PropTypeDefaultWhat 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.

Loading
<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

client

Confirmation that the thing you just did worked.

Appears bottom-right.
// Wrap the app once:
<ToastProvider>{children}</ToastProvider>

// Then, anywhere inside it:
const toast = useToast();
toast({ tone: "success", title: "Agent created", description: "It runs hourly." });
ToastProvider / useToast props
PropTypeDefaultWhat it does
durationnumber5000Errors 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.

Drawer

client

A 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>
Drawer props
PropTypeDefaultWhat 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

client

A short hint attached to a control, on hover and on focus.

Monthly runs
<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.

layout-dashboardhomegridboxusersusersettingsbuildinginboxtrashshieldsearcharrow-leftarrow-rightarrow-up-rightchevron-downchevron-rightlog-outmenuxcheckpluscopydownloadexternal-linkinfoalertbellclockfilterkeysparkleswappin
<Icon name="search" />
<Icon name="shield" size={20} />
// Every name: ICON_NAMES
Icon props
PropTypeDefaultWhat it does
name*IconName—See ICON_NAMES for the full set.
titlestring—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

client

Light / system / dark. Three states, because "system" is a real choice and the default.

This one is live — it drives the theme of the page you are reading.
// 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.