SanovaDesign SystemBack to dashboard

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

Page background
Large app shell / panel background
Cards and inputs
Hairlines and dividers
Primary text
Secondary text
Hints, axis labels, placeholders
Text on solid brand and status fills, reversed logo

Using the primary green

Which step to reach for, and how bright it is
StepBrightnessWith whiteUse it for
500.971.1:1Tinted surfaces: selected rows, active sidebar item, soft button background
1000.931.2:1Hover on tinted surfaces, outgoing chat bubbles, unread highlights
2000.871.4:1Borders on tinted surfaces, light chart series
3000.791.9:1Secondary chart series, decorative accents
4000.692.6:1Chart series, focus ring, illustrations
5000.554.6:1Icons, progress bars, chart series, counters. Lowest step that passes for white text
6000.486.3:1Solid buttons (resting), links, text on white and on tints
7000.418.5:1Button hover and pressed, text on tinted backgrounds
8000.3510.9:1Headings on tints, dark surfaces
9000.3112.9:1Deepest text and dark surfaces

Rules 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 states

Blue

Informational states: upcoming, scheduled, in-person

Success

Positive trends, confirmations, completed

Violet

Secondary accent, doctors, records

Coral

Alerts, destructive actions, revenue

Amber

Warnings and highlights

Teal

Logo accent, supporting highlights

Neutral

Text, borders, backgrounds

Typography

Aa

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.02em

Clinic overview for today

Welcome headline
.text-h134px / 1.15 · 500 · -0.015em

Clinic overview for today

Page titles
.text-h228px / 1.2 · 500 · -0.01em

Clinic overview for today

Section titles
.text-h323px / 1.25 · 500 · -0.005em

Clinic overview for today

Sub-sections
.text-h418px / 1.3 · 500 · 0

Clinic overview for today

Card titles
.text-stat30px / 1.1 · 700 · -0.02em

$16,073

KPI numbers
.text-lead26px / 1.2 · 400 · -0.01em

Clinic overview for today

Intro / subtitle text
.text-body-lg16px / 1.5 · 400 · 0

Clinic overview for today

Long-form text
.text-body14px / 1.5 · 400 · 0

Clinic overview for today

Default text
.text-label14px / 1.4 · 500 · 0

Clinic overview for today

Names, form labels, buttons
.text-small13px / 1.4 · 400 · 0

Clinic overview for today

Secondary information
.text-caption12px / 1.35 · 500 · 0.01em

Clinic overview for today

Axis labels, footnotes

Foundations

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

Elevation

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

Spacing

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-5656px

Sizes

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)

Buttons

Import Button from @/components/ui/button. Pass href to render a link with the same styles.

Variants

variant="primary"Main action on a screen
variant="secondary"Neutral / secondary action
variant="soft"Low-emphasis, tinted action
variant="ghost"Tertiary and toolbar actions
variant="danger"Destructive action

Sizes

Heights: sm 32px · md 44px · lg 52px. Corner radius: 8px · 12px · 14px.

Icons

Icon only

Always give icon-only buttons an aria-label.

Every button on every page has the same slightly rounded corners (8px · 12px · 14px by size). There is no pill option; round shapes are reserved for chips, counters and avatars.

States

Hover, active (pressed) and keyboard focus styles are built in — tab to a button to see the focus ring.

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"
Status
tone="info"
Status
tone="neutral"
Status
tone="warning"
Status
tone="danger"
Status
tone="violet"
Status

A leading dot and fully round corners. Use for states that change: Active, Rescheduled, At Risk.

Tag and sizes

variant="tag"
In PersonTelehealthActive Patient
size="sm"
ActiveCare Team

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 filters
328
tone="soft"Quiet, menu counts
28
tone="danger"Needs attention
7
tone="neutral"
12
size="sm"
29
<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 number
Up 12%Down 3%Up 2Down 8%
variant="chip"Tinted background
Up 12%Down 3%Up 2
<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 size
initialsNo portrait registered
onlinePresence dot
srcExplicit image; falls back if it fails
shape="rounded"Softly rounded square, for profile cards
PersonAvatar, name and a line of detail
Emily CarterMRN: CC-1001Alex MorganFollow-up Visit
<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

248Up 12%

from last month

Completed

36Up 20%

from last month

New Patients

18Up 50%

from last month

At Risk

7Up 2

from last month

Icon tiles

tone

ToneTile 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

states
select allInteractive

Supports 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 buttons
TabsUnderline, controls a panel

Showing: 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 fewer
DropdownMore than five options

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

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

  1. ScheduleSet working hours and location
  2. 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

Profile photo

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

TimePatientStatus
09:00 AMOlivia BennettChecked In
09:30 AMLiam CarterUpcoming

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 Carter2024-04-12Active
Robert Kim2024-03-12At Risk
Sophia Nguyen2024-04-03Inactive
Daniel Lee2024-04-20Active
<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 pagesInteractive
5 pages

Try 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

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>

Page banner

The soft green welcome banner under a page title: a short pitch on the left, portraits in arched frames on the right. The title and the main action stay in PageHeader; the banner is decoration and copy only.

Import from @/components/ui/page-banner

<PageHeader title="Doctors" description="…" actions={<Button>Add Doctor</Button>} />
<PageBanner title="…" description="…" visual={<ArchLineup photos={[…]} />} />

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

Main
Side

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

No doctors found

Try a different search or adjust your filters.

<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

ActionTile

Medical Records

With a badge, the count replaces the arrow.

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