Design tokens
Fourteen tokens, mapped to Tailwind steps. Every text pairing meets WCAG AA.
signal
brandPrimary actions, links, active state
#6A4BCBsignal-hover
brandHover and pressed on filled buttons
#5A3DB5signal-wash
violet-50Tinted backgrounds, selected rows
#F5F3FFink
neutral-900Titles and primary text
#171717muted
neutral-500Secondary text, meta, placeholders
#737373paper
neutral-50Page background
#FAFAFAmist
neutral-200Dividers, card and input borders
#E5E5E5rating
yellow-500Star icons only, never text
#F0B100danger
rose-700Errors, destructive actions
#C70036danger-wash
rose-50Cancelled badges, error tints
#FFF1F2success
teal-800Completed, confirmations
#005F5Asuccess-wash
teal-50Completed badges
#F0FDFAwarn
orange-800Pending, needs attention
#7E2A0Cwarn-wash
orange-50Pending badges
#FFF7EDTokens map to Tailwind steps where a close match exists. signal is brand-specific — the nearest step, violet-800, is far more saturated.
Headings
Six levels, all from UiHeading. Sans throughout — never serif. Pass as when the semantic tag differs from the visual level.
Inter everywhere, self-hosted — Latin, Latin Extended and Cyrillic, so en, ru and tr are covered. The native iOS app keeps SF Pro. One face per platform: no display cut, no heading-only family.
Notifications
page · text-[2rem] font-semibold · h1
Studio Nova
title · text-[1.6rem] font-semibold · h1
Balayage & toning
section · text-lg font-semibold · h2
Colour & highlights
group · text-base font-semibold · h3
Available times
ui · text-sm font-semibold · h3
Yesterday
label · text-sm · muted · h2
Full colour service with a licensed stylist, 2 hours
Body · text-sm · muted · not a heading
Heading hints
A muted line under a heading is part of that heading. Pass hint rather than writing a <p> after it — the mt-1 lives in the component.
Notifications
Choose how we reach you about bookings and reminders.
level="group" — a bold label above muted copy is a heading, so it lands in the outline as an <h3>:
Sign in to manage settings
Notification preferences are tied to your account.
The hint slot instead of the prop, when the copy carries markup:
Privacy
Read the privacy policy before deleting your account.
Not a hint — a value under a label is a pair of data lines and keeps its own tighter spacing:
In context
Studio Nova
Full colour service with a licensed stylist, 2 hours
Radius
Two radii. Pills for things you toggle or dismiss, xl for everything else.
rounded-full
Chips, filters, tags, icon buttons, search
rounded-xl
Buttons, inputs, cards, sheets, modals
Buttons
White on signal 6.0:1 · signal on wash 5.5:1
Pill buttons — same styles, rounded-full:
Pills need a little more horizontal padding to look balanced.
Navigation chrome
Circular icon buttons, size-10, on a neutral fill. Back sits left, close sits right.
HeaderToolbar — back left, title centre, actions right:
Studio Nova
The title fades in on scroll past 24px. Transparent over content, white with a bottom border once scrolled.
FullDialog — centred title, close right:
Reschedule
Full-screen. Title is always visible, no scroll fade.
Sheet and Modal — drag handle, title left, no close on mobile:
Choose a time
Tuesday 12 August
Dismissed by dragging or tapping the backdrop, so no close button on mobile. Modal shows one on desktop only.
Icon button states:
size-10 keeps the tap target at 40px. Every icon button needs an aria-label — there is no text to read.
Alerts
Inline notices. Same tints as status badges, so a colour means the same thing everywhere.
With a title, for anything longer than one line:
Two services overlap
Dismissible:
Errors get role="alert" so screen readers interrupt; the rest use role="status".
Loader
Three dots bouncing in sequence, brand colour, no fade. Inside a filled button they inherit the text colour instead.
In buttons:
On a signal fill the dots must inherit text colour — brand on brand is invisible.
Dates
Relative up to a month, then the day and month. The year appears only when it differs.
Renders a real <time> element and updates itself while on screen.
Prices
Thousands separated by a comma, no decimals. Never tabular-nums — numbers stay in the typeface's proportional figures.
Use useMoney() — never format a price inline.
List rows
UiList owns the spacing; rows carry no margins of their own. UiListRow is the row — label, optional leading icon, optional trailing value, chevron by default. Renders a link when given to, otherwise a button.
plain — bare rows on a section:
divided — a rule between rows. Never hand-write the hr, and never suppress the last one with an index check:
as="div" — the row is not itself the control, so it must not be a button wrapping the switch:
variant="card" — standalone bordered rows:
variant="joined" — rows sharing one bordered container:
Empty states
No background of its own — edge-faded grid rules on the back, floating cards above the copy. pattern picks the cards: list (default) or grid. Always name the next action.
Without a hint:
No bookings here yet.
Compact — inside a card or section:
No free times on this day.
A search with no results is not an empty state — keep the query visible and offer a way back:
No places found
Nothing matches “balayage” in Ashgabat. Try a different service or area.
pattern="grid" — tiles, for galleries and card layouts:
No photos yet.
Work from this salon will show up here.
pattern="list" — rows, for feeds and lists (the default):
No reviews match this filter.
Skeletons
Bars use mist on white, pulsing on the whole row rather than each bar. Mirror the real layout so nothing jumps when content lands.
List row — thumbnail, title, two meta lines.
Detail header:
Chip row:
Text block — last line short, like real copy:
Skeletons are decorative — mark the container aria-hidden="true" and announce loading in one live region.
Loadable
UiLoadable owns the loading → empty → content chain, so a finished fetch with no rows never shows a skeleton. It marks the skeleton aria-hidden, puts animate-pulse on the container rather than each bar, and announces loading once in a live region.
Загрузка…
Gate on the request status, never on the row count alone — an empty array reads the same before the fetch starts and after it returns nothing.
Status badges
The six booking statuses. Tinted background, no border — badges label, they don't invite a tap.
With a dot, when the badge sits in a dense row:
In a booking row:
Balayage & toning
Studio Nova · Tue 12 Aug, 14:00
Confirmed carries the brand colour, completed goes green. All sit on the 100 wash with 800 text, so no badge looks lighter than its neighbours. Lowest is confirmed at 5.1:1.
Counter badges
Counts use danger so they read as "needs attention" regardless of the accent.
Cap at 99+. The bare dot is for "something is new" when the exact count doesn't help.
Inline counts, beside a label rather than over an icon:
Neutral for a plain total, danger only when it wants action.
Chips and filters
Selected state uses signal fill, not just a border.
Inline links
Bookings can be cancelled up to 24 hours before your appointment. See our cancellation policy for details, or read the terms of service.
ink link in muted body · 3.8:1 against surrounding text, underline matches the text
Standalone link, outside body copy:
View all reviewsInput
Error state uses danger on the border.
Pill suits search and single-field bars.
Borders use mist. Focus is the browser default.
Danger and rating
This booking was cancelled.
The star is decorative, so it can be bright yellow — the number carries the rating in ink. danger 6.4:1 · white on danger 6.4:1