Loading
Loading
Neville James Achieng logo
All articlesDesign Systems

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 read

The 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.css asks for a display font (say Plus Jakarta Sans) but next/font only ever loaded Inter, so it silently falls back and nobody notices. Always load the real display font via next/font and confirm it's actually applied.

3.5 Semantic colour rolesthis 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, some secondary/card, a little primary accent). 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.

  1. One page-header pattern, everywhere.
    <h1 class="text-2xl font-bold tracking-tight">Candidates</h1>
    
    tracking-tight on every heading. Bold + tight = crisp.
  2. Spacing on a grid. --spacing: 0.25rem (4px). Gaps gap-2 / gap-4 / gap-6, card padding p-6. Everything lands on a 4/8px rhythm → visual order without thinking. (8-point grid.)
  3. Cards are the default container. rounded-xl border py-6 shadow-sm, content padded px-6, internal flex flex-col gap-6. Consistent inner rhythm = dense screens still feel calm.
  4. Restrained, subtle gradients only. Header washes from-primary/10 to-transparent; a thin accent bar h-0.5 bg-gradient-to-r from-primary/40 via-primary to-primary/40. Gradients are seasoning, never a full loud fill.
  5. Animation is cheap and CSS-first. animate-in fade-in zoom-in-95 for enters; transition on color/box-shadow for hovers. Reach for JS motion (GSAP) only for the genuinely complex 1–2 things. Fast (100–200ms), subtle, never blocking.
  6. First-class empty / loading / error states. A dedicated Empty component (icon-in-a-muted-square + title + description + action), plus Skeleton and Spinner. Designed empty states are a huge perceived-quality lever most apps skip.
  7. 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.
  8. Accessibility is built in, so it also looks right. Every interactive element has focus-visible:ring-ring/50 ring-[3px] and aria-invalid styling from the shadcn primitives. Good focus rings read as "polished," not just "accessible."
  9. 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-york style = 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 @theme and port your OKLCH tokens into the shadcn role names (§7).


6. Skill: GSAP (motion)

The motion hierarchy — reach for the cheapest tool first:

  1. CSS / tw-animate-css — enters, hovers, simple state changes. ~90% of motion.
  2. GSAP — scroll-driven reveals, sequenced timelines, smooth scroll, anything choreographed.
  3. (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.out easing. 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 --radius with the rest derived from it?
  • Headings have tracking-tight and a real display font loaded via next/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 .dark value)?
  • 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 *-foreground pairing 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)

  1. npx shadcn@latest initnew-york style, slate base, CSS variables = yes.
  2. Replace the generated :root / .dark / @theme in globals.css with a tuned token block (re-skin by changing one hue number everywhere, or regenerate on tweakcn.com).
  3. Load your display font via next/font and set --font-sans (don't repeat the §3.4 gap).
  4. npx shadcn@latest add button card badge dialog … empty skeleton.
  5. 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:

  1. 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.
  2. Tailwind v3 → v4. Moving the theme into @theme (no tailwind.config.ts) unlocks the shadcn new-york ecosystem directly.
  3. 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.
  4. Empty / skeleton / error states. Audit screens for designed empty + loading states (§4.6) — the cheapest perceived-quality win.
  5. 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.