Skip to main content
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-800zinc-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

Primary

Selection

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.
VariableLightDark
--backgroundhsl(0 0% 100%)hsl(224 71% 4%)
--foregroundhsl(222.2 47.4% 11.2%)hsl(213 31% 91%)
--mutedhsl(210 40% 96.1%)hsl(223 47% 11%)
--muted-foregroundhsl(215.4 16.3% 46.9%)hsl(215.4 16.3% 56.9%)
--accenthsl(210 40% 96.1%)hsl(216 34% 17%)
--borderhsl(214.3 31.8% 91.4%)hsl(216 34% 17%)
--ringhsl(215 20.2% 65.1%)hsl(216 34% 17%)
--destructivehsl(0 100% 50%)hsl(0 63% 31%)

Zinc Scale — Dark Card Surfaces

Use zinc directly on dark card surfaces — not the CSS variables above.

Surfaces

Text

Status Colors

Always use /10 opacity background paired with matching foreground text. Never solid.

Status

Priority

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.
TokenFamilyWeightsUse
font-sansInterAllAll UI — body, labels, buttons, inputs
font-serifPP Editorial New200, 400Editorial headings, landing hero text
font-monoAnonymous Pro400, 700Code 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.
TagClassesSize
<h1>text-3xl font-bold30px
<h2>text-2xl font-bold24px
<h3>text-xl font-bold20px
<h4>text-lg font-bold18px
<h5>text-base font-bold16px
<h6>text-sm font-bold14px

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

ValueUse
gap-1 / gap-1.5Icon + label pairs
gap-2Standard row items
gap-3Section spacing inside cards
space-y-2Vertical list of items inside a card
p-3Inner card item padding
p-4Outer card padding
px-3 / px-4Horizontal padding on inputs, buttons

Border Radius

ContextClassSize
Dark cards — outerrounded-2xl16px
Dark cards — inner itemsrounded-2xl or rounded-xl12px
Imagesrounded-3xl24px
Buttons, inputsrounded-md6px
Badges, pillsrounded-full
Context menusrounded-xl12px
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.
ContextValue
Buttons, inputsshadow-xs
Dialogs, sheetsshadow-lg
Dark cards (solid)No shadow — flat design
Dark cards (glass)bg-zinc-800/40 backdrop-blur-xl
Hover on dark surfaceshover:bg-white/5
LevelClassUse
Moderatebackdrop-blur-lgGlass cards
Standardbackdrop-blur-xlPanels overlaying content, floating cards
Maximumbackdrop-blur-2xlSearch 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

LayerClasses
Outer containerrounded-2xl bg-zinc-800 p-4 w-fit min-w-[400px]
Outer (accordion)rounded-2xl bg-zinc-800 p-3 py-0
Inner itemrounded-2xl bg-zinc-900 p-3
Inner item compactrounded-xl bg-zinc-900 p-3
Glass variantrounded-2xl bg-zinc-800/40 p-4 backdrop-blur-xl
Section headertext-sm font-semibold text-zinc-100 mb-3
Item titletext-sm font-medium text-zinc-200
Item title (prominent)text-sm font-medium text-zinc-100
Body texttext-xs text-zinc-400
Meta / timestamptext-xs text-zinc-500
Item spacingspace-y-2
Status badgerounded-full px-2 py-0.5 text-xs + status color
Section divider<Divider className="bg-zinc-700/50" /> (HeroUI)
Hoverable list itemp-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.
ContextValue
Inline (badges, text)height={17}
Action buttonssize={16}
Prominent / decorativesize={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

ClassDurationUse
animate-spinInfiniteLoading spinner
animate-pulseInfiniteSkeleton placeholder
animate-accordion-down0.2s ease-outAccordion open
animate-accordion-up0.2s ease-outAccordion close
animate-scale-in0.4s bounceElement entrance
animate-scale-in-blur0.5s bounceBlurred entrance
animate-shimmer2s linearShimmer effect
animate-shake0.7sError shake

Transitions

Default: transition-all duration-200. Use this everywhere unless a specific property needs targeting.
ScenarioClasses
All propertiestransition-all duration-200
Color onlytransition-colors duration-200
Button pressactive:scale-95 transition-all! duration-300

Easing

NameValueUse
DefaulteaseMost transitions
Exit / entranceease-outEntrances, exits
Bouncecubic-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

Style preset: new-york · Base color: zinc · CSS variables: on · Located at src/components/ui/
ComponentKey details
Buttondefault destructive outline secondary ghost link · sizes: default sm lg icon
Inputh-9 rounded-md shadow-xs · focus ring · aria-invalid for errors
TextareaSame as Input · min-h-16 · auto-height via field-sizing-content
DialogZoom + fade · use for confirmations, forms requiring focus
SheetFade-in slide panel · use for side panels, settings drawers
PopoverAnchored overlay · use for inline pickers, contextual options
TooltipHover label only — no interactive content
DropdownMenu / ContextMenuAction lists
AccordionAnimated expand/collapse
Avatarrounded-full, image + fallback
Skeletonanimate-pulse rounded-md
ScrollAreaRadix scrollable with edge shadows
SidebarCollapsible · Cmd/Ctrl+B toggle · cookie-persisted

Styling Tools

// 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",
    },
  },
});

Forms & Validation

Field pattern

<FormField
  control={form.control}
  name="fieldName"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Label</FormLabel>
      <FormControl>
        <Input placeholder="..." {...field} />
      </FormControl>
      <FormMessage />  {/* auto-shows error */}
    </FormItem>
  )}
/>

Input states

Error state is driven by aria-invalid={!!error} — styling applies automatically via the Input component.
StateVisual
Defaultborder-input bg-transparent
Focusring-ring/50 ring-[3px] border-ring
Errorring-destructive/20 border-destructive (via aria-invalid)
Disabledopacity-50 cursor-not-allowed
Loadingcursor-wait (set disabled on the input)

Loading & Empty States

Loading

PatternWhen
<Skeleton className="h-4 w-32 rounded-md" />Known content shape, replacing text/images
animate-pulse on the containerUnknown shape, shimmer a region
animate-spin on an iconInline 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

StateClasses
Hover (standard)hover:bg-accent · hover:bg-primary/90 · hover:opacity-80
Hover (dark surface)hover:bg-white/5
Focus visiblefocus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:border-ring
Active / pressactive:scale-95
Disableddisabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed
Erroraria-invalid:ring-destructive/20 aria-invalid:border-destructive
Hover revealopacity-0 transition-all group-hover:opacity-100 (parent needs group)

Dark / Light Mode

Class-based: .dark on <html>. Tailwind dark: modifier works everywhere.
  • Layout surfacesbg-background text-foreground (auto-switches via CSS vars)
  • Dark cardsbg-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

BreakpointValueImpact
Mobilemax-width: 600pxFull-width layouts, larger tap targets
Tabletmax-width: 990pxNavbar becomes full-width strip
md:768pxText size shifts (text-basetext-sm)
Core layout does not use lg:, xl:, or 2xl: breakpoints.

Scrollbars

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