Skip to content
Component installs need the registry setup
VegaStack Design

Design Principles

Neutral ink, semantic color, disciplined typography, bordered surfaces, and shadow only where something floats.

Last updated

VegaStack's look is restraint, with color spent only where it carries meaning. The product reads as calm, dense, and typographic; color and elevation are spent sparingly so the few places that use them actually mean something. The system is shadcn base-nova used as-is, so the shape of every component is upstream's; what follows is the judgement layered on top, and the parts a script can check are enforced by design-lint and the design-audit skill.

Neutral ink, minimal color

The primary action color is a pure neutral ink, not a brand hue. --primary is oklch(0.205 0 0) in light and oklch(0.922 0 0) in dark — shadcn's own achromatic neutral ramp. Default buttons, focus rings, key text, and every selected / active state all sit in this neutral register, so the interface stays quiet by default.

Color is rationed to a named role. Beyond neutral ink, color belongs to status families (success, warning, destructive), blue info, categorical chart/tag palettes, or the brand marker role. Product controls stay neutral unless color carries one of those explicit meanings.

Surfaces & text

--background
--foreground
--card
--card-foreground
--popover
--popover-foreground
--secondary
--secondary-foreground
--muted
--muted-foreground
--accent
--accent-foreground

Action — neutral ink

--primary
--primary-foreground

Info — links & informational (the one chromatic accent)

--info
--info-foreground
--info-text

Destructive

--destructive
--destructive-foreground
--destructive-text

Success

--success
--success-foreground
--success-text

Warning

--warning
--warning-foreground
--warning-text

Lines & focus

--border
--input
--ring

Brand

--brand
--brand-text

Media chrome

--media-scrim
--media-scrim-strong
--media-foreground

Photo scrim

--scrim
--scrim-foreground

Charts — categorical series

--chart-1
--chart-2
--chart-3
--chart-4
--chart-5
--chart-6
--chart-7
--chart-8
--chart-single

Tags — categorical metadata (not status)

--tag-blue
--tag-blue-subtle
--tag-blue-text
--tag-cyan
--tag-cyan-subtle
--tag-cyan-text
--tag-green
--tag-green-subtle
--tag-green-text
--tag-lime
--tag-lime-subtle
--tag-lime-text
--tag-yellow
--tag-yellow-subtle
--tag-yellow-text
--tag-orange
--tag-orange-subtle
--tag-orange-text
--tag-red
--tag-red-subtle
--tag-red-text
--tag-pink
--tag-pink-subtle
--tag-pink-text
--tag-magenta
--tag-magenta-subtle
--tag-magenta-text
--tag-purple
--tag-purple-subtle
--tag-purple-text

Sidebar surface

--sidebar
--sidebar-foreground
--sidebar-primary
--sidebar-primary-foreground
--sidebar-accent
--sidebar-accent-foreground
--sidebar-border
--sidebar-ring

Spend nothing you don't have to

Reach for bg-primary (neutral ink) for actions, selection, and active states; reach for a colored token only through its named status, information, category, or brand role. Unnamed decoration is wrong.

Do
Use bg-primary for product actions; use status, info, category, or brand tokens only for their named roles.
Don't
Invent an unnamed accent or use a chromatic token where neutral ink communicates the same thing.

Size does hierarchy

Headings, body, prose and numbers are all Geist (numbers with tabular-nums); code and identifiers are Geist Mono. Hierarchy comes from size, weight and color, on Tailwind's stock scale — text-sm is 14px, text-base is 16px, and there are no named role utilities layered over them. What is layered on is optical: every step above text-base carries Geist's own line-height and negative letter-spacing, declared once in the theme, so a heading is correctly spaced without anyone reaching for tracking-*. See Typography.

Geist — the quick brown foxtext-7xl · 72 / 72 · −0.06em · display
Geist — the quick brown foxtext-6xl · 60 / 64 · −0.06em · display
Geist — the quick brown foxtext-5xl · 48 / 56 · −0.06em · display
Geist — the quick brown foxtext-4xl · 36 / 44 · −0.05em
Geist — the quick brown foxtext-3xl · 30 / 38 · −0.04em
Geist — the quick brown foxtext-2xl · 24 / 32 · −0.04em
Geist — the quick brown foxtext-xl · 20 / 26 · −0.02em
Geist — the quick brown foxtext-lg · 18 / 26 · −0.012em
Geist — the quick brown foxtext-base · 16 / 24 · prose body
Geist — the quick brown foxtext-sm · 14 / 20 · default body · the control voice
Geist — the quick brown foxtext-xs · 12 / 16

Use font-sans for everything textual, with tabular-nums for numbers and currency, and font-mono for code and identifiers; the serif face is a rationed display accent (emphasis words, pull-quotes), never running text.

Do
Let size, weight and color carry hierarchy — font-medium for titles, font-semibold where a heading earns it.
Don't
Stack three weights in one view, or hand-set font-size and line-height instead of using the scale.

Bordered surfaces, shadow where something floats

Resting surfaces are defined by borders, not elevation. Cards, inputs, panels and rows use border-border with no drop shadow. Shadow means a surface genuinely floats above the page, and it is Tailwind's own step at the weight upstream gives that component — shadow-sm, shadow-md or shadow-lg.

Floating surface

active tab chip · floating sidebar · inset main

shadow-sm

Floating surface

popover · menu · select

shadow-md

Floating surface

sheet · submenu

shadow-lg

Corners derive from one --radius (0.625rem / 10px): rounded-sm 6px, rounded-md 8px, rounded-lg 10px, rounded-xl 14px. Closely nested edges must be concentric.

--radius-sm

0.6 × --radius · 6px

--radius-md

0.8 × --radius · 8px

--radius-lg

1 × --radius · 10px

--radius-xl

1.4 × --radius · 14px

Do
Separate resting surfaces with border-border, and reserve shadow for surfaces that genuinely float.
Don't
Give a card, a row or a resting control a drop shadow, or nest equal-elevation cards.

Semantic tokens only

Components reference semantic utilities (bg-primary, text-muted-foreground, border-border), never raw palettes or hardcoded values. Tokens are OKLCH variables defined in @vegastack/design-tokens/theme.css and bridged to Tailwind through @theme inline, so redefining a single --primary in your app repaints every component in both themes.

// good — semantic tokens, themeable
<button className="bg-primary text-primary-foreground rounded-md" />

// bad — hardcoded hex / raw palette, opaque to overrides
<button className="bg-[#18181b] text-white rounded-[6px]" />

design-lint fails any hardcoded hex/px, raw palette class (bg-neutral-900), or visual property in an inline style — the only sanctioned inline-style use is assigning CSS custom properties (and a single, file-scoped swatch-fill exception in ColorPicker).

Do
Style with semantic tokens: bg-primary, text-muted-foreground, border-border, rounded-md.
Don't
Hardcode hex or px, use raw palettes (bg-neutral-900), or put visual props in inline styles.

4px spacing scale

Spacing follows Tailwind v4's 4px base unit — every gap, pad, and margin is a multiple of 4px via the standard scale (p-1 4px, p-2 8px, p-4 16px, …). Stick to scale steps; don't reach for arbitrary spacing values.

1 · calc(var(--spacing) * 1) · 4px
2 · calc(var(--spacing) * 2) · 8px
3 · calc(var(--spacing) * 3) · 12px
4 · calc(var(--spacing) * 4) · 16px
6 · calc(var(--spacing) * 6) · 24px
8 · calc(var(--spacing) * 8) · 32px
12 · calc(var(--spacing) * 12) · 48px
16 · calc(var(--spacing) * 16) · 64px

Motion is quiet and CSS-first

Motion uses three duration tokens (--duration-fast 150ms, --duration-base 200ms, --duration-slow 300ms) and four easings (--ease-standard, --ease-emphasized, --ease-exit, --ease-spring). Transitions are subtle and CSS-first; the JS motion library is installed by the registry items that render Motion-driven behavior, including animated icons. prefers-reduced-motion: reduce is enforced globally in @vegastack/design-tokens/base.css, so reduced-motion is honored without per-component work.

Duration

--duration-fast

150ms

--duration-base

200ms

--duration-slow

300ms

Easing

--motion-ease-standard

cubic-bezier(0.2, 0, 0, 1)

--motion-ease-emphasized

cubic-bezier(0.3, 0, 0, 1)

--motion-ease-exit

cubic-bezier(0.4, 0, 1, 1)

Accessible by default — WCAG 2.2 AA

Accessibility is a build rule, not a review step. Every component must pass axe and meet WCAG 2.2 AA without dropping existing 2.1 assertions, with every applicable UI state implemented — default, hover, focus, loading, empty, error, success, disabled. Interactive elements carry a visible :focus-visible ring: a 2px outline at 1px offset, keyed off --ring (which mirrors the neutral --primary ink). There are no separate ring-width or offset tokens — the 2px / 1px convention is the ring.

Press Tab to move focus onto these controls and reveal the --ring outline.

Color choices are AA-aware and gated fail-closed by the token contrast check: every status family ships a second ink, <family>-text, for the page and for the family's own tint, and --muted-foreground is held to the AA text floor on every neutral surface because it carries real secondary copy. The full contract — axe gate, keyboard semantics, and reduced motion — lives on the Accessibility page.

Focus is non-negotiable

Never remove the focus outline to "clean up" a design. Keyboard users rely on it, and the design-audit skill flags any interactive element that suppresses :focus-visible.

Do
Keep a visible 2px focus-visible ring and meet AA contrast on every interactive element.
Don't
Strip focus outlines, or use a status fill as ink on its own tint instead of the -text half.

On this page