The Master UI/UX Playbook
A single reference for building interfaces that look designed, not defaulted — a portable, token-driven system distilled from auditing several production frontends.
16 min readThe whole thesis in one line: ~90% of the "premium" feel comes from one file — the design tokens — not from the components. The components are mostly stock; what makes them look custom is the tokens they read, and the discipline of always composing with tokens instead of hard-coded values. This playbook distils that approach from a handful of real systems (a Tailwind v4 + shadcn/ui dashboard, an enterprise Angular app, and a warm editorial Next.js product) into one reusable method, with shadcn/ui and GSAP bolted on.
0. How to use this doc
- Building a new screen in an existing app? Read §4 (composition rules) + §8 (checklist).
- Starting a new app? Read §2 (stack) → copy the token block in §10 → re-tune one hue.
- Want a different feel (corporate vs warm vs neutral)? Swap only the Layer-1 tokens per §7. Never touch components.
- Adding motion? §6 (GSAP). Adding components? §5 (shadcn).
The one sentence: 90% of the "premium" feel comes from one file — globals.css (the tokens) — not from the components. The components are ~95% stock shadcn; what makes them look custom is the tokens they read and the discipline of always composing with tokens instead of hard-coded values.
1. The core thesis — it's a system, not talent
Good-looking UI is consistency + good defaults, applied as three layers you never mix:
LAYER 3 — Composition (pages/features) ← your "taste" lives here
spacing rhythm, hierarchy, when to use a card/badge/empty-state
LAYER 2 — Components (components/ui, shadcn) ← rarely touched
Button, Card, Badge, Dialog… read tokens via variants
LAYER 1 — Tokens (globals.css :root + @theme)← tune ONCE per app
color, radius, shadow, font, spacing, tracking
The golden rule: components only ever reference Layer-1 tokens (bg-primary, text-muted-foreground, rounded-lg, shadow-sm). They almost never hard-code a hex value or a pixel radius. That's why re-theming = editing one file, and why nothing ever looks "off-brand." When you build a new app, re-tune Layer 1 and reuse Layers 2 & 3 verbatim.
2. The stack (and what each piece buys you)
| Tool | What it actually buys you |
|---|---|
| Tailwind CSS v4 | Utility engine. v4 is CSS-first — the theme lives in @theme inside globals.css, no tailwind.config.js needed. |
shadcn/ui (new-york style) |
Copy-paste components you own (in src/components/ui). Not an npm dep → freely themeable. new-york is more compact/refined than default. |
| Radix UI | Headless, accessible primitives shadcn wraps (dialogs, dropdowns, popovers). Keyboard nav, focus traps, ARIA for free. |
| OKLCH color | Author all colors in oklch(), not hex/hsl. Perceptually uniform → tints/shades/dark-mode stay balanced. The single biggest "designer" tell. |
| tweakcn-style token set | The token block matches the output of tweakcn.com, a shadcn theme generator. The "secret weapon." |
| tw-animate-css | Drop-in animate-in, fade-in, zoom-in, slide-in-from-* utilities. Most animation is CSS, not JS. |
| GSAP (+ Lenis) | The heavy-motion engine for scroll-driven reveals, timelines, smooth scroll. See §6. (framer-motion works too; this playbook standardises on GSAP.) |
| lucide-react | One consistent icon family — consistency is itself a polish signal. |
| class-variance-authority (cva) | Defines component variants (variant/size) as data, not ad-hoc classes. |
clsx + tailwind-merge (cn()) |
Merges classes and resolves Tailwind conflicts so overrides always win cleanly. |
| next-themes | Dark mode via a .dark class + prefers-color-scheme. |
| sonner | Toasts. |
Display font via next/font |
A real typeface (see §3.4). |
Takeaway: the differentiators are Tailwind v4 + shadcn + OKLCH tokens + tinted shadows + a real font. Everything else is supporting cast.
3. The token decisions that create the "premium" feel
Each maps to a named, researched principle — it's not magic.
3.1 Color in OKLCH, not hex — perceptual uniformity
--primary: oklch(0.4865 0.2423 291.8661); /* a vivid violet */
OKLCH = Lightness, Chroma, Hue. Equal lightness numbers look equally bright across hues, so generated tints/shades and dark-mode variants stay balanced instead of muddy.
- Apply it: author colors in OKLCH; use oklch.com / tweakcn instead of guessing hex.
- Example:
--c-forest: 0.3334 0.0398 172.40,--c-clay: 0.7222 0.1632 53.38, etc.
3.2 Brand-tinted shadows — the biggest single upgrade
--shadow-color: hsl(263 70% 50%); /* the brand hue, NOT black */
--shadow-sm: 0px 8px 30px 0px hsl(263 70% 50% / 0.08), 0px 1px 2px -1px hsl(263 70% 50% / 0.08);
Default shadows are black at high opacity → "cheap drop shadow." Here shadows are the brand hue at very low opacity (0.04–0.08) with a large ~30px blur. Cards look lit by the brand color and float softly.
- Principle: "long, soft, low-opacity, colour-matched shadows" (Refactoring UI).
- Example:
--shadow-card: 0 1px 2px rgb(31 61 52 / .05), 0 6px 16px rgb(31 61 52 / .07)(forest-tinted).
3.3 Generous, layered radius — softness
--radius: 1rem; /* 16px — default shadcn is 0.5rem/8px */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
A larger base radius reads modern/friendly; deriving the rest from one --radius keeps every corner in proportion. One knob rounds the whole app (e.g. --radius: 1rem).
3.4 Tight letter-spacing + a real typeface — typographic polish
--tracking-normal: -0.015em; /* slight negative tracking everywhere */
--font-sans: "Plus Jakarta Sans", Inter, system-ui, sans-serif;
body { letter-spacing: var(--tracking-normal); }
Negative tracking (−0.01 to −0.02em) makes headings look crafted, not default-browser. Pair a geometric-humanist sans over system-ui.
- ⚠️ The gap to watch: a classic bug —
globals.cssasks for a display font (say Plus Jakarta Sans) butnext/fontonly ever loaded Inter, so it silently falls back and nobody notices. Always load the real display font vianext/fontand confirm it's actually applied.
3.5 Semantic colour roles — this is why nothing clashes
The palette is tiny and named by role, not by colour:
background / foreground / card / popover / primary / secondary /
muted / accent / destructive / border / input / ring + sidebar-* + chart-1..5
Each has a -foreground pair guaranteeing readable contrast. You never think "what blue?" — you think "is this muted text or foreground text?"
- Principle: semantic design tokens + the 60/30/10 rule (lots of
background/muted, somesecondary/card, a littleprimaryaccent). One accent CTA per view; everything else neutral.
3.6 Transparency via color-mix, not new colours
background: color-mix(in oklch, var(--primary) 15%, transparent);
Hover/active/selected states are an existing token mixed with transparent — states stay on-palette. Nobody ever invents #a-slightly-lighter-purple.
3.7 Dark mode is complete, not negotiable
Every token has a .dark value (including different, stronger shadows). Because components use roles, dark mode "just works" — define a full .dark token set once and every screen inherits it.
4. Composition rules (Layer 3 — the repeatable "taste")
Copy the rules, not the pixels. These recur on every page.
- One page-header pattern, everywhere.
<h1 class="text-2xl font-bold tracking-tight">Candidates</h1>tracking-tighton every heading. Bold + tight = crisp. - Spacing on a grid.
--spacing: 0.25rem(4px). Gapsgap-2 / gap-4 / gap-6, card paddingp-6. Everything lands on a 4/8px rhythm → visual order without thinking. (8-point grid.) - Cards are the default container.
rounded-xl border py-6 shadow-sm, content paddedpx-6, internalflex flex-col gap-6. Consistent inner rhythm = dense screens still feel calm. - Restrained, subtle gradients only. Header washes
from-primary/10 to-transparent; a thin accent barh-0.5 bg-gradient-to-r from-primary/40 via-primary to-primary/40. Gradients are seasoning, never a full loud fill. - Animation is cheap and CSS-first.
animate-in fade-in zoom-in-95for enters;transitiononcolor/box-shadowfor hovers. Reach for JS motion (GSAP) only for the genuinely complex 1–2 things. Fast (100–200ms), subtle, never blocking. - First-class empty / loading / error states. A dedicated
Emptycomponent (icon-in-a-muted-square + title + description + action), plusSkeletonandSpinner. Designed empty states are a huge perceived-quality lever most apps skip. - Variants over one-off classes. Need a destructive button?
<Button variant="destructive" size="sm">. New visual states get added to the cva variant table, not sprinkled inline — this stops entropy as the app grows. - Accessibility is built in, so it also looks right. Every interactive element has
focus-visible:ring-ring/50 ring-[3px]andaria-invalidstyling from the shadcn primitives. Good focus rings read as "polished," not just "accessible." - Dark mode parity on every new screen (it's free if you used roles).
5. Skill: shadcn/ui
shadcn is not a component library you install — it's components you copy into your repo and own, themed entirely by your Layer-1 tokens.
Setup (new app):
npx shadcn@latest init # choose: new-york style, slate base, CSS variables = yes
npx shadcn@latest add button card badge dialog input select dropdown-menu \
table tabs sonner skeleton empty
- Components land in
src/components/ui/*— you edit them freely; updates are opt-in, not forced. new-yorkstyle = tighter, more refined defaults than the original.
How theming works: every component is written against role tokens (bg-primary, text-muted-foreground, border-border, rounded-md, shadow-sm). Change a token in globals.css → every component updates. Never hard-code colors inside a component.
Variants via cva (the pattern that prevents class soup):
const button = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors " +
"focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
size: { sm: "h-8 px-3", md: "h-9 px-4", lg: "h-10 px-6" },
},
defaultVariants: { variant: "default", size: "md" },
},
);
cn()=twMerge(clsx(...))— merge consumer classes so overrides win without conflicts.- Radix underneath gives you keyboard nav, focus management, and ARIA for dialogs/menus/popovers for free.
Already on Tailwind v3 with a hand-rolled
ui/layer? Keep the same role-token discipline. To adopt shadcn proper, migrate to Tailwind v4@themeand port your OKLCH tokens into the shadcn role names (§7).
6. Skill: GSAP (motion)
The motion hierarchy — reach for the cheapest tool first:
- CSS /
tw-animate-css— enters, hovers, simple state changes. ~90% of motion. - GSAP — scroll-driven reveals, sequenced timelines, smooth scroll, anything choreographed.
- (framer-motion — fine too, but this playbook standardises on GSAP.)
Install:
npm i gsap @studio-freight/lenis # or 'lenis' (new package name)
Smooth scroll (Lenis) + ScrollTrigger sync — set up once, app-wide:
"use client";
import { useEffect } from "react";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
export function SmoothScroll() {
useEffect(() => {
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ duration: 1.1, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update);
const raf = (t: number) => { lenis.raf(t * 1000); };
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);
return () => { gsap.ticker.remove(raf); lenis.destroy(); };
}, []);
return null;
}
The reveal-on-scroll pattern (the workhorse — fade/slide content in as it enters):
useGSAP(() => { // @gsap/react useGSAP scopes + auto-cleans
gsap.from("[data-reveal]", {
y: 24, opacity: 0, duration: 0.7, ease: "power3.out", stagger: 0.08,
scrollTrigger: { trigger: "[data-reveal]", start: "top 85%" },
});
}, { scope: containerRef });
Rules that keep GSAP tasteful (not 2014-startup):
- Subtle distances (16–32px), short durations (0.5–0.8s),
power2/3.outeasing. No bounce, no spin. - Always honour reduced motion:
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => { /* animations here */ }); - Animate transforms + opacity only (GPU-friendly); avoid animating layout props (width/top/margin).
- Scope + clean up (
useGSAP/gsap.context) so React re-renders don't stack tweens. - Use GSAP for: hero entrances, scroll-pinned sections, number count-ups, staggered card grids, page transitions. Use CSS for: button hovers, dropdown opens, skeleton shimmer.
Rule of thumb: the public/marketing surface is the right place for GSAP + Lenis (hero, story sections). Portals and dashboards should stay CSS-first — fast, calm, non-blocking.
7. The three brand "moods" — swappable Layer-1 token sets
Same components, same composition rules — only the tokens change. Pick a mood per surface.
| Lever | Warm / Editorial | Corporate / Enterprise | Neutral / Product |
|---|---|---|---|
| Primary hue | Forest green + clay/terracotta accent | Navy #142239 + cobalt #2369d8 |
Slate + one accent (violet/blue/red) |
| Palette temp | Warm (sand/clay/gold) | Cool (navy/grey/blue) | Cool-neutral (slate) |
| Display font | Newsreader serif + Plus Jakarta Sans | Manrope + DM Sans (geometric sans) | Inter (one family, 900 for display) |
| Headings | Serif, larger, airier | Sans, bold, tight tracking, uppercase eyebrows | Sans, bold, tracking-tight |
| Radius | 1rem (soft) | 7–12px (crisp) | 0.5–1rem |
| Density | Airy, marketing-grade whitespace | Dense data tables, tight padding | Balanced, data-dense dashboard |
| Shadows | Forest-tinted, soft | Cool-tinted, hairline | Brand-tinted, soft |
| Feel | Human, premium, calm | Operational, authoritative | Clean, modern SaaS |
Applying it: pick the mood per surface, not per app. A human, marketing-led brand can keep the warm/editorial mood on its public pages while giving the admin/dashboard route group a corporate mood (cooler hues, smaller radius, denser tables) — because it's a token swap on a route group, not a rewrite.
8. The 12-point "is my UI boring?" checklist
Run against any screen. Each "no" is a fix.
- Colors authored in OKLCH and referenced by role (
bg-primary,text-muted-foreground)? - Shadows tinted with the brand hue, low opacity, large blur — not default black?
- One
--radiuswith the rest derived from it? - Headings have
tracking-tightand a real display font loaded vianext/font? - Spacing on a 4/8px grid (
gap-2/4/6,p-6)? - Primary used sparingly (≈10% — one CTA per view) over mostly neutral surfaces?
- Hover/focus/selected states use
color-mix/opacity of existing tokens, not new colors? - Every interactive element has a visible focus ring?
- Designed empty, loading (skeleton), and error states?
- Dark mode fully defined (every role has a
.darkvalue)? - Animations CSS-first, fast (100–200ms), subtle — JS/GSAP only where truly needed?
- New visual states added as cva variants, not one-off inline classes?
9. The named principles (so it's researched, not vibes)
- Refactoring UI (Wathan & Schoger) — the direct source of: colored/layered shadows, "lots of greys + one accent," spacing scale, depth via overlap, designing empty states first. Read this one first — it maps almost 1:1.
- 8-point grid — all spacing a multiple of 4/8px.
- Type scale & optical tracking — modular heading sizes; tighten tracking as size grows.
- 60/30/10 colour rule — dominant neutral / secondary / small accent.
- Semantic design tokens (W3C) — name by role (
primary,muted-foreground), not value. - OKLCH / perceptual color (CSS Color 4) — uniform lightness for sane tints & dark mode.
- WCAG contrast — the
*-foregroundpairing guarantees text-on-surface contrast. - Material / Apple HIG elevation — shadow size encodes hierarchy (resting card vs popover vs modal).
- Tools: tweakcn.com (generate the token block visually), ui.shadcn.com/themes, oklch.com, Realtime Colors.
10. Copy-paste starter (drop into any new app)
npx shadcn@latest init→ new-york style, slate base, CSS variables = yes.- Replace the generated
:root/.dark/@themeinglobals.csswith a tuned token block (re-skin by changing one hue number everywhere, or regenerate on tweakcn.com). - Load your display font via
next/fontand set--font-sans(don't repeat the §3.4 gap). npx shadcn@latest add button card badge dialog … empty skeleton.- Build pages using the §4 rules. Never hard-code a color/radius/shadow again.
The tuned token block (change the --primary hue to re-brand the whole app):
/* globals.css */
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 1rem; /* §3.3 one knob for roundness */
--background: oklch(0.9838 0.0035 247.8583);
--foreground: oklch(0.1284 0.0267 261.5937);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1284 0.0267 261.5937);
--primary: oklch(0.4865 0.2423 291.8661); /* ← change THIS hue to re-brand */
--primary-foreground: oklch(0.9838 0.0035 247.8583);
--secondary: oklch(0.9486 0.0085 303.5068);
--secondary-foreground: oklch(0.3410 0.1625 292.9477);
--muted: oklch(0.9679 0.0027 264.5424);
--muted-foreground: oklch(0.5503 0.0235 264.3620);
--accent: oklch(0.9546 0.0227 303.2883);
--accent-foreground: oklch(0.4865 0.2423 291.8661);
--destructive: oklch(0.6356 0.2082 25.3782);
--border: oklch(0.9278 0.0058 264.5314);
--input: oklch(0.9278 0.0058 264.5314);
--ring: oklch(0.4865 0.2423 291.8661);
/* §3.2 brand-tinted, soft, low-opacity shadows */
--shadow-color: 263 70% 50%;
--shadow-sm: 0px 8px 30px 0px hsl(var(--shadow-color) / 0.08), 0px 1px 2px -1px hsl(var(--shadow-color) / 0.08);
--shadow: 0px 8px 30px 0px hsl(var(--shadow-color) / 0.08), 0px 1px 2px -1px hsl(var(--shadow-color) / 0.08);
--shadow-md: 0px 8px 30px 0px hsl(var(--shadow-color) / 0.08), 0px 2px 4px -1px hsl(var(--shadow-color) / 0.08);
--shadow-lg: 0px 8px 30px 0px hsl(var(--shadow-color) / 0.08), 0px 4px 6px -1px hsl(var(--shadow-color) / 0.08);
--tracking-normal: -0.015em; /* §3.4 tight tracking */
--font-sans: "Plus Jakarta Sans", Inter, system-ui, sans-serif;
}
.dark {
--background: oklch(0.1091 0.0091 301.6956);
--foreground: oklch(0.9838 0.0035 247.8583);
--card: oklch(0.1376 0.0118 301.0607);
--primary: oklch(0.6083 0.2172 297.1153); /* lighter primary in dark */
--shadow-color: 0 0% 0%; /* dark shadows go black + deeper */
--shadow-lg: 0px 20px 40px -10px hsl(var(--shadow-color) / 0.60), 0px 4px 6px -11px hsl(var(--shadow-color) / 0.60);
/* …mirror every role; tweakcn generates this half for you */
}
@theme inline { /* expose tokens to Tailwind utilities */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
/* …one line per role… */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-sans);
}
@layer base {
* { @apply border-border outline-ring/50; }
body { @apply bg-background text-foreground font-sans; letter-spacing: var(--tracking-normal); }
}
11. Adopting this in an existing codebase
If a project is already partway there — OKLCH colors, tinted shadows, one radius, a real font, a full .dark set — the highest-leverage upgrades are usually:
- Role-naming. If tokens are named by color (
--c-forest,--c-clay) rather than role (--primary,--muted-foreground), add a thin role layer (--primary: var(--c-forest)). Stock shadcn components then drop in unchanged, and you can swap moods per §7. - Tailwind v3 → v4. Moving the theme into
@theme(notailwind.config.ts) unlocks the shadcnnew-yorkecosystem directly. - A second mood for dense surfaces. Per §7, give the admin/dashboard route group a cooler, denser token override (navy/cobalt, smaller radius, tighter table padding) while the public surface keeps its warm/editorial mood — a token swap, not a rewrite.
- Empty / skeleton / error states. Audit screens for designed empty + loading states (§4.6) — the cheapest perceived-quality win.
- Scope your motion. Keep GSAP/Lenis on the marketing surface; keep portals and dashboards CSS-first.
TL;DR
It's a system, not a person's talent. The four upgrades that turn "boring" into "premium" fastest: (1) OKLCH semantic tokens, (2) brand-tinted soft shadows, (3) bigger radius + tight tracking + a real font, (4) designed empty/loading states. The look is portable: copy the token block + the shadcn ui/ folder, re-tune one hue, and Layers 2 & 3 come along unchanged.

