Source of truth is DESIGN.md at the repo root. This page renders it visually. Keep them in sync when tokens change.
Philosophy
GAIA’s UI is dark-first, flat, and single-accent. Every decision flows from five constraints:
- Dark-first. Primary experience is dark mode (
#111111 background). Light mode is supported via CSS variables but is secondary.
- Flat depth. Depth comes from layered backgrounds (
zinc-800 → zinc-900) only. Never borders, rings, or outlines.
- Single accent. One primary action color:
#00bbff. Everything else is zinc-scale neutrals.
- Borderless cards. Data cards use background-only separation — no
border-, ring-, or outline- in the card tree.
- Subtle motion. Animations serve entrance, exit, and state changes only. Never decorative. Keep durations ≤ 300ms.
Colors
Brand Tokens
Layout rule: Use --color-primary for CTAs, user chat bubbles, selection highlights, and links. Use --color-primary-bg / --color-secondary-bg for the main app canvas and sidebar. Never use these on dark card surfaces — use zinc directly there.
Semantic Variables (Shadcn / Radix)
Use on layout surfaces and standard components. Switch automatically between light and dark.
| Variable | Light | Dark |
|---|
--background | hsl(0 0% 100%) | hsl(224 71% 4%) |
--foreground | hsl(222.2 47.4% 11.2%) | hsl(213 31% 91%) |
--muted | hsl(210 40% 96.1%) | hsl(223 47% 11%) |
--muted-foreground | hsl(215.4 16.3% 46.9%) | hsl(215.4 16.3% 56.9%) |
--accent | hsl(210 40% 96.1%) | hsl(216 34% 17%) |
--border | hsl(214.3 31.8% 91.4%) | hsl(216 34% 17%) |
--ring | hsl(215 20.2% 65.1%) | hsl(216 34% 17%) |
--destructive | hsl(0 100% 50%) | hsl(0 63% 31%) |
Zinc Scale — Dark Card Surfaces
Use zinc directly on dark card surfaces — not the CSS variables above.
Status Colors
Always use /10 opacity background paired with matching foreground text. Never solid.
bg-emerald-400/10 text-emerald-400 // success
bg-amber-400/10 text-amber-400 // warning
bg-red-400/10 text-red-400 // error
bg-blue-400/10 text-blue-400 // info
bg-zinc-700/50 text-zinc-400 // pending
Never use solid color backgrounds for status badges — always /10 opacity background paired with matching text color.
Typography
Three font families. Never set font-family inline — use the Tailwind token class.
| Token | Family | Weights | Use |
|---|
font-sans | Inter | All | All UI — body, labels, buttons, inputs |
font-serif | PP Editorial New | 200, 400 | Editorial headings, landing hero text |
font-mono | Anonymous Pro | 400, 700 | Code blocks, <code>, technical content |
Specimens
Inter — font-sans
The quick brown fox jumps
GAIA proactively manages your email, calendar, tasks, and workflows — so you don’t have to.
Light 300Regular 400Medium 500Semibold 600Bold 700
PP Editorial New — font-serif
The future of personal AI
is already here.
Ultralight 200Ultralight ItalicRegular 400Regular Italic
Anonymous Pro — font-mono
const gaia = await agent.run(“Summarize my inbox”);
Heading scale
Defined globally via @layer base — use semantic HTML tags, styles apply automatically.
| Tag | Classes | Size |
|---|
<h1> | text-3xl font-bold | 30px |
<h2> | text-2xl font-bold | 24px |
<h3> | text-xl font-bold | 20px |
<h4> | text-lg font-bold | 18px |
<h5> | text-base font-bold | 16px |
<h6> | text-sm font-bold | 14px |
Text patterns
// Uppercase section labels — settings, card headers, form groups
<p className="text-xs font-medium uppercase tracking-wider text-zinc-500">
Section Title
</p>
// Truncation — always truncate in constrained containers
<span className="truncate">...</span>
<p className="line-clamp-2">...</p>
<p className="line-clamp-1 max-w-[200px]">...</p>
Inline code gets border-radius: 10px and padding: 4px globally. Use font-mono or .monospace class.
Spacing
| Value | Use |
|---|
gap-1 / gap-1.5 | Icon + label pairs |
gap-2 | Standard row items |
gap-3 | Section spacing inside cards |
space-y-2 | Vertical list of items inside a card |
p-3 | Inner card item padding |
p-4 | Outer card padding |
px-3 / px-4 | Horizontal padding on inputs, buttons |
Border Radius
| Context | Class | Size |
|---|
| Dark cards — outer | rounded-2xl | 16px |
| Dark cards — inner items | rounded-2xl or rounded-xl | 12px |
| Images | rounded-3xl | 24px |
| Buttons, inputs | rounded-md | 6px |
| Badges, pills | rounded-full | — |
| Context menus | rounded-xl | 12px |
Never use rounded-lg on card containers — that’s the Shadcn base radius, visually too small. Cards always use rounded-2xl.
Depth & Elevation
Depth primarily from background layering and blur, not shadow.
| Context | Value |
|---|
| Buttons, inputs | shadow-xs |
| Dialogs, sheets | shadow-lg |
| Dark cards (solid) | No shadow — flat design |
| Dark cards (glass) | bg-zinc-800/40 backdrop-blur-xl |
| Hover on dark surfaces | hover:bg-white/5 |
| Level | Class | Use |
|---|
| Moderate | backdrop-blur-lg | Glass cards |
| Standard | backdrop-blur-xl | Panels overlaying content, floating cards |
| Maximum | backdrop-blur-2xl | Search overlays, full-screen modals |
Dark Card System
All data cards, tool sections, and info panels use this contract. Two-tone zinc depth, no borders.
Template
"use client";
const statusClasses = {
success: "bg-emerald-400/10 text-emerald-400",
error: "bg-red-400/10 text-red-400",
warning: "bg-amber-400/10 text-amber-400",
info: "bg-blue-400/10 text-blue-400",
pending: "bg-zinc-700/50 text-zinc-400",
} as const;
export default function MyCard({ title, items, badge }) {
return (
<div className="rounded-2xl bg-zinc-800 p-4 w-fit min-w-[400px]">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-semibold text-zinc-100">{title}</p>
{badge && (
<span className="rounded-full bg-zinc-700/50 px-2 py-0.5 text-xs text-zinc-400">
{badge}
</span>
)}
</div>
<div className="space-y-2">
{items.map((item) => (
<div key={item.id} className="rounded-2xl bg-zinc-900 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-zinc-200">{item.label}</span>
<span className={`rounded-full px-2 py-0.5 text-xs ${statusClasses[item.status]}`}>
{item.value}
</span>
</div>
{item.meta && <p className="text-xs text-zinc-500 mt-1">{item.meta}</p>}
</div>
))}
</div>
</div>
);
}
Live preview
Recent Activity4 items
Deploy pipelineSuccess
2 minutes ago
Memory syncWarning
12 minutes ago
Email batchInfo
1 hour ago
Layer reference
| Layer | Classes |
|---|
| Outer container | rounded-2xl bg-zinc-800 p-4 w-fit min-w-[400px] |
| Outer (accordion) | rounded-2xl bg-zinc-800 p-3 py-0 |
| Inner item | rounded-2xl bg-zinc-900 p-3 |
| Inner item compact | rounded-xl bg-zinc-900 p-3 |
| Glass variant | rounded-2xl bg-zinc-800/40 p-4 backdrop-blur-xl |
| Section header | text-sm font-semibold text-zinc-100 mb-3 |
| Item title | text-sm font-medium text-zinc-200 |
| Item title (prominent) | text-sm font-medium text-zinc-100 |
| Body text | text-xs text-zinc-400 |
| Meta / timestamp | text-xs text-zinc-500 |
| Item spacing | space-y-2 |
| Status badge | rounded-full px-2 py-0.5 text-xs + status color |
| Section divider | <Divider className="bg-zinc-700/50" /> (HeroUI) |
| Hoverable list item | p-4 transition-all hover:bg-white/5 |
Constraints: Never border-, ring-, outline- anywhere in the card tree. rounded-2xl on outer containers always. zinc-800 outer → zinc-900 inner is the entire separation mechanism. Status colors always use /10 opacity backgrounds.
Icons
All icons come from @icons (@theexperiencecompany/gaia-icons). Never raw SVGs.
import { CheckmarkCircle02Icon, Alert01Icon, Copy01Icon } from "@icons";
Icons accept className, height, width, and size props.
| Context | Value |
|---|
| Inline (badges, text) | height={17} |
| Action buttons | size={16} |
| Prominent / decorative | size={24} |
// In a button
<Button variant="ghost" size="icon">
<Copy01Icon className="h-4 w-4" />
</Button>
// In a chip / badge
<Alert01Icon className="text-warning-500" height={17} />
// With hover animation
<SomeIcon className="transition-all duration-200 group-hover:scale-110" />
Never use Unicode/text symbols in JSX: no →, ↗, •, ✓, ×, or similar. Always use icon components from @icons.
Animations
Available classes
| Class | Duration | Use |
|---|
animate-spin | Infinite | Loading spinner |
animate-pulse | Infinite | Skeleton placeholder |
animate-accordion-down | 0.2s ease-out | Accordion open |
animate-accordion-up | 0.2s ease-out | Accordion close |
animate-scale-in | 0.4s bounce | Element entrance |
animate-scale-in-blur | 0.5s bounce | Blurred entrance |
animate-shimmer | 2s linear | Shimmer effect |
animate-shake | 0.7s | Error shake |
Transitions
Default: transition-all duration-200. Use this everywhere unless a specific property needs targeting.
| Scenario | Classes |
|---|
| All properties | transition-all duration-200 |
| Color only | transition-colors duration-200 |
| Button press | active:scale-95 transition-all! duration-300 |
Easing
| Name | Value | Use |
|---|
| Default | ease | Most transitions |
| Exit / entrance | ease-out | Entrances, exits |
| Bounce | cubic-bezier(0.34, 1.56, 0.64, 1) | scale-in, scale-in-blur |
Framer Motion
Import from motion/react — not framer-motion. AnimatePresence is required for exit animations. Keep durations ≤ 300ms for micro-interactions, ≤ 500ms for entrances.
import { AnimatePresence, m } from "motion/react";
<AnimatePresence mode="wait">
{visible && (
<m.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
{content}
</m.div>
)}
</AnimatePresence>
Toast / Notifications
Sileo — already mounted globally. Call the toast function directly. Never add <Toaster> or import from sonner / react-hot-toast.
Toast style: dark fill (#262626), white title, white/75 description, top-right position. Action button colors apply automatically by type: error → red, warning → amber, success → green, info → blue.
import { toast } from "@/lib/toast";
toast.success("File saved");
toast.error("Something went wrong");
toast.warning("Storage almost full");
toast.info("New message received");
Component Library
Shadcn UI
HeroUI
Overlay hierarchy
Style preset: new-york · Base color: zinc · CSS variables: on · Located at src/components/ui/| Component | Key details |
|---|
Button | default destructive outline secondary ghost link · sizes: default sm lg icon |
Input | h-9 rounded-md shadow-xs · focus ring · aria-invalid for errors |
Textarea | Same as Input · min-h-16 · auto-height via field-sizing-content |
Dialog | Zoom + fade · use for confirmations, forms requiring focus |
Sheet | Fade-in slide panel · use for side panels, settings drawers |
Popover | Anchored overlay · use for inline pickers, contextual options |
Tooltip | Hover label only — no interactive content |
DropdownMenu / ContextMenu | Action lists |
Accordion | Animated expand/collapse |
Avatar | rounded-full, image + fallback |
Skeleton | animate-pulse rounded-md |
ScrollArea | Radix scrollable with edge shadows |
Sidebar | Collapsible · Cmd/Ctrl+B toggle · cookie-persisted |
Used for richer UI inside cards. Located at @heroui/*.| Component | Standard usage |
|---|
Chip | Status badges · variant="flat" always |
Button | Card actions · variant="flat" or variant="solid" |
Accordion / AccordionItem | Expandable card sections |
Progress | Progress bars |
Tabs / Tab | Tabbed card content |
Avatar | Contact / user avatars |
ScrollShadow | Wraps overflowing lists |
Chart palette (Recharts): ["#a78bfa", "#34d399", "#60a5fa", "#f472b6", "#fb923c"] Pick the right overlay for the context:| Use case | Component |
|---|
| Destructive confirmation, focused form | Dialog |
| Side panel, settings, multi-step flow | Sheet |
| Inline picker, date selector, contextual detail | Popover |
| Single-line label on hover | Tooltip |
| Action list from a trigger | DropdownMenu |
| Right-click actions | ContextMenu |
// cn() — always use for conditional class merging, never string concatenation
import { cn } from "@/lib/utils";
<div className={cn("base-class", condition && "conditional-class", className)} />
// cva — for components with multiple visual variants
import { cva } from "class-variance-authority";
const cardVariants = cva("rounded-2xl p-4", {
variants: {
depth: {
outer: "bg-zinc-800",
inner: "bg-zinc-900",
},
},
});
Field pattern
<FormField
control={form.control}
name="fieldName"
render={({ field }) => (
<FormItem>
<FormLabel>Label</FormLabel>
<FormControl>
<Input placeholder="..." {...field} />
</FormControl>
<FormMessage /> {/* auto-shows error */}
</FormItem>
)}
/>
Error state is driven by aria-invalid={!!error} — styling applies automatically via the Input component.
| State | Visual |
|---|
| Default | border-input bg-transparent |
| Focus | ring-ring/50 ring-[3px] border-ring |
| Error | ring-destructive/20 border-destructive (via aria-invalid) |
| Disabled | opacity-50 cursor-not-allowed |
| Loading | cursor-wait (set disabled on the input) |
Loading & Empty States
Loading
| Pattern | When |
|---|
<Skeleton className="h-4 w-32 rounded-md" /> | Known content shape, replacing text/images |
animate-pulse on the container | Unknown shape, shimmer a region |
animate-spin on an icon | Inline action in progress |
Full <LoadingIndicator /> | Whole chat response pending |
Skeleton inherits: bg-accent animate-pulse rounded-md. Match the skeleton shape to the content it replaces.
Empty states
No shared component — build inline:
<div className="flex flex-col items-center gap-2 py-8 text-center">
<SomeIcon className="text-zinc-600" size={24} />
<p className="text-sm text-zinc-400">No items yet</p>
<p className="text-xs text-zinc-500">Optional sub-text</p>
</div>
Interactive States
| State | Classes |
|---|
| Hover (standard) | hover:bg-accent · hover:bg-primary/90 · hover:opacity-80 |
| Hover (dark surface) | hover:bg-white/5 |
| Focus visible | focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:border-ring |
| Active / press | active:scale-95 |
| Disabled | disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed |
| Error | aria-invalid:ring-destructive/20 aria-invalid:border-destructive |
| Hover reveal | opacity-0 transition-all group-hover:opacity-100 (parent needs group) |
Dark / Light Mode
Class-based: .dark on <html>. Tailwind dark: modifier works everywhere.
- Layout surfaces →
bg-background text-foreground (auto-switches via CSS vars)
- Dark cards →
bg-zinc-800 / bg-zinc-900 (always dark — no dark: needed)
- Explicit overrides → only when CSS variables don’t cover it
- Brand cyan (
#00bbff) is the same in both modes
Responsiveness
| Breakpoint | Value | Impact |
|---|
| Mobile | max-width: 600px | Full-width layouts, larger tap targets |
| Tablet | max-width: 990px | Navbar becomes full-width strip |
md: | 768px | Text size shifts (text-base → text-sm) |
Core layout does not use lg:, xl:, or 2xl: breakpoints.
Global scrollbar is already styled (8px, pill-shaped, zinc-700 thumb). Use .no-scrollbar to suppress chrome on scroll areas where it would be distracting.
Rules
Do
rounded-2xl on all outer card containers
zinc-800 outer → zinc-900 inner for card depth
/10 opacity backgrounds for all status colors
- Import icons from
@icons
cn() for all conditional class merging
transition-all duration-200 as the default transition
AnimatePresence for exit animations
- Import from
motion/react
import { toast } from "@/lib/toast"
Don’t
border-, ring-, or outline- anywhere in a card tree
rounded-lg on card containers
- Solid backgrounds for status badges
- Unicode symbols in JSX (
→, •, ✓) — use icons
- Add
<Toaster> — already mounted globally
- Import from
sonner or react-hot-toast
- CSS variables on dark card surfaces — use zinc directly
- Set
font-family inline — use Tailwind token classes
- Import from
framer-motion — use motion/react