Design System · v1.0
For engineers building Magpie
Editorial, restrained, audit-ready. Sage & cream palette with sharp typography and zero rounded corners on landing surfaces. This page is the source of truth — if a component disagrees with it, fix the component.
01 — Foundation
We read like a magazine for regulators. Sharp grids, mono eyebrows, generous whitespace. No drop shadows, no gradients, no purple.
Landing and marketing surfaces use 0px radius. Product chrome may use the semantic --radius token (10px) for inputs and cards.
Never hard-code colors in components. Use CSS variables (--landing-*, --primary, --foreground) or their Tailwind aliases.
Eyebrows, labels, timestamps, IDs use uppercase mono at 10–11px with letter-spacing 0.25em.
Hover lifts of -translate-y-0.5, color/opacity transitions only. No bouncy springs. Honor prefers-reduced-motion.
AA contrast minimum, visible focus rings, semantic HTML, alt text on every img, keyboard-reachable everywhere.
02 — Tokens
Used across /, marketing pages, the CMS, and the v2 app shell. Reference via bg-[var(--landing-bg)] or text-[var(--landing-fg)].
Background
--landing-bg
#f5f0e8
Surface
--landing-surface
#e8efe2
Accent
--landing-accent
#dce5d4
Foreground
--landing-fg
#2d3a2a
Foreground soft
--landing-fg-soft
#3a4a37
Used by shadcn/ui components. Auto-themed via :root and .dark. Available as Tailwind utilities: bg-primary, text-foreground, etc.
background
--background
foreground
--foreground
card
--card
primary
--primary
secondary
--secondary
muted
--muted
accent
--accent
destructive
--destructive
border
--border
ring
--ring
Rule: never write hex in components.
❌ className="bg-[#2d3a2a]" ✅ className="bg-[var(--landing-fg)]" or bg-foreground
03 — Tokens
Display
Space Grotesk
Headings, titles, buttons, brand wordmarks. Weights: 400 (regular), 500 (medium), 700 (bold). Apply via style={{ fontFamily: "'Space Grotesk', sans-serif" }}.
Body
DM Sans
Paragraphs, descriptions, table data. The default for everything that isn't a heading. Inherits from --font-sans via Inter fallback if DM Sans not loaded.
Mono
JetBrains Mono
Eyebrows, IDs, timestamps, code. Always uppercase with tracking-[0.25em] at 10–11px when used as label.
Type scale
Display 60px / 0.98
H1 — 36px / tight
H2 — 24px / tight
H3 — 18px / medium
Body — 16px / relaxed leading
Small — 14px / 80% opacity for secondary
EYEBROW — 11px MONO
04 — Layout
Page wrapper: mx-auto max-w-7xl px-6 lg:px-12. Section vertical rhythm: py-16 lg:py-20 with a top border-t border-[var(--landing-fg)]/10.
Use a 12-column grid: grid grid-cols-12 gap-8. Editorial layouts favor 7/5 or 8/4 splits over symmetric 6/6.
05 — Motion
-translate-y-0.5 on primary CTAs.
150ms ease for opacity & color.
framer-motion: opacity 0→1, y 12→0, 0.6s ease.
All animation must respect @media (prefers-reduced-motion: reduce) — use useReducedMotion() from framer-motion.
07 — Components
Recipe
border-[var(--landing-fg)]/20, transparent bg, 2.5/3 paddingfocus:border-[var(--landing-fg)] focus:outline-none08 — Components
Card · Default
bg-[var(--landing-bg)] · border /15
Card · Subtle
bg-[var(--landing-surface)] · for grouped content
Card · Inverted
For CTA blocks and emphasis only
Border opacity ladder: /10 for layout dividers, /15 for cards,/20 for inputs, /100 for emphasis.
09 — Components
| System | Risk | Status | Updated |
|---|---|---|---|
| Credit scoring v3 | High | Approved | 2 days ago |
| KYC anomaly | Medium | In review | 5 hours ago |
| Fraud queue triage | Low | Approved | 1 week ago |
10 — Components
Saved
Your changes were recorded.
Action required
A control failed enforcement.
Tip
Use sonner for transient toasts.
11 — Quality bar
Do
py-16 lg:py-20 + top border.Don't
#2d3a2a).text-white / bg-black — break dark mode.12 — Motion library
Copy-paste primitives for loading, progress, and ambient motion. Each element is dependency-free Tailwind + a tiny @keyframes rule you can drop into src/styles.css. All animations honor prefers-reduced-motion.
01 · loader
Pulsing dot
Status indicator for live / streaming / recording states.
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-60 animate-ping" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-emerald-500" />
</span>02 · loader
Spinner ring
Default async spinner. Inherits currentColor.
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.15" strokeWidth="2" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>03 · loader
Three-dot typing
AI / chat / 'someone is typing' affordance.
{/* tailwind config: add 'bounce-dot' keyframe (1.2s ease-in-out infinite) */}
<div className="flex items-end gap-1.5">
{[0, 150, 300].map(d => (
<span key={d} className="h-2 w-2 rounded-full bg-current animate-[bounce-dot_1.2s_ease-in-out_infinite]"
style={{ animationDelay: `${d}ms` }} />
))}
</div>04 · skeleton
Shimmer skeleton
Loading placeholder for cards, rows, and avatars.
<div className="relative h-3 w-full overflow-hidden bg-foreground/10">
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/60 to-transparent
animate-[shimmer_1.8s_ease-in-out_infinite]" />
</div>
/* @keyframes shimmer { 100% { transform: translateX(100%); } } */05 · progress
Indeterminate bar
Top-of-page progress for route changes and uploads.
<div className="relative h-1 w-full overflow-hidden bg-foreground/10">
<div className="absolute inset-y-0 w-1/4 bg-foreground animate-[progress_1.6s_ease-in-out_infinite]" />
</div>
/* @keyframes progress { 0%{transform:translateX(-100%)} 100%{transform:translateX(400%)} } */06 · success
Animated checkmark
Confirmation moment after a successful save / submit.
<svg viewBox="0 0 52 52" className="h-16 w-16 text-emerald-600">
<circle cx="26" cy="26" r="24" fill="none" stroke="currentColor" strokeWidth="2"
strokeDasharray="160" strokeDashoffset="160"
style={{ animation: "draw 0.6s ease-out forwards" }} />
<path d="M14 27 L23 36 L39 18" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
strokeDasharray="50" strokeDashoffset="50"
style={{ animation: "draw 0.4s 0.5s ease-out forwards" }} />
</svg>
/* @keyframes draw { to { stroke-dashoffset: 0; } } */07 · ambient
Floating gradient orb
Decorative hero element. Pair two with mix-blend-multiply.
<div className="relative h-full w-full overflow-hidden">
<div className="absolute left-1/4 top-1/2 h-64 w-64 -translate-y-1/2 rounded-full
bg-emerald-300/60 blur-3xl animate-[blob_7s_ease-in-out_infinite]" />
<div className="absolute right-1/4 top-1/2 h-64 w-64 -translate-y-1/2 rounded-full
bg-amber-200/60 blur-3xl animate-[blob_9s_1s_ease-in-out_infinite]" />
</div>
/* @keyframes blob {
0%,100%{transform:translate(0,0) scale(1)}
33%{transform:translate(30px,-20px) scale(1.1)}
66%{transform:translate(-20px,15px) scale(0.95)} } */08 · scanner
Scanning beam
Document analysis / AI inference visual.
<div className="relative overflow-hidden">
<div className="absolute inset-x-0 h-px bg-emerald-500
shadow-[0_0_12px_2px_rgba(16,185,129,0.7)]
animate-[scan_2s_ease-in-out_infinite]" />
{/* …content… */}
</div>
/* @keyframes scan { 0%,100%{transform:translateY(-100%)} 50%{transform:translateY(2400%)} } */09 · ticker
Infinite marquee
Logo strip, news ticker, status feed.
<div className="w-full overflow-hidden">
<div className="flex w-max gap-8 whitespace-nowrap animate-[marquee_18s_linear_infinite]">
{[...items, ...items].map((t, i) => <span key={i}>{t}</span>)}
</div>
</div>
/* @keyframes marquee { to { transform: translateX(-50%); } } */10 · typewriter
Blinking caret
magpie.audit
Pair with a typewriter hook for terminal-style input.
<span className="inline-block h-[1em] w-[2px] translate-y-0.5 bg-current
animate-[caret_1s_steps(2)_infinite]" />
/* @keyframes caret { 50% { opacity: 0; } } */11 · empty
Floating illustration
Gentle bob for empty-state and onboarding artwork.
<div className="animate-[float_4s_ease-in-out_infinite]">{/* svg */}</div>
/* @keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} } */12 · orbit
Orbiting dot
Loading state for connection / sync flows.
<div className="relative h-20 w-20">
<div className="absolute inset-0 rounded-full border border-foreground/20" />
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2
rounded-full bg-current animate-[orbit_2.4s_linear_infinite]" />
</div>
/* @keyframes orbit {
from { transform: rotate(0) translateX(40px) rotate(0); }
to { transform: rotate(360deg) translateX(40px) rotate(-360deg); } } */13 — State gallery
A catalogue of every dead-end the user can hit. Each card is a self-contained component — illustration, copy, and code — designed in the Magpie editorial style. Drop them into your route's errorComponent / notFoundComponent or render conditionally for empty data.
State · 404
Not found
The URL doesn't match any route. Offer a path home and a search.
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
<h1 className="text-7xl font-medium tracking-tighter">404</h1>
<p className="opacity-70">This page wandered off.</p>
<Link to="/" className="border px-4 py-2">Take me home</Link>
</div>State · 500
Server error
Unhandled exception. Always include a request ID for support.
<div className="mx-auto max-w-md p-8 text-center">
<ServerCrash className="mx-auto h-10 w-10 text-destructive" />
<h1 className="mt-4 text-2xl font-medium">Something broke</h1>
<p className="mt-2 text-sm opacity-70">Request ID: {requestId}</p>
<button onClick={() => router.invalidate()} className="mt-6 border px-4 py-2">
Try again
</button>
</div>State · 503
Unavailable
Planned maintenance. Show ETA when known.
<div className="mx-auto max-w-md p-8 text-center">
<Wrench className="mx-auto h-10 w-10 animate-[wiggle_1.4s_ease-in-out_infinite]" />
<h1 className="mt-4 text-2xl font-medium">Back in a moment</h1>
<p className="mt-2 text-sm opacity-70">
Scheduled maintenance · ETA {eta}
</p>
</div>State · 403
Forbidden
Authenticated but not authorized. Suggest contacting an admin.
<div className="mx-auto max-w-md p-8 text-center">
<ShieldAlert className="mx-auto h-10 w-10" />
<h1 className="mt-4 text-2xl font-medium">Access denied</h1>
<p className="mt-2 text-sm opacity-70">
Ask your workspace admin for the {role} role.
</p>
</div>State · 401
Unauthorized
No active session. Preserve the original URL for post-login redirect.
<Navigate to="/auth" search={{ next: location.pathname }} replace />State · EMPTY
No items
Use for empty lists, dashboards, and notification panels.
{items.length === 0 && (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<Inbox className="h-10 w-10 opacity-60" />
<p className="text-sm opacity-70">Nothing here yet.</p>
</div>
)}State · NO-RES
Search empty
Surface filters that excluded results and offer to clear them.
<div className="py-12 text-center">
<Search className="mx-auto h-8 w-8 opacity-60" />
<p className="mt-3 text-sm opacity-70">
No results for "{query}"
</p>
<button onClick={clearFilters} className="mt-3 text-xs underline">
Clear filters
</button>
</div>State · OFFLINE
No connection
Detect via navigator.onLine. Queue mutations and retry on reconnect.
useEffect(() => {
const onOff = () => setOnline(navigator.onLine);
window.addEventListener("online", onOff);
window.addEventListener("offline", onOff);
return () => {
window.removeEventListener("online", onOff);
window.removeEventListener("offline", onOff);
};
}, []);State · EXP
Session expired
JWT expiry. Modal-style takeover with a sign-in CTA.
<Dialog open={expired}>
<DialogContent>
<Clock className="h-8 w-8" />
<DialogTitle>Session expired</DialogTitle>
<Button onClick={() => signIn()}>Sign in again</Button>
</DialogContent>
</Dialog>State · LOAD
Loading
Use for first-paint loading on dashboards and long lists.
{isLoading ? (
<div className="space-y-2">
{[90,70,80,55].map((w,i) => (
<Skeleton key={i} style={{ width: `${w}%` }} className="h-3" />
))}
</div>
) : <List data={data} />}State · OK
Success
Confirmation screen for multi-step flows and exports.
<div className="flex flex-col items-center gap-3 p-8 text-center">
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
<h2 className="text-xl font-medium">All done</h2>
<p className="text-sm opacity-70">Your report is ready.</p>
</div>State · ERR
Inline error
Form-level / component-level error with retry.
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Couldn't save</AlertTitle>
<AlertDescription>
{error.message}
<button onClick={retry} className="ml-2 underline">Retry</button>
</AlertDescription>
</Alert>State · LOCK
Locked
Paywall / feature flag gate. Plain copy, no scare tactics.
<div className="border-2 border-dashed p-8 text-center">
<Lock className="mx-auto h-8 w-8 opacity-60" />
<h3 className="mt-3 font-medium">Available on Team plan</h3>
<Button onClick={openBilling} className="mt-4">Upgrade</Button>
</div>State · SOON
Coming soon
Use for placeholder routes and gated feature previews.
<div className="flex flex-col items-center gap-3 py-16">
<div className="flex gap-1">
{[0,150,300].map(d => (
<span key={d} className="h-2 w-2 rounded-full bg-current animate-bounce"
style={{ animationDelay: `${d}ms` }} />
))}
</div>
<p className="text-sm opacity-70">Coming soon</p>
</div>State · UPL
No upload
Drop file
First-run state for uploads, imports, and integrations.
<label className="flex h-40 cursor-pointer flex-col items-center justify-center
border-2 border-dashed">
<FileQuestion className="h-8 w-8 opacity-70" />
<p className="mt-2 text-sm">Drop a file or click to browse</p>
<input type="file" className="sr-only" onChange={handleFile} />
</label>