Back to Pyroscope

Design System

ui/DESIGN.md

2.0.114.5 KB
Original Source

Design System

The design system lives in a single file: src/theme.css. Import it once at the app entry point and then reference its CSS custom properties anywhere in the codebase.

ts
// src/main.tsx
import './theme.css';

No build step, preprocessor, or runtime dependency required.


Core concept: two token tiers

The system has two layers, and knowing which to use is the main thing to internalize.

Primitives are raw palette values — the color blue at a given lightness, a spacing unit, a radius value. They live in :root and are named after what they are.

css
--blue-500: #3d71d9;
--space-4: 1rem;

Semantic tokens are role-based aliases that reference primitives (or define their own values). They are named after how they are used.

css
--color-primary: var(--blue-500);
--bg-primary: #212a44;

The rule: components always use semantic tokens. Primitives exist only to define semantics — never reference --blue-500 in a component. This is what allows the light/dark theme swap to work: semantic tokens are redefined per theme, primitives are not.


Theming

Dark mode is the default. Light mode is activated by setting data-theme="light" on <html>. Remove the attribute to return to dark.

ts
// Enable light mode
document.documentElement.setAttribute('data-theme', 'light');

// Return to dark mode
document.documentElement.removeAttribute('data-theme');

Any component using semantic tokens automatically updates — no JavaScript, no class toggling on individual elements.


Semantic tokens reference

Backgrounds

Four depth layers, from furthest back to foremost. Use them in order — don't skip layers.

TokenUse
--bg-canvasThe outermost page background
--bg-primaryPanels, cards, main content surfaces
--bg-secondarySidebars, form inputs, nested surfaces
--bg-elevatedDropdowns, popovers, tooltips, dialogs
css
.panel {
  background: var(--bg-primary);
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-lg);
}

.dropdown {
  background: var(--bg-elevated);
  box-shadow: var(--shadow-md);
}

In light mode, --bg-elevated and --bg-primary both resolve to #ffffff. Elevation is communicated by shadow rather than color difference — so always pair --bg-elevated with an appropriate --shadow-*.

Borders

Alpha-based so they blend correctly on any background layer.

TokenUse
--border-weakDividers, row separators, subtle section breaks
--border-mediumDefault element borders (inputs, cards, panels)
--border-strongFocused elements, prominent outlines
css
.input {
  border: 1px solid var(--border-medium);
}

.input:focus {
  border-color: var(--color-primary-border);
  box-shadow: 0 0 0 3px var(--action-focus);
}

Text

TokenUse
--text-primaryAll main body and UI text
--text-secondaryLabels, hints, descriptions, metadata
--text-disabledNon-interactive / disabled text
--text-linkAnchor text
--text-link-hoverAnchor text on hover
--text-max-contrastText placed on top of colored backgrounds (e.g. inside a filled badge or alert)

Action states

These are overlay colors — apply them via background on hover/selected states, not as solid colors.

TokenUse
--action-hoverBackground overlay when a row, item, or button is hovered
--action-selectedBackground overlay for the currently active/selected item
--action-focusFocus ring color (used with box-shadow or outline)
css
.menu-item:hover {
  background: var(--action-hover);
}

.menu-item[aria-current='true'] {
  background: var(--action-selected);
}

Semantic color roles

Each role (primary, secondary, success, error, warning) exposes five tokens:

SuffixUse
(none)Solid fill — primary button background, alert background
-hoverSolid fill on hover
-subtleLow-opacity tinted background — for badges, highlights, alert banners
-borderBorder and ring color when referencing this role
-textReadable text in this color on a neutral background
-foregroundText placed on top of the solid fill (e.g. label inside a filled button)
css
/* A filled primary button */
.btn-primary {
  background: var(--color-primary);
  color: var(--color-primary-foreground); /* white */
  border: 1px solid transparent;
}

.btn-primary:hover {
  background: var(--color-primary-hover);
}

/* An outlined primary button */
.btn-primary-outline {
  background: transparent;
  color: var(--color-primary-text);
  border: 1px solid var(--color-primary-border);
}

/* An inline status badge */
.badge-error {
  background: var(--color-error-subtle);
  color: var(--color-error-text);
  border: 1px solid var(--color-error-border);
}

The -text tokens are theme-aware: in dark mode they resolve to a light shade of the color (readable on dark backgrounds), in light mode they resolve to a darker shade (readable on white).

Shadows

TokenTypical use
--shadow-xsSubtle lift for small interactive elements
--shadow-smCards and panels in light mode
--shadow-mdDropdowns, popovers
--shadow-lgModals, dialogs
--shadow-xlFull-screen overlays, drawers

Shadow opacity is automatically heavier in dark mode and lighter in light mode — the same token works correctly in both themes.


Primitive tokens reference

Use these only when defining new semantic tokens, not in components directly.

Color palette

All hues follow a numeric lightness scale (100 = lightest, 700 = darkest). Available hues: --neutral, --blue, --green, --red, --orange.

--blue-100  #d6e4ff   ← very light
--blue-300  #6e9fff
--blue-500  #3d71d9   ← mid (used as --color-primary)
--blue-700  #1e449e   ← dark

The neutral scale runs from --neutral-0 (#ffffff) to --neutral-1000 (#000000) with stops at 50, 100, 200 … 950. Most dark-mode backgrounds are derived from --neutral-850 through --neutral-900.

Spacing

4 px base scale in rem. Choosing rem over px means the spacing scales proportionally when a user adjusts their browser's base font size.

TokenValuepx equivalent
--space-0-50.125rem2 px
--space-10.25rem4 px
--space-20.5rem8 px
--space-30.75rem12 px
--space-41rem16 px
--space-51.25rem20 px
--space-61.5rem24 px
--space-82rem32 px
--space-102.5rem40 px
--space-123rem48 px
--space-164rem64 px

Border radius

TokenValueUse
--radius-sm3pxChips, badges, tags
--radius-md5pxButtons, inputs, standard elements
--radius-lg8pxCards, panels, dropdowns
--radius-xl12pxDialogs, modals
--radius-full9999pxPills, avatars, toggles

Typography

Font families:

css
font-family: var(--font-sans); /* Roboto, with system fallbacks */
font-family: var(--font-mono); /* Roboto Mono, with system fallbacks */

Size scale — base UI text is --text-md (14 px). The html element is set to 16px, so 1rem = 16px.

TokenValue
--text-xs11 px
--text-sm12 px
--text-md14 px ← default body size
--text-lg16 px
--text-xl18 px
--text-2xl20 px
--text-3xl24 px
--text-4xl32 px

Weights: --weight-light (300), --weight-regular (400), --weight-medium (500), --weight-bold (700)

Line heights: --leading-tight (1.25), --leading-base (1.5), --leading-relaxed (1.7)

Letter spacing: --tracking-tight (-0.01em), --tracking-normal (0), --tracking-wide (0.02em), --tracking-caps (0.08em)

Z-index

TokenValueUse
--z-raised10Slightly elevated in-flow elements
--z-dropdown1000Dropdown menus
--z-sticky1100Sticky headers, toolbars
--z-overlay1200Background overlays
--z-modal1300Modal dialogs
--z-popover1400Popovers, command palettes
--z-toast1500Toast notifications
--z-tooltip1600Tooltips (always on top)

Motion

Durations: Use shorter durations for small elements, longer for large ones.

TokenValueUse
--duration-fast100msIcon swaps, simple visibility toggles
--duration-base150msDefault — buttons, badges, borders
--duration-slow200msDropdowns, expanding panels
--duration-slower300msModals, drawers, page transitions

Easing:

TokenUse
--ease-outMost interactions — elements that enter or respond to user input
--ease-in-outElements that travel from one place to another
--ease-smoothGeneral-purpose smooth curve (Material-style)
--ease-springPlayful overshoot — tooltips, popovers appearing
css
.dropdown {
  transition:
    opacity var(--duration-slow) var(--ease-out),
    transform var(--duration-slow) var(--ease-spring);
}

Patterns

Building a card component

css
.card {
  background: var(--bg-primary);
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
  box-shadow: var(--shadow-sm);
}

.card-title {
  font-size: var(--text-lg);
  font-weight: var(--weight-medium);
  color: var(--text-primary);
  margin-bottom: var(--space-3);
}

.card-body {
  font-size: var(--text-md);
  color: var(--text-secondary);
  line-height: var(--leading-base);
}

Building a status badge

css
.badge {
  display: inline-flex;
  align-items: center;
  padding: var(--space-0-5) var(--space-2);
  border-radius: var(--radius-sm);
  font-size: var(--text-xs);
  font-weight: var(--weight-medium);
}

.badge-success {
  background: var(--color-success-subtle);
  color: var(--color-success-text);
  border: 1px solid var(--color-success-border);
}

Building a form input

css
.input {
  background: var(--bg-secondary);
  color: var(--text-primary);
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-md);
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-md);
  transition:
    border-color var(--duration-base) var(--ease-out),
    box-shadow var(--duration-base) var(--ease-out);
}

.input::placeholder {
  color: var(--text-disabled);
}

.input:focus {
  outline: none;
  border-color: var(--color-primary-border);
  box-shadow: 0 0 0 3px var(--action-focus);
}

.input:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

Adding a new semantic token

This is highly atypical. The token set is intentionally minimal and was designed to cover the common cases without bloat. Before adding a new semantic token, check whether an existing one covers the need — and if not, seek sign-off from the team first. Expanding the token set has a maintenance cost: every new token requires a light-mode override if it is theme-dependent, and it needs to be documented here.

If a new token is genuinely warranted, add it to the :root block in theme.css alongside the existing semantic tokens, and add a [data-theme="light"] override if its value should differ between themes.

css
/* In :root (dark default) */
--sidebar-width: 240px;
--topbar-height: var(--space-12);

What not to do

Don't use primitives in components. --blue-500 is a palette building block, not a component color. Use --color-primary instead, which adapts across themes.

Don't hardcode hex values. If a color doesn't exist in the token system, extend the system — don't reach for raw hex in a component stylesheet.

Don't skip background layers. If content should appear above the page background, use --bg-primary. If it should appear above that (as in a sidebar or nested surface), use --bg-secondary. Skipping layers breaks the visual depth model.

Don't use px for spacing. The spacing scale uses rem for accessibility. Users who set a larger browser font size get proportionally larger spacing. Hardcoded px spacing won't scale with them.