Design System
The building blocks of the Sanova interface. Everything here is generated from lib/tokens.ts, app/globals.css and app/ui.css.
Colors
Click any swatch to copy its hex value. Use the CSS variables, e.g. var(--color-primary-500).
Semantic
Using the primary green
Which step to reach for, and how bright it is500.971.1:1Tinted surfaces: selected rows, active sidebar item, soft button background1000.931.2:1Hover on tinted surfaces, outgoing chat bubbles, unread highlights2000.871.4:1Borders on tinted surfaces, light chart series3000.791.9:1Secondary chart series, decorative accents4000.692.6:1Chart series, focus ring, illustrations5000.554.6:1Icons, progress bars, chart series, counters. Lowest step that passes for white text6000.486.3:1Solid buttons (resting), links, text on white and on tints7000.418.5:1Button hover and pressed, text on tinted backgrounds8000.3510.9:1Headings on tints, dark surfaces9000.3112.9:1Deepest text and dark surfacesRules of thumb: a solid fill with white text should be at least 4.5:1 (ideally 5–7:1); hover is one step darker; use 500 and lighter for tints, charts and icons but never for small text on a tint; text on a tint should use 600 or darker. Brightness is OKLab lightness (0 black, 1 white); steps about 0.07 apart look clearly different.
Primary
Brand, primary actions, links, active statesBlue
Informational states: upcoming, scheduled, in-personSuccess
Positive trends, confirmations, completedViolet
Secondary accent, doctors, recordsCoral
Alerts, destructive actions, revenueAmber
Warnings and highlightsTeal
Logo accent, supporting highlightsNeutral
Text, borders, backgroundsTypography
Geist
By Vercel. Geist Sans for all interface text, Geist Mono for code and values.
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789 $ % & ( ) ! ?
Geist Mono — 0123456789 { } =>
Type scale
.text-display48px / 1.05 · 400 · -0.02emClinic overview for today
Welcome headline.text-h134px / 1.15 · 500 · -0.015emClinic overview for today
Page titles.text-h228px / 1.2 · 500 · -0.01emClinic overview for today
Section titles.text-h323px / 1.25 · 500 · -0.005emClinic overview for today
Sub-sections.text-h418px / 1.3 · 500 · 0Clinic overview for today
Card titles.text-stat30px / 1.1 · 700 · -0.02em$16,073
KPI numbers.text-lead26px / 1.2 · 400 · -0.01emClinic overview for today
Intro / subtitle text.text-body-lg16px / 1.5 · 400 · 0Clinic overview for today
Long-form text.text-body14px / 1.5 · 400 · 0Clinic overview for today
Default text.text-label14px / 1.4 · 500 · 0Clinic overview for today
Names, form labels, buttons.text-small13px / 1.4 · 400 · 0Clinic overview for today
Secondary information.text-caption12px / 1.35 · 500 · 0.01emClinic overview for today
Axis labels, footnotesFoundations
Shape, depth, spacing, sizing and motion. Each value is a CSS variable generated from lib/tokens.ts. Components use the variable, never the literal value.
Corner radius
--radius-xs6px--radius-sm8px--radius-md10px--radius-control12px--radius-xl14px--radius-lg16px--radius-2xl24px--radius-pill999pxElevation
--shadow-xs--shadow-control--shadow-card--shadow-float--shadow-raised--shadow-menu--shadow-drawer--shadow-popover--shadow-primary--shadow-danger--shadow-cta--shadow-cta-lg--shadow-focus--shadow-focus-danger--shadow-hairline--shadow-avatar-ringSpacing
Gap, padding and margin. Named by pixel value.
--space-22px--space-44px--space-66px--space-88px--space-1010px--space-1212px--space-1414px--space-1616px--space-1818px--space-2020px--space-2424px--space-2828px--space-3232px--space-3636px--space-4040px--space-4848px--space-5656pxSizes
Control height
--control-xs- 26px
--control-sm- 32px
--control-chip- 36px
--control-md- 44px
--control-lg- 50px
--control-xl- 52px
Icon
--icon-xs- 13px
--icon-sm- 14px
--icon-md- 16px
--icon-lg- 18px
--icon-xl- 20px
--icon-2xl- 32px
Font size
--font-size-2xs- 0.625rem
--font-size-xs- 0.6875rem
--font-size-sm- 0.75rem
--font-size-md- 0.8125rem
--font-size-base- 0.875rem
--font-size-lg- 0.9375rem
Motion
Duration
--duration-instant- 0.1s
--duration-fast- 0.12s
--duration-base- 0.15s
--duration-medium- 0.2s
--duration-slow- 0.25s
--duration-slower- 0.4s
--duration-enter- 0.45s
--duration-spin- 0.7s
Easing
--ease-standard- ease
--ease-out- cubic-bezier(0.2, 0.7, 0.2, 1)
--ease-move- cubic-bezier(0.4, 0, 0.2, 1)
Overlays
Translucent fills
--color-scrim- rgb(15 23 42 / 0.5)
--color-scrim-soft- rgb(15 23 42 / 0.4)
--color-white-muted- rgb(255 255 255 / 0.2)
Components
Reusable building blocks. Every page is composed from these, so a change here updates the whole app.
Chips
Small coloured labels for statuses, tags and roles.
Import from @/components/ui/chip
Status (default)
tone="success"tone="info"tone="neutral"tone="warning"tone="danger"tone="violet"A leading dot and fully round corners. Use for states that change: Active, Rescheduled, At Risk.
Tag and sizes
variant="tag"size="sm"tag has no dot and squarer corners, for static labels like visit type. size="sm" is for tight spaces.
<Chip tone="success">Active</Chip>
<Chip variant="tag" tone="info" size="sm">In Person</Chip>Counters
Tiny round number badges for unread messages, applied filters and menu counts.
Import from @/components/ui/counter
tone="primary"Default. Unread, applied filterstone="soft"Quiet, menu countstone="danger"Needs attentiontone="neutral"size="sm"<Counter>3</Counter>
<Counter tone="soft" size="sm">28</Counter>Deltas
Change indicators with an arrow. Colour is independent of direction: a rising 'at risk' count is bad.
Import from @/components/ui/delta
variant="text"Default, inline with a numbervariant="chip"Tinted background<Delta value="12%" />
<Delta value="2" tone="danger" variant="chip" />Avatars
Round portraits with an initials fallback while loading, when the photo fails, or when there is none.
Import from @/components/ui/avatar, person
sizeAny pixel sizeinitialsNo portrait registeredonlinePresence dotsrcExplicit image; falls back if it failsshape="rounded"Softly rounded square, for profile cardsPersonAvatar, name and a line of detail<Avatar name="Emily Carter" size={48} online />
<Avatar name="Alex Morgan" src={url} shape="rounded" />Cards
The surface everything sits on. An optional header holds a title, description and action.
Import from @/components/ui/card
Default card
With a description
Standard 20px padding, hairline border, no heavy shadow.
padding="lg"
24px padding for roomier content.
padding="none": content runs edge to edge. Use for tables, lists and chat panels.
rounded
24px corners (--radius-2xl) for feature surfaces such as heroes and profile cards.
<Card title="Upcoming" description="Today's schedule" action={<Select … />}>…</Card>
<Card padding="none">…table…</Card>Stat tiles
KPI tiles: a tinted icon, a label, a big number and a change indicator. StatGrid lays them out responsively.
Import from @/components/ui/stat-tile
Total Patients
from last month
Completed
from last month
New Patients
from last month
At Risk
from last month
Icon tiles
toneToneTile is the tinted rounded square used inside tiles, lists and quick actions.
<StatGrid>
<StatTile label="At Risk" value={7} change="2" period="from last month"
icon={LuHeart} tone="coral" deltaTone="danger" />
</StatGrid>Inputs
Search fields, selects and checkboxes. Controls that sit next to each other share a height so they line up.
Import from @/components/ui/search-input, select, checkbox
Search input
44px, the same height as default buttons. Use tone="white" on a tinted background.
Select
size="sm" / "md"sm (32px) fits card headers; md (44px) sits beside default buttons and search fields; lg (50px) is for forms in dialogs.
Checkbox
statesselect allInteractiveSupports checked, indeterminate (some selected) and disabled.
<SearchInput label="Search patients" value={q} onChange={…} />
<Select size="md" aria-label="Status" value={v} options={opts} onChange={setV} />
<Checkbox checked={all} indeterminate={some && !all} onChange={toggle} />Segmented control & tabs
Segmented control switches how the same data is shown (Day / Week / Month). Tabs switch between sections of content.
Import from @/components/ui/segmented, tabs
Segmented32px, matches small buttonsTabsUnderline, controls a panelShowing: All Messages
Button pressedA toggle button<Segmented aria-label="View" value={view} options={["Day","Week","Month"]} onChange={setView} />
<Tabs aria-label="Type" panelId="list" value={tab} options={tabs} onChange={setTab} />Filter chips
Quiet single-select filter with item counts, for narrowing a list by category. A text row up to five options, a dropdown beyond that.
Import from @/components/ui/filter-chips
Text rowFive options or fewerDropdownMore than five optionsSwitches to a dropdown on its own when there are more than five options (maxInline).
<FilterChips aria-label="Specialty" value={v} onChange={setV}
options={[{ value: "All Doctors", count: 14 }, { value: "Cardiology", count: 2 }]} />Form fields
A label, a control and a hint or error, wired together for screen readers. Inputs are 44px tall with 12px corners; the large size used in dialogs is 50px with 14px corners.
Import from @/components/ui/field, text-input, select, filter-chips
Field with controls
As it should appear on the doctor's profile.
Enter an email address like name@clinic.com.
Field passes the control its id, aria-describedby and aria-invalid. Error text says what is wrong and how to fix it.
Choice chips
Like filter chips, but any number can be on at once: working days, focus areas, languages.
<Field label="Full name" required error={errors.name}>
{(control) => <TextInput {...control} size="lg" icon={<LuUser />} value={name} onChange={…} />}
</Field>
<Field label="Department">
{(control) => <Select {...control} size="lg" placeholder="Select department" value={v} options={opts} onChange={setV} />}
</Field>Modal
A dialog built on the native dialog element, so focus is trapped, Escape closes it and the page behind is inert. Content is only mounted while it is open, so forms reset each time.
Import from @/components/ui/modal
ModalConfirmation dialogSet width in pixels. Turn off dismissOnBackdrop for long forms. On phones the dialog fills the screen. Build the contents from ModalPanel (or ModalForm), ModalHeader, ModalBody and ModalFooter; put two fields side by side with FieldRow.
<Modal open={open} onClose={close} labelledBy="title-id" width={480}>
<ModalForm onSubmit={save}>
<ModalHeader title="New Prescription" titleId="title-id" description="…" onClose={close} />
<ModalBody>…<FieldRow>…</FieldRow></ModalBody>
<ModalFooter>…</ModalFooter>
</ModalForm>
</Modal>Stepper
A vertical list of steps for multi-step flows. Finished steps show a check and can be clicked to go back; later steps cannot be skipped to.
Import from @/components/ui/stepper
- ScheduleSet working hours and location
- ReviewCheck details before adding
<Stepper aria-label="Add doctor steps" steps={steps} current={step} onSelect={setStep} />Image upload
Portrait upload. The frame has the same proportions as portraits on doctor cards, so what you pick is what you get. Drag a file onto it or click to browse; a chosen photo can be replaced or removed, and a preview shows how it looks in lists.
Import from @/components/ui/image-upload
Accepts JPG and PNG up to 5 MB. Wrong files show an error that says what to do. Try it with a photo from your computer.
<ImageUpload label="Profile photo" previewName={name} value={photo} onChange={setPhoto} />Progress bars
A share or completion value, in any tone.
Import from @/components/ui/progress-bar
tone="primary"tone="success"tone="violet"tone="coral"tone="amber"size="sm"<ProgressBar value={40} tone="success" aria-label="General Practice share" />Legends
Colour keys for charts. Column legends can show a count and a share; row legends are a compact inline key.
Import from @/components/ui/legend
direction="column"With detail and value- Female64952%
- Male56245%
- Other373%
direction="row"Compact key- In-person
- Telemedicine
<Legend direction="row">
<LegendItem color="var(--color-primary-500)" label="In-person" />
</Legend>Tables
Two variants. Simple sits inside a padded card; banded runs edge to edge with a tinted header and row hover. Headers can be sortable.
Import from @/components/ui/table
Simple
| Time | Patient | Status |
|---|---|---|
| 09:00 AM | Olivia Bennett | Checked In |
| 09:30 AM | Liam Carter | Upcoming |
Use inside a padded card, e.g. today's appointments.
Banded + sortable
Click a header to sort (ascending, descending, off). Put it in a card with no padding.
| Status | ||
|---|---|---|
| Emily Carter | 2024-04-12 | Active |
| Robert Kim | 2024-03-12 | At Risk |
| Sophia Nguyen | 2024-04-03 | Inactive |
| Daniel Lee | 2024-04-20 | Active |
<Card padding="none">
<Table variant="banded" minWidth={900}>
<thead><tr><Th sort={{ direction, onSort }}>Patient</Th></tr></thead>
<tbody>…</tbody>
</Table>
</Card>Pagination
Page buttons with first/last always visible and ellipses for the gaps.
Import from @/components/ui/pagination
12 pagesInteractive5 pagesTry it: the window of pages follows the current page.
<Pagination page={page} totalPages={42} onChange={setPage} />Empty states
Shown when a list, table or chat has nothing to display. Say what is empty and offer a next step.
Import from @/components/ui/empty-state
No conversations
You're all caught up.
No patients found
Try a different search or adjust your filters.
<EmptyState icon={LuSearchX} title="No patients found" description="…" action={<Button …/>} />Page layout
Every page is AppShell (sidebar) > AppHeader (top bar) > PageContent (padded main) > PageHeader (title + actions) > content.
Import from @/components/layout/*
PageHeader
Patients
Manage your patients, view records, and keep track of their care.
Title, optional description and eyebrow, and actions that wrap below the title on narrow screens.
<AppShell>
<AppHeader />
<PageContent>
<PageHeader title="Patients" description="…" actions={<Button>Add Patient</Button>} />
…
</PageContent>
</AppShell>Split layout
A main column and a side column. Under 1100px of content width the side column drops below, and its cards flow into a grid. Pick the side width with side, or pass columns for a fixed ratio.
Import from @/components/layout/split-layout
sm · md · lg
sm is 380px (calendars, summaries), md is 440px (record panels), lg is 460–540px (a detail view).
<SplitLayout side="md" sideLabel="Patient summary" aside={<><HealthInfo /><Documents /></>}>
<ActivityTimeline … />
</SplitLayout>Notice
Green confirmation for the result of an action. It dismisses itself after six seconds, or when closed. Keep the message in state and clear it in onDismiss.
Import from @/components/ui/notice
Dr. Mia Chen was added to your team.
<Notice message={notice} onDismiss={() => setNotice(null)} />List toolbar
What sits around every searchable list: FilterBar (chips, search, filters), FilterPanel (the extra filters), ResultsFooter (count and pages) and NoResults (when nothing matches). Pass inset to FilterBar, FilterPanel and ResultsFooter when they sit inside a card.
Import from @/components/ui/filter-bar, results-footer, no-results
<FilterBar chips={<FilterChips … />}>
<SearchInput … />
<Button>Filters</Button>
</FilterBar>
<ResultsFooter firstIndex={0} shown={6} total={24} noun={["doctor", "doctors"]} page={1} totalPages={4} onPageChange={…} />Info & action tiles
InfoTile is a compact figure or fact inside a card: use variant=metric for counts and variant=fact for a value under its label. ActionTile is a shortcut to a task, a link when it has an href. For a page-level KPI use StatTile.
Import from @/components/ui/info-tile, action-tile
InfoTile
- 14
- Total Doctors
- 7
- Specialties
- A+
- Blood Type
<InfoGrid columns={2}>
<InfoTile variant="fact" label="Blood Type" value="A+" icon={LuHeart} tone="coral" />
</InfoGrid>
<ActionGrid>
<ActionTile label="Messages" icon={<LuMessageSquare />} tone="blue" href="/messages" />
</ActionGrid>Detail list
Icon-led facts about one thing: when, how long, where, notes. An item with a title aligns to the top.
Import from @/components/ui/detail-list
- Thursday, May 9, 2024
- 1:30 PM – 2:30 PM (60 minutes)
- New Patient ConsultationInitial assessment and medical history review.
<DetailList>
<DetailItem icon={LuCalendar}>Thursday, May 9, 2024</DetailItem>
<DetailItem icon={LuFileText} title="Notes">…</DetailItem>
</DetailList>


