Cairn / Design System v1.0
Getting started

shadcn-svelte

shadcn-svelte is the default component set for all Kinda products. Cairn provides the CSS tokens, theming model, and usage guidelines — shadcn provides the component implementations.

Why shadcn? shadcn copies component files directly into your repo — you own them and can edit them without fighting a library API. Cairn's app.css overrides shadcn's default palette by redefining --background, --primary, --border, and related variables with Cairn tokens. Every shadcn component picks up Cairn's stone neutrals, brand colour, and radius scale automatically — no per-component changes needed for base styling.
1 · Initialise
npx shadcn-svelte@latest init
When prompted: Style → new-york  ·  Base colour → stone (overridden anyway — stone is closest to Cairn's warm neutrals)  ·  CSS variables → yes.
2 · Replace app.css with the Cairn theme file
Delete the generated src/app.css and replace it with app.css from this repo. Add the fonts to src/app.html:
<link rel="preconnect" href="https://fonts.googleapis.com"> <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" >
3 · Set the product theme
In your root +layout.svelte, set data-theme on the html element. The theme name maps to a [data-theme] block in app.css which defines the 7 brand primitive tokens.
<script> // 'orbit' | 'beacon' | 'vault' | 'pulse' const product = 'orbit'; </script> <html data-theme={product} lang="en"> <body><slot /></body> </html>
4 · Add dark mode support
npx shadcn-svelte@latest add mode-watcher
<!-- src/routes/+layout.svelte --> <script> import { ModeWatcher } from 'mode-watcher'; </script> <ModeWatcher /> <slot />
Token mapping — shadcn → Cairn
These are the shadcn CSS variables that app.css overrides with Cairn tokens. All shadcn components reference these variables — setting them correctly themes the entire component set.
shadcn variable Cairn token Light Purpose
--background neutral #F5F5F4 App canvas — stone warm white, never pure grey
--foreground ink #18181A Primary text
--card neutral-raised #FFFFFF Cards and panels — white on stone canvas creates surface hierarchy
--primary primary #5B6EE1 Brand colour — buttons, focus rings, active states. Dark: shifts to primary-light
--muted neutral #F5F5F4 Ghost elements, placeholder backgrounds, disabled areas
--muted-foreground neutral-mid #78716C Secondary and tertiary text
--accent primary-muted #E8EAFF Ghost hover, sidebar active bg, selected row backgrounds
--accent-foreground primary-text #3A4DC8 Text on accent backgrounds
--destructive error #E05B40 Ember — destructive actions only, never a red brand variant
--border neutral-border #E5E5E3 All structural borders — used at 0.5px throughout
--ring primary #5B6EE1 Focus ring colour — focus-visible rule applies 12% opacity
--radius rounded.lg 12px Base radius — shadcn derives sm (~4px), md (~8px), xl (~16px) from this
--sidebar primary-dark-nav #0F1330 Always dark — sidebar is product context, not content area
Component overrides
Most shadcn components work without changes once the token layer is applied. These are the exceptions — all standard shadcn customisation pattern, not hacks:
Component Override Why
Button Replace cursor-pointer with cursor-default in buttonVariants base class Desktop app convention — pointer is for links, not actions
Badge Add success, warning, outline variants to badgeVariants shadcn default maps to badge-neutral only; Cairn needs 5 semantic states
Table row Add hover:bg-muted transition-colors No zebra stripes — hover highlight only per Cairn table spec
Table head Add text-[11px] uppercase tracking-[0.06em] text-muted-foreground Receded header style — eye lands on data, not labels
Separator Add opacity-50 All app borders are 0.5px (50% opacity on a 1px element)
Marketing surface
Wrap marketing sections in .surface-marketing. shadcn components inside automatically inherit --mkt- token overrides for the dark canvas context. Use class="eyebrow" for section labels (12px / 600 / +0.09em / uppercase / brand-400).
<!-- Wrap any marketing section --> <section class="surface-marketing px-10 py-20"> <p class="eyebrow">Feature</p> <!-- shadcn Card inherits dark canvas overrides inside this wrapper --> <Card class="border-white/8"> <CardTitle class="text-white">Your data, always current</CardTitle> </Card> <!-- Marketing primary button — taller than app buttons --> <Button class="h-[50px] px-7 text-[17px]">Start free trial</Button> </section>
Getting started

Design.md

The Design.md file is the machine-readable source of truth for the Cairn design system. It is the first thing any coding agent or AI tool should read before generating UI for a Kinda product.

What Design.md is for. Design.md follows the google-labs-code/design.md specification — a structured format that lets coding agents reliably understand a design system. It combines YAML front-matter (machine-readable tokens) with Markdown prose (human-readable guidelines). Agents read the YAML for exact values and the prose for intent and constraints.
YAML front-matter
Machine-readable tokens
All colour, typography, spacing, radius, and component tokens in a structured schema. This is what agents and CLI tools parse for exact values.
colors
typography
rounded
spacing
components
Markdown body
Design intent and constraints
Explains why each decision was made and what rules must not be broken. Agents use this for context when token values alone are ambiguous.
Overview
Colors
Typography
Components
Do's and Don'ts
Technical integration guide
Companion file covering setup, token mapping details, and component override code. References Design.md for usage guidelines.
Setup steps
Token mapping
Component overrides
New theme steps
Using Design.md with a coding agent
Point any agent at the file before generating UI. With Claude Code or similar tools:
# Reference in your CLAUDE.md or project instructions Read Design.md before generating any UI component for this project. All colours, typography, spacing, and component behaviour must match the tokens and constraints defined in that file.
The @google/design.md CLI can lint the file for broken references and WCAG contrast issues, and export tokens to Tailwind v4 CSS or DTCG format:
npx @google/design.md lint Design.md npx @google/design.md export --format tailwind-v4 Design.md
Foundation

Architecture

Cairn uses three layers of design tokens. Components only ever reference semantic tokens — they are unaware of which product is active or which mode is set.

Why three layers? Most design systems have two — a palette and component values. The third layer (semantic tokens) is what enables multi-product theming and light/dark mode without touching component code. A button uses color-interactive, not a hex value. Swap the hex behind that token and every component updates automatically.
Layer 1
Primitive tokens
Raw values. Never used directly in components. Define the full palette.
indigo-500
stone-200
sage-600
radius-lg
Layer 2 — swapped per product & mode
Semantic tokens
Named by purpose. Components reference only these. This layer absorbs all theme and mode switches.
color-interactive
color-surface
color-text-primary
nav-background
Layer 3 — optional
Component tokens
For complex components that need their own overrides. Used sparingly.
nav-bg
badge-text
focus-ring
Foundation

Two surfaces

The system supports two fundamentally different surface types. They share the same token foundation but follow different rules for how those tokens are deployed.

Why split surfaces? App UI and marketing have opposite jobs. The app user has already committed — the job is efficiency. The marketing visitor hasn't committed — the job is confidence and momentum. Trying to use one visual language for both creates a UI that is either too cold for marketing or too noisy for a task surface. The split is maintained in code via a .surface-marketing wrapper class and a --mkt- token namespace that never bleeds into app components.
App UI Task surface
PurposeHelp users do work. Get out of the way.
CanvasStone-50 warm white. Never pure grey.
Brand colourInteractive elements only. Never decorative.
Display sizeMax 30px
MotionMicro only. 100–150ms.
ShadowsNever
Marketing Persuasion surface
PurposeBuild confidence. Create momentum. Convert.
Canvas#0D0D12 dark. Always explicit, never inherited.
Brand colourLight source on dark canvas. CTAs + eyebrows.
Display size48–72px permitted
MotionEntrance + scroll reveals. 250–350ms.
ShadowsBrand glow on CTA only
Foundation

Typography

Space Grotesk across all surfaces. Space Mono for code, keys, and financial tables. The pairing shares design DNA — intentional, not assembled.

Why Space Grotesk over Inter? Inter is neutral by design — it deliberately disappears. Space Grotesk has detectable character in specific letters: the double-storey a, the spurred G, the curved-tail t, and particularly strong numerals. At display sizes this distinction is what makes metric cards and hero headlines feel designed rather than default. It still works at 12px in dense UIs without losing legibility.

Why 600 weight only at 22px+? Space Grotesk 500 is optically heavier than Inter 500. Using 600 at body or label sizes produces a heavy, overworked feeling. 600 is reserved for display-scale headings where the extra weight reads as editorial rather than aggressive.
App type scale
Specimens
Display heading
30px / 600 / −0.03em / 1.15 leading — page titles only
Heading 1
22px / 500 / −0.022em — section titles, modal headers
Heading 2
18px / 500 / −0.015em — card titles, subsections
Label / UI text
15px / 500 / 0 — nav items, table headers, buttons
Body — used for descriptions, secondary content, and help text throughout the application interface.
14px / 400 / 0 / 1.65 leading
Caption — timestamps, metadata, helper text
12px / 400 / +0.01em
Space Mono — code, IDs, keys, financial figures
13px / 400 / Space Mono
Marketing type scale
Marketing specimens — dark canvas
Revenue intelligence
Know exactly where
your growth comes from
Connect billing, CRM, and product data. Stop arguing about numbers and start moving them.
Eyebrow
13px / 600 / +0.09em
Hero
72px / 600 / −0.04em
Body lead
18px / 400 / 1.70
Why use brand-muted (#818CF8) for eyebrows, not white? On a dark canvas, a white uppercase label above a headline creates two elements fighting for first read. The brand-muted colour signals "this is the category" without competing with the headline's job. It also reinforces the brand colour as an active presence on the dark canvas, where it reads as a light source rather than a tint.
Foundation

Colour

Indigo is the default brand colour (Orbit product). Each product defines its own brand ramp. Stone neutrals carry all app surface structure. Sage and Ember are status-only.

Why stone neutrals rather than grey? Pure grey (#f5f5f5) reads as "default browser" on screen. Stone has a barely-perceptible warm undertone that makes surfaces feel considered. The difference is subtle in isolation but clearly visible when grey and stone are placed side by side. It also prevents the app from feeling clinical — important for a product users spend hours in daily.

Why is brand colour restricted to interactive elements? Every use of brand colour in the app UI should answer "is this something I can interact with?" If the answer is no, the colour shouldn't be there. Decorative brand colour dilutes the interactive signal and makes the UI harder to scan.
Brand (default — Orbit)
Indigo 50
#E8EAFF
Indigo 500
#5B6EE1
Indigo 700
#3A4DC8
Indigo 900
#1E2660
Indigo 400
#818CF8
Neutrals (stone)
Stone 50
#F5F5F4
Stone 200
#E5E5E3
Stone 500
#78716C
Stone 900
#1C1917
Ink
#18181A
Status — Sage (success) & Ember (error)
Why are there only two status colours? Most design systems have four (success, warning, error, info). Info usually maps to the brand colour — which in Cairn is already doing interactive-element duty. Warning (amber) is added only when a product genuinely needs it. Sage and Ember are used for status only, never for branding or decoration. Keeping the set small makes each colour more meaningful when it appears.
Sage
#16A269
Sage 50
#D1FAE5
Ember
#E05B40
Ember 50
#FEF0ED
Foundation

Spacing

4px base unit. All spacing is a multiple of 4. Page gutters: 24px mobile / 40px desktop (app). Marketing sections: 80px vertical padding desktop / 48px mobile.

4
8
12
16
24
32
48
64
Foundation

Border radius

Five stops. Each maps to a specific element type — don't use radius-xl on a button or radius-sm on a modal.

4px
sm — chips
8px
md — inputs
12px
lg — cards
16px
xl — modals
pill
full — badges
Foundation

Elevation

Elevation is expressed through border weight and surface contrast — never box shadows in the app UI.

Why borders instead of shadows? Box shadows create visual noise that competes with content. They also render inconsistently across displays and are invisible in dark mode unless carefully managed. A 0.5px border is precise, scales perfectly at any resolution, and works identically in light and dark mode. The one exception is marketing: a subtle brand-colour glow on CTA buttons is permitted because in that context the glow serves as a signal of interactivity on a dark surface, not a decorative effect.
Use Style Exception
Default border 0.5px solid color-border
Hover / emphasis 0.5px solid color-border-secondary
Featured item 1px solid color-interactive Only exception to 0.5px rule
App UI shadows Never
Marketing CTA glow box-shadow: 0 0 24px rgba(91,110,225,0.25) CTA buttons on dark canvas only
Foundation

Motion

Motion rules differ significantly between surfaces. App motion is nearly invisible. Marketing motion is choreographed.

Why such different motion rules per surface? In the app, motion that draws attention is a distraction — the user is trying to complete a task. In marketing, motion is part of the persuasion. A feature section that reveals on scroll feels alive; the same animation on a data table row would be maddening. The shared principle across both: motion should be purposeful, not ornamental. No continuous loops, no parallax, no motion for its own sake.
App UI
100–150ms / ease-out
Hover states, focus rings, dropdown open/close. Never decorative. Users should not consciously notice transitions.
Marketing
250–350ms / cubic-bezier(0.16, 1, 0.3, 1)
Scroll reveals: translateY(24px→0) + fade. Sibling stagger: 60ms, max 4 items. Hero headline: fade only — never translate large text.
App components

Buttons

Four variants. One primary per view. No gradients on any button variant.

Variants
Why is "Sign in" a text link, not a secondary button? Giving sign-in a button border creates two competing CTAs next to the primary. Sign-in is for returning users who know what they want — it doesn't need to compete. The hierarchy should be: new user (primary CTA) is more prominent than returning user (text link). This also reduces visual noise in the nav.
Variant Height Font Use
Primary 36px 14px / 500 One per view. The most important action.
Secondary 36px 14px / 500 Alternative actions. Can appear alongside primary.
Ghost 36px 14px / 500 Tertiary actions, toolbar actions, low-emphasis.
Destructive 36px 14px / 500 Delete, remove. Ember colour. Never use primary red.
App components

Badges

Pill shape. 12px / 500. Used for status, plan tier, and filterable tags.

Active Complete Failed Pending Archived
App components

Form inputs

36px height. Focus ring uses brand colour at 12% opacity — visible without being distracting.

App components

Data tables

No zebra stripes. Hover highlight only. Numeric columns right-aligned with tabular numerals. Density: comfortable (44px) default, compact (32px) available.

Why no zebra stripes? Alternating row colours fragment attention horizontally — your eye reads across a stripe rather than scanning down a column. Hover-only highlighting keeps the table visually quiet and lets status badges and avatars carry their colour without competing with row backgrounds. When you need to draw attention to a row, the hover state is more precise and intentional than a permanent background tint on every other row.
Customer Plan MRR Joined Status
AL
Acme Labs
Enterprise £4,200 Jan 2023 Active
CC
Cedar Co.
Starter £149 Jun 2023 Churned
DS
Datastream
Enterprise £6,500 Nov 2022 Active
Marketing components

Marketing buttons

More presence than app buttons. The ghost uses a visible white border — the app ghost would disappear on the dark canvas.

Button variants — dark canvas
Marketing components

Section patterns

Three reusable section layouts. One idea per section. Consistent vertical padding (80px desktop / 48px mobile) creates rhythm.

Hero — centred, full-width
Revenue intelligence
Know exactly where your growth comes from
Connect billing, CRM, and product data in one place.
Feature split — text left / visual right
Live sync
Your data, always current
Changes in Stripe or HubSpot appear within seconds. No manual exports.
MRR
£24,830
↑ 12% vs last month
Marketing components

Pricing cards

Featured plan uses a 1px brand-colour border — the only 1px border in the system. The card background is never filled with brand colour.

Why not fill the featured card with brand colour? A brand-colour background on the featured card reads as "this is selected", not "this is recommended". It also forces white text on a coloured background, which reduces the legibility of the feature list — exactly the content that needs to be read carefully. The 1px border achieves the same differentiation without the legibility cost.
Starter
£49/mo
Up to 3 users
1 data source
30-day history
Email reports
Enterprise
Custom
Unlimited users
Unlimited sources
SSO / SAML
SLA + support
Navigation

Marketing nav

52px height. Transparent at page top, solid on scroll. Authenticated state replaces CTA with "Go to app" + avatar.

Signed out
Orbit
Sign in
Signed in
Orbit
Go to app →
SK
Why "Go to app →" in brand-muted, not white? White would make it compete with the wordmark and active nav link. Brand-muted (#818CF8) reads as a navigation action — something purposeful — rather than a label. It also creates visual continuity with the eyebrow colour used in marketing sections, reinforcing that brand-muted is "the accent" on dark surfaces.
Navigation

App sidebar

192px wide. Always dark regardless of light/dark mode. Product switcher at top, user identity at bottom.

Why a sidebar instead of a top nav in the app? Top navs force all products to share the same horizontal space. As features are added, top nav items overflow into dropdowns or truncate. A sidebar scales vertically without compression and keeps all navigation visible. It also creates a clear spatial separation between navigation (left, dark) and content (right, light) that aids orientation in information-dense interfaces.

Why is the sidebar always dark, even in light mode? The sidebar's job is product context — which product, which section, who you are. Keeping it anchored to the dark treatment makes it consistent across all products regardless of their brand colour, and gives the product icon somewhere to land with real contrast. A light sidebar in a light-mode app would require careful work to avoid looking like a continuation of the content area.
O
Orbit
Overview
Customers
Invoices
Reports
Overview
Export
MRR
£24,830
Customers
348
Navigation

The handoff moment

The transition from marketing to app is a designed moment, not just a route change. The visual jump is intentional — it signals the user has crossed a threshold.

Why design the handoff explicitly? Most SaaS products treat the marketing-to-app transition as a technical problem (routing). The user clicks "Start free trial", gets redirected, and lands in an empty dashboard. That jarring emptiness after a high-energy conversion moment is a missed opportunity. The three principles: (1) the dark sidebar appears immediately — it's the visual anchor that says "you're in the product". (2) First load shows an onboarding checklist, not an empty state. (3) Brand colour (#5B6EE1) is the continuous thread — it appears in the marketing CTA button, then immediately in the app sidebar and the first active checklist item.
Marketing surface
Know exactly where your growth comes from
App surface — first load
O
Orbit
Welcome — let's get you set up
1
Connect your first data source
2
Invite your team
Multi-product

Per-product theming

Each product defines 7 brand tokens under a data-theme attribute. Everything else is shared.

Why not override semantic tokens directly per product? If products override --color-interactive directly, you lose the ability to control light/dark mode separately. The brand primitive tokens (--brand-500, etc.) sit between primitives and semantic tokens — semantic tokens reference brand tokens, brand tokens are set per product. This means a product theme file only needs to define 7 values, and the entire semantic layer — including dark mode variants — updates automatically.

Important caveat: not all brand colours work in all positions. A pale or low-saturation brand colour cannot be used as a nav background the same way deep indigo can. Each product's theme file must be authored with care — --brand-dark-nav in particular may need to be an independently chosen dark value rather than a mechanical darkening of the brand colour.
/* Required per-product brand tokens */ [data-theme="orbit"] { --brand-500: #5B6EE1; /* Primary interactive */ --brand-400: #818CF8; /* Dark mode variant (lighter) */ --brand-50: #E8EAFF; /* Light muted fill */ --brand-700: #3A4DC8; /* Text on brand-50 background */ --brand-900: #1E2660; /* Nav background (light mode) */ --brand-dark-muted: #1E254A; /* Dark mode badge fill */ --brand-dark-nav: #0F1330; /* Dark mode nav — independently authored */ } [data-theme="beacon"] { --brand-500: #0E9E72; --brand-400: #34C98F; --brand-50: #D4F5EA; --brand-700: #097355; --brand-900: #065440; --brand-dark-muted: #0A2E22; --brand-dark-nav: #031F18; }
Multi-product

Dark mode

Semantic tokens absorb the mode switch. Components never know which mode is active.

Key rules for dark mode colour shifts: Brand colour lightens (500 → 400) because saturated colours at 500 feel too heavy on dark backgrounds. The muted tint inverts — a light 50-stop tint on a dark surface reads as a light bleed, not a badge. Nav stays darkest even in dark mode — losing the hierarchy between nav and page background makes the sidebar disappear visually. Surface hierarchy inverts: in light mode, page is stone-50 and cards are white (lighter). In dark mode, page is ink-950 and cards are ink-900 (lighter). Same logic, opposite direction.
Light mode
--color-surface
#F5F5F4
--color-surface-raised
#FFFFFF
--color-text-primary
#18181A
--color-interactive
#5B6EE1 (500)
--color-interactive-muted
#E8EAFF (50)
Dark mode
--color-surface
#18181A
--color-surface-raised
#232325
--color-text-primary
#F5F5F4
--color-interactive
#818CF8 (400)
--color-interactive-muted
#1E254A (dark)
/* Semantic tokens — light defaults */ :root { --color-surface: #F5F5F4; --color-surface-raised: #FFFFFF; --color-border: #E5E5E3; --color-text-primary: #18181A; --color-text-secondary: #78716C; --color-interactive: var(--brand-500); --color-interactive-muted:var(--brand-50); --nav-background: var(--brand-900); } /* Dark mode overrides */ @media (prefers-color-scheme: dark) { :root { --color-surface: #18181A; --color-surface-raised: #232325; --color-border: #3A3A3C; --color-text-primary: #F5F5F4; --color-text-secondary: #A09E97; --color-interactive: var(--brand-400); --color-interactive-muted:var(--brand-dark-muted); --nav-background: var(--brand-dark-nav); } }
Multi-product

Creating a new theme

Each product requires exactly 7 brand primitive tokens. Everything else — typography, spacing, neutral palette, radius, component behaviour — is shared and updates automatically when those 7 values change.

Don't mechanically darken the brand colour for --brand-dark-nav. Not all brand colours work in all positions. A pale or low-saturation brand colour cannot serve as a nav background the same way deep indigo can. --brand-dark-nav should be independently chosen — a dark surface that feels right as a sidebar, not a mathematical shade derived from the brand. Treat it as a dedicated dark-surface colour that happens to share the brand's hue family.
The 7 brand tokens
/* app.css — one [data-theme] block per product */ [data-theme="yourproduct"] { --brand-500: oklch(…); /* Primary interactive — buttons, focus rings */ --brand-400: oklch(…); /* Dark mode variant — lighter stop */ --brand-50: oklch(…); /* Light muted fill — badge bg, hover states */ --brand-700: oklch(…); /* Text on brand-50 backgrounds */ --brand-900: oklch(…); /* Nav background (light mode) */ --brand-dark-muted: oklch(…); /* Dark mode badge fill — must be a dark stop */ --brand-dark-nav: oklch(…); /* Dark mode nav — independently authored */ }
What each token drives
Token Used in Contrast requirement
--brand-500 Primary buttons, focus rings, active nav items, links ≥ 4.5:1 on white (#FFFFFF) — WCAG AA
--brand-400 Dark mode buttons, marketing eyebrow text ≥ 4.5:1 on #0D0D12 (marketing canvas)
--brand-50 Badge backgrounds, focus fill, hover states, selected rows Must support brand-700 text at ≥ 4.5:1
--brand-700 Text on brand-50 backgrounds, badge labels ≥ 4.5:1 on brand-50 — WCAG AA
--brand-900 Light mode nav background White text at 45% opacity must be legible
--brand-dark-muted Dark mode badge fill, muted interactive backgrounds Must be dark — a tint (50-stop) would read as light bleed
--brand-dark-nav Dark mode nav background White text at 100% must pass AA ≥ 4.5:1
Set the theme in your layout
// src/routes/+layout.svelte <html data-theme="yourproduct" lang="en"> <body><slot /></body> </html>
Ship checklist
Check Pass condition
brand-500 on white ≥ 4.5:1 contrast ratio (WCAG AA)
brand-400 on #0D0D12 ≥ 4.5:1 contrast ratio (WCAG AA)
brand-700 on brand-50 ≥ 4.5:1 contrast ratio (WCAG AA)
White (45% opacity) on brand-900 Nav resting state legible
White (100%) on brand-dark-nav ≥ 4.5:1 contrast ratio
brand-dark-nav independently authored Not a mechanical darkening of brand-900
data-theme set on <html> in root layout All semantic tokens resolve through brand layer
Run Design.md linter npx @google/design.md lint Design.md exits 0