Unify

Components

Every component, live, with the code to use it. They follow the theme in the header and the accent of the app they run in.

Button

Primary is ink, not accent. One primary per view; `accent` at most once per screen. `loading` keeps the label and adds aria-busy.

tsx
<Button>Save changes</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="danger"><Trash2 /> Delete</Button>
<Button loading={pending}>Saving</Button>
<Button asChild><Link href="/new">New</Link></Button>
<IconButton label="More actions"><MoreHorizontal /></IconButton>

Badge

Status is coloured text on a tint — never a solid fill.

LivePrivate betaDownInfoNewDraftv2
tsx
<Badge variant="success">Live</Badge>
<Badge variant="warning">Private beta</Badge>
<Badge>Draft</Badge>

Card

Raised surface, hairline border, 14px radius, no shadow. `interactive` for cards that are links.

October spending

42 receipts from 9 stores.

€1,284.50
tsx
<Card>
  <CardHeader>
    <CardTitle>October spending</CardTitle>
    <CardDescription>42 receipts from 9 stores.</CardDescription>
  </CardHeader>
  <CardContent>…</CardContent>
  <CardFooter><Button size="sm" variant="secondary">View receipts</Button></CardFooter>
</Card>

Field, Input, Select, Textarea

Field wires the label, hint and error to the control for assistive tech. Fields get an accent border and a soft ring on focus.

As it appears on invoices.

tsx
<Field label="Email" hint="We never share it." error={errors.email}>
  {(props) => <Input type="email" name="email" {...props} />}
</Field>

<Select defaultValue="eur">
  <SelectTrigger><SelectValue /></SelectTrigger>
  <SelectContent>
    <SelectItem value="eur">Euro (EUR)</SelectItem>
  </SelectContent>
</Select>

Checkbox, Radio, Switch

Checked is ink. A switch applies at once; a checkbox is part of a form that is saved.

tsx
<Label className="flex items-center gap-2 font-normal"><Checkbox /> Email me</Label>
<RadioGroup defaultValue="monday">
  <Label className="flex items-center gap-2"><RadioGroupItem value="monday" /> Monday</Label>
</RadioGroup>
<Switch checked={on} onCheckedChange={setOn} />

Segmented control & theme

A few mutually exclusive options, inline. ThemeSwitcher and ThemeToggle are built on it.

tsx
<SegmentedControl value={range} onValueChange={setRange}
  options={[{ value: "day", label: "Day" }, { value: "week", label: "Week" }]} />
<ThemeSwitcher />   {/* settings */}
<ThemeToggle />     {/* top bar */}

Tabs

Views of the same object. For navigation between pages, use links.

Overview content.
tsx
<Tabs defaultValue="overview">
  <TabsList>
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="items">Items</TabsTrigger>
  </TabsList>
  <TabsContent value="overview">…</TabsContent>
</Tabs>

Dialog & Sheet

Glass, 20px radius, the modal shadow. Sheets slide from an edge — mobile navigation and side panels.

tsx
<Dialog>
  <DialogTrigger asChild><Button variant="secondary">Delete</Button></DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Delete this receipt?</DialogTitle>
      <DialogDescription>This cannot be undone.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose asChild><Button variant="secondary">Cancel</Button></DialogClose>
      <Button variant="danger" onClick={remove}>Delete receipt</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Toast

Mount <Toaster /> once in the root layout; call toast() anywhere on the client.

tsx
import { toast } from "@nashyvan/unify";
toast.success("Settings saved");
toast.error("Could not reach the server", { action: { label: "Retry", onClick: retry } });

Table

Mono uppercase heads, 44px rows, a quiet row hover. Wrap in a Card for the border; it scrolls inside itself on phones.

StoreDateTotal
Rewe12 Oct 2026€34.20
dm11 Oct 2026€12.85
Lidl9 Oct 2026€58.10
tsx
<Card className="overflow-hidden">
  <Table>
    <TableHeader><TableRow><TableHead>Store</TableHead>…</TableRow></TableHeader>
    <TableBody><TableRow><TableCell>Rewe</TableCell>…</TableRow></TableBody>
  </Table>
</Card>

Reading receipts is paused

The monthly AI budget is used up; it resumes on 1 November.

Backup finished

Storage almost full

92% of 50 GB used.
tsx
<Alert variant="warning" title="Storage almost full">92% of 50 GB used.</Alert>
<Alert variant="danger" title="The last deploy failed" action={<Button size="sm">View log</Button>} />

Empty state

Icon, one plain sentence, one action — never a blank panel.

No receipts yet

Photograph a receipt and it shows up here, item by item.

tsx
<EmptyState icon={<Inbox />} title="No receipts yet"
  description="Photograph a receipt and it shows up here."
  action={<Button size="sm"><Plus /> Scan a receipt</Button>} />
Loading
tsx
<Skeleton className="h-3 w-1/3" />
<Spinner />
<Progress value={72} label="Upload" />
<Progress value={92} tone="warning" label="Storage" />
ALGHOperationalSlowDownSearch ⌘K
tsx
<Avatar name={user.name} />
<AppIcon {...ICON} size={40} />
<StatusDot status="up" pulse label="Operational" />
<Kbd>⌘</Kbd><Kbd>K</Kbd>

Metric tile & Data list

One number per tile, tabular figures. A data list is label → value rows: detail panels and /system.

Uptime 24h99.98%1 incident
Latency142 msp50, 24 h

App

Version
2026-10-12
Commit
a1b2c3d
Environment
production
tsx
<MetricTile label="Uptime 24h" value="99.98%" caption="1 incident" href="/monitoring" />
<DataList title="App" rows={[{ label: "Version", value: "2026-10-12" }]} />
Terminal
pnpm add @nashyvan/unify
tsx
<CodeBlock title="Terminal" code="pnpm add @nashyvan/unify" />

App shell

Sidebar on desktop, a sheet behind the menu button on phones, a sticky glass top bar, a 1152px content column. Sign in to see it in use.

app/(app)/layout.tsx
<AppShell
  brand={<AppLockup {...ICON} name="Spentry" />}
  brandHref="/dashboard"
  nav={[
    { items: [{ href: "/dashboard", label: "Dashboard", icon: <LayoutDashboard /> }] },
    { label: "Account", items: [{ href: "/settings", label: "Settings", icon: <Settings /> }] },
  ]}
  actions={<><ThemeToggle /><UserMenu /></>}
>
  {children}
</AppShell>

Sign-in and /system have their own page: Patterns.