Session 3: Design Tokens & Theming — the Heart of It

Session 3 of the shadcn/ui and Figma series for Gordon, a designer shipping his Aster studio landing page, and the heart of the system. Tokens come in three layers: primitive palette values feed semantic role variables, which in turn feed light and dark modes, so a whole kit can be retinted by editing a handful of --primary, --muted, and --accent values instead of changing colors component by component. The session covers OKLCH - the oklch(L C H) notation, why it is perceptually uniform, and why Tailwind v4 and shadcn default to it - along with the single --radius that drives the entire corner scale through calc(), and tweakcn.com as a visual theme editor that round-trips to a real globals.css (:root, .dark, and @theme inline) and to a Figma-compatible theme. In the scaffolded your-turn build, Gordon crafts Aster's violet brand theme in tweakcn using oklch(0.55 0.24 285) and a radius of 0.625rem, tunes dark mode, exports globals.css, and imports the Figma theme. There are four checks, three traps, one pattern in the retheme recipe, the continuing Aster example, and real OKLCH values throughout.

Subject: shadcn/ui + Figma · 97 slides · applied lesson

Open the interactive version of this deck · Homework for this lesson

What this lesson covers

The lesson, slide by slide

1. Design Tokens & Theming

Title

Session 3 · The Heart of It

Retheme a whole kit from a handful of semantic variables — and produce a matching globals.css. Today: the three token layers, OKLCH, one --radius, and tweakcn.

2. What you'll be able to do

Objectives

A themeable system isn't a pile of hex codes — it's three layers that let one edit ripple everywhere. By the end of this session you can:

3. What survived from Session 2: Tailwind's System, Made Visual in Figma?

Warm-up

Discussion prompt

Before we open Session 3: Design Tokens & Theming — the Heart of It: without looking back, what was the main idea of Session 2: Tailwind's System, Made Visual in Figma, and what could you do by the end of it that you could not do before?

Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.

Answer:

Session 2 of 6, in 60 slides. It covers utility-first thinking, where styling means composing tokens rather than writing bespoke CSS, and the four scales you live in: spacing on a 4px base, color, type, and radius. Auto Layout is flexbox, with padding, gap, direction, and alignment mapping straight onto p-4, gap-2, and flex-col, so you can read any Auto Layout frame off as Tailwind classes. You then block out Aster's homepage sections on the spacing scale. Traps cover eyeballing pixel values instead of using scale tokens.

4. Why this session is the heart of it

Concept

In Sessions 1–2 you mapped Auto Layout to flexbox and built Aster's components. But right now their colors are hard-coded: the hero button is one violet, a pricing card is another, the footer link is a third. Change the brand and you're hunting through every layer.

Tokens fix that. Every component stops naming a color and starts naming a role — bg-primary, text-foreground, border-border. Retheme once, at the role, and the whole kit follows.

Get this layer right and the rest of shadcn/ui is easy. Get it wrong and you'll fight color drift forever.

5. Break it if you can: Why this session is the heart of it

Counterexample

Discussion prompt

Tokens fix that. Every component stops naming a color and starts naming a role — bg-primary, text-foreground, border-border. Retheme once, at the role, and the whole kit follows.

That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.

Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.

Answer:

Get this layer right and the rest of shadcn/ui is easy. Get it wrong and you'll fight color drift forever.

6. Today's roadmap

Concept

Four moves, each one feeding the next:

Three layers
Primitive → semantic → mode. Edit the middle one.
OKLCH
oklch(L C H) — perceptually even color.
One --radius
One number drives every corner.
tweakcn
Tune, preview, export globals.css + Figma theme.

7. Which is which: Today's roadmap

Matching

Match the pairs

From Today's roadmap — match each one to what it actually does. The descriptions have been shuffled.

  • c1. Three layers
  • c2. OKLCH
  • c3. One --radius
  • c4. tweakcn
  • b1. Primitive → semantic → mode. Edit the middle one.
  • b2. oklch(L C H) — perceptually even color.
  • b3. One number drives every corner.
  • b4. Tune, preview, export globals.css + Figma theme.

Why: Three layers, OKLCH, One --radius, tweakcn are easy to tell apart while they are sitting next to their descriptions and much harder afterwards, which is what this checks.

8. The Three Token Layers

Section

Section 1

9. What a design token is

Concept

A design token is a named value you reference instead of hard-coding. In shadcn/ui these live as CSS custom properties: --primary, --background, --radius. A component says bg-primary, and the value of --primary decides what that actually is.

design token — A named, reusable value (a color, a radius, a spacing) referenced by a stable name rather than a literal. Change the value once and every reference updates.

10. The three layers, top to bottom

Concept

Figure (svg): Token layers stack

Primitive — raw palette values. violet-600 is a fixed OKLCH; it means one exact color and never bends to context.

Semantic — meaningful roles. --primary means the brand action color, whatever that currently is. It points at a primitive.

Mode — the same semantic role gets different values in light :root vs .dark. --primary is one violet in light, a lighter violet in dark.

11. Roles, not paints

Intuition

Think of a theater. Primitives are the paint cans in storage — 'violet-600', 'zinc-50'. Semantic tokens are roles in the script — 'the lead', 'the background'. Mode is which lighting rig is on — matinee or evening.

You don't rewrite the script to recast the lead — you point 'the lead' at a different actor. Retheming is recasting a role, not repainting every prop.

12. By analogy: Roles, not paints

Analogy

Discussion prompt

Explain Roles, not paints by analogy to something with no shadcn/ui + Figma in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.

Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.

Answer:

You don't rewrite the script to recast the lead — you point 'the lead' at a different actor. Retheming is recasting a role, not repainting every prop.

13. One edit, whole kit retints

Worked example

Aster's button, links, and focus rings all reference the role --primary — none of them names a violet directly.

/* semantic layer, in :root */
--primary: oklch(0.55 0.24 285);

/* every component just references the role */
.button   { background: var(--primary); }
.link     { color:      var(--primary); }
.input:focus-visible { outline-color: var(--primary); }

Change that one line and all three retint at once.

you editwhat changes
--primary (once, in :root)button + links + rings, together
nothing elseno per-component hunting

14. Fill in: what changes for One edit, whole kit retints

Comparison

Comparison matrix

From One edit, whole kit retints: refill the what changes column from what you know. The rest of the table is as it appeared.

you editwhat changes
--primary (once, in :root)button + links + rings, together
nothing elseno per-component hunting

15. Something is wrong here: editing a primitive instead of a semantic role

Anomaly

Predict first

A student writes this, and it looks reasonable:

You want Aster's buttons violet, so you go edit the primitive violet-600 value itself.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: violet-600 is a raw palette value — it isn't wired to actions, it's just a shade of violet.

Edit the semantic role --primary — the thing that means 'brand action color'.

Why: violet-600 is a raw palette value — it isn't wired to actions, it's just a shade of violet.

16. Trap: editing a primitive instead of a semantic role

Trap

The trap

You want Aster's buttons violet, so you go edit the primitive violet-600 value itself.

Redefine violet-600 to your brand violet

Why: violet-600 is a raw palette value — it isn't wired to actions, it's just a shade of violet.

Everything that ever used violet-600 shifts

Why: A tag chip, an illustration, a chart series — all move too. And whatever role uses a different primitive doesn't change. The system drifts.

The fix

Edit the semantic role --primary — the thing that means 'brand action color'.

Set --primary: oklch(0.55 0.24 285)

Why: You're changing the role, so exactly the things that play that role move.

Buttons, links, rings retint; unrelated violets stay put

Why: Semantic tokens are the correct seam — edit meaning, not paint.

17. The semantic roles you get

Concept

shadcn/ui ships a fixed vocabulary of semantic roles. You'll edit these constantly, so learn what each one colors.

semantic tokenwhat it colors
--background / --foregroundpage surface / default text on it
--primarymain brand action — buttons, active links
--secondarylower-emphasis buttons / surfaces
--mutedsubtle backgrounds, disabled areas
--accenthover highlights, subtle emphasis
--destructivedelete / danger actions
--border / --input / --ringhairlines / field borders / focus ring
--card / --popoverraised surfaces (with their own -foreground)

18. What each one costs: The semantic roles you get

Trade off

Comparison matrix

From The semantic roles you get: every row here is a choice with a cost. Fill the what it colors column, then say which row you would actually pick and what you give up for it.

semantic tokenwhat it colors
--background / --foregroundpage surface / default text on it
--primarymain brand action — buttons, active links
--secondarylower-emphasis buttons / surfaces
--mutedsubtle backgrounds, disabled areas
--accenthover highlights, subtle emphasis
--destructivedelete / danger actions
--border / --input / --ringhairlines / field borders / focus ring
--card / --popoverraised surfaces (with their own -foreground)

19. Roles beyond --primary

Worked example

Aster's page isn't just brand-violet buttons. Each surface names its own role, so each retints independently.

.navbar     { background: var(--background); }
.feature    { background: var(--muted); }      /* subtle panel */
.badge-new  { background: var(--accent); }     /* hover highlight */
.delete-btn { background: var(--destructive); } /* danger */

Want the feature panels a touch warmer? Edit --muted once — every panel follows, and nothing else moves.

roleAster surface
--backgroundnavbar, page
--mutedfeature-grid panels
--accent'new' badge, hovers
--destructivecancel-plan button

20. Why the middle layer is the seam

Intuition

Primitives are too low to edit — they're shared raw paint, so touching one splatters everywhere. Components are too high — editing one leaves its siblings behind.

The semantic layer is the Goldilocks seam: broad enough that one edit reaches every component playing that role, narrow enough that it touches nothing else. That's why retheming always happens there.

21. Teach it back: Why the middle layer is the seam

Explain it

Discussion prompt

Explain Why the middle layer is the seam to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.

Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.

Answer:

Primitives are too low to edit — they're shared raw paint, so touching one splatters everywhere. Components are too high — editing one leaves its siblings behind.

22. The -foreground pair

Concept

Almost every color role comes in a pair: the surface color and the text-on-it color. --primary is the button fill; --primary-foreground is the label color that sits on that fill.

-foreground token — The paired text/icon color designed to sit on top of its base role. --primary-foreground is chosen for contrast against --primary — it is not decorative, it is the readability half of the pair.

Retint --primary to a dark violet and you must move --primary-foreground to something light, or the label goes unreadable.

23. Take the definitions apart: design token vs -foreground token

Definition probe

Sort into buckets

Every line below is part of the definition of design token or of -foreground token — one or the other, never both. Put each where it belongs.

design token
A named, reusable value (a color, a radius, a spacing) referenced by a stable name rather than a literal.; Change the value once and every reference updates.
-foreground token
The paired text/icon color designed to sit on top of its base role.; --primary-foreground is chosen for contrast against --primary; it is not decorative, it is the readability half of the pair.
b1
A named, reusable value (a color, a radius, a spacing) referenced by a stable name rather than a literal. Change the value once and every reference updates.
b2
The paired text/icon color designed to sit on top of its base role. --primary-foreground is chosen for contrast against --primary — it is not decorative, it is the readability half of the pair.

24. Something is wrong here: treating -foreground as decorative

Anomaly

Predict first

A student writes this, and it looks reasonable:

You darken --primary to a deep violet and leave --primary-foreground alone.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: You assumed -foreground is just 'some accent color' you can ignore.

Move the pair together so contrast holds.

Why: You assumed -foreground is just 'some accent color' you can ignore.

25. Trap: treating -foreground as decorative

Trap

The trap

You darken --primary to a deep violet and leave --primary-foreground alone.

--primary → very dark violet, --primary-foreground still dark

Why: You assumed -foreground is just 'some accent color' you can ignore.

Button label becomes dark-on-dark — unreadable

Why: The pair broke: contrast is the whole job of -foreground, and you left it behind.

The fix

Move the pair together so contrast holds.

--primary dark violet → --primary-foreground light

Why: -foreground is the readability half; it must contrast its base.

Label stays legible on the fill

Why: Think in pairs: surface + text-on-surface, every time.

26. Rebuild the recipe: The retheme recipe

Ranking

Put in order

These are the steps of The retheme recipe, scrambled. Put them back in order before the next slide shows you.

  1. Find the semantic role — 'brand action' → --primary, not a primitive, not a component.
  2. Edit it once in :root — plus its -foreground partner if contrast shifts.
  3. Mirror it in .dark — the same role, tuned for a dark surface.
  4. Let it cascade, then verify light AND dark — every reference to that role should have moved.

Why: This is the order the recipe itself gives. Recalling the sequence without the slide in front of you is the difference between recognising the method and being able to run it — most of what goes wrong in practice is a step done out of turn.

27. The retheme recipe

Pattern

Before your first real retheme, memorize the four moves. Every color, radius, or dark-mode change you make this session runs on this recipe:

  1. Find the semantic role — 'brand action' → --primary, not a primitive, not a component.
  2. Edit it once in :root — plus its -foreground partner if contrast shifts.
  3. Mirror it in .dark — the same role, tuned for a dark surface.
  4. Let it cascade, then verify light AND dark — every reference to that role should have moved.

Find the role → edit once → mirror in dark → verify both. If you touched a primitive or a single component, you're off the recipe.

28. Where does it stop working: The retheme recipe

Edge cases

Discussion prompt

The retheme recipe works on the cases you have just seen. Push it to the edge: what is the most degenerate input it still handles — empty, zero, one item, everything equal — and what is the first case where it stops being true? Name the case, not just "it breaks".

Hint: Try the smallest legal input, then the largest, then the one where two things collide. Methods are specified at their edges; the middle takes care of itself.

Answer:

Before your first real retheme, memorize the four moves. Every color, radius, or dark-mode change you make this session runs on this recipe:

29. Rule out three: Check: which layer do you edit?

Elimination

Eliminate the wrong options

Which token should he edit to retheme the brand action color across the kit?

3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.

  • A. The semantic role --primary
  • B. The primitive violet-600
  • C. The button component's local background color
  • D. Each component's color, one by one

Survives elimination: A

Why: Buttons, links, and rings all reference the role --primary. Editing that one semantic token retints exactly the things that play the brand-action role — and leaves unrelated violets alone.

30. Check: which layer do you edit?

Check

Gordon wants every Aster button, link, and focus ring to become violet in one move, without disturbing an unrelated violet chip.

Check your understanding

Which token should he edit to retheme the brand action color across the kit?

  • A. The semantic role --primary (correct)
  • B. The primitive violet-600
  • C. The button component's local background color
  • D. Each component's color, one by one

Answer: A

Why: Buttons, links, and rings all reference the role --primary. Editing that one semantic token retints exactly the things that play the brand-action role — and leaves unrelated violets alone.

Why B tempts people
A primitive is a raw palette value not wired to a role, so editing it shifts every unrelated use of that shade and misses roles built on other primitives — the system drifts.
Why C tempts people
Editing one component's local color changes only that component; the links and rings stay on the old color, so the kit desyncs.
Why D tempts people
Editing each component defeats the entire point of tokens; it is slow and guarantees you miss one and drift.

31. The full pair list

Concept

You'll set these pairs in :root and .dark. Learn them as pairs and you'll never darken a fill without moving its text.

baseits -foreground
--background--foreground
--primary--primary-foreground
--secondary--secondary-foreground
--muted--muted-foreground
--accent--accent-foreground
--destructive--destructive-foreground
--card--card-foreground
--popover--popover-foreground

--border, --input, and --ring stand alone — they color lines, not text-on-a-fill, so they need no partner.

32. What rests on this: The full pair list

Socratic

Discussion prompt

You'll set these pairs in :root and .dark. Learn them as pairs and you'll never darken a fill without moving its text.

Suppose that were not true. What is the first thing in Session 3: Design Tokens & Theming — the Heart of It that would stop working?

Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.

Answer:

--border, --input, and --ring stand alone — they color lines, not text-on-a-fill, so they need no partner.

33. OKLCH — The Color Model

Section

Section 2

34. Reading oklch(L C H)

Concept

Every token value in a modern shadcn theme is written in OKLCH: oklch(L C H).

Aster's brand primary is oklch(0.55 0.24 285) — mid lightness, high chroma, hue 285 (violet).

35. Aster's primary, decoded

Worked example

Break oklch(0.55 0.24 285) into its three knobs and you can tune each independently.

--primary: oklch(0.55 0.24 285);
/*                L    C    H
   L = 0.55  -> mid lightness (not too dark)
   C = 0.24  -> quite saturated (vivid violet)
   H = 285   -> violet on the hue wheel        */
wantturn this knob
lighter/darker violetL (up = lighter)
more/less punchyC (up = more saturated)
shift toward blue or magentaH (down toward blue, up toward magenta)

36. Why perceptual uniformity matters

Intuition

Figure (svg): Even vs uneven lightness steps

OKLCH is perceptually uniform: equal steps in L look equally different to the eye. Bump L by 0.15 anywhere and the visual jump is the same.

That is exactly what you want for a palette scale and for dark mode: predictable, even contrast. You can trust that a step is a real step, not a mystery.

37. Building Aster's violet scale

Worked example

Hold hue (285) and chroma (~0.24) steady, step L evenly, and you get a clean, predictable ramp — light tints through the brand to deep shades.

oklch(0.95 0.03 285)   /* violet-50-ish, tint  */
oklch(0.75 0.15 285)   /* light               */
oklch(0.55 0.24 285)   /* --primary (Aster)   */
oklch(0.40 0.20 285)   /* deep                */

Because L is perceptual, the visual gap from row to row is even — no muddy mid-tones, no washed-out ends.

Lrole it could fill
0.95subtle violet background
0.55--primary
0.40pressed / active state

38. OKLCH vs hex / HSL

Concept

Hex (#8b7cff) is opaque — you can't eyeball how light it is. HSL has a lightness channel, but it lies: HSL 50% lightness in yellow looks far brighter than 50% in blue. Equal HSL numbers do not mean equal perceived lightness.

OKLCH's L is perceived lightness. That's why Tailwind v4 and shadcn default to it — the defaults ship as oklch(...), and dark-mode contrast stays predictable across hues.

modellightness channel is perceptual?
hexno channel — opaque
HSLno — 50% varies wildly by hue
OKLCHyes — L matches the eye

39. Neutrals: chroma zero

Concept

Aster's --background is pure white and --foreground near-black. In OKLCH those are oklch(1 0 0) and oklch(0.145 0 0) — chroma 0 means no color at all, just lightness.

So a neutral is 'some L, zero C, hue irrelevant'. This is why shadcn's default grays read as oklch(0.985 0 0), oklch(0.145 0 0), and so on — a tidy gray ramp built only from L.

valuereads as
oklch(1 0 0)white
oklch(0.145 0 0)near-black
oklch(0.55 0.24 285)vivid violet

40. Something is wrong here: reading OKLCH L like HSL lightness

Anomaly

Predict first

A student writes this, and it looks reasonable:

You assume oklch(0.5 ...) behaves like HSL 50% and will read the same across hues.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: You're carrying the HSL habit where 50% is just a slider position, not a perceived value.

Trust OKLCH L as perceived lightness — that's the point of the model.

Why: You're carrying the HSL habit where 50% is just a slider position, not a perceived value.

41. Trap: reading OKLCH L like HSL lightness

Trap

The trap

You assume oklch(0.5 ...) behaves like HSL 50% and will read the same across hues.

Set a yellow and a blue both at 'L 0.5' expecting a match

Why: You're carrying the HSL habit where 50% is just a slider position, not a perceived value.

In HSL that would fail — the yellow reads far lighter

Why: HSL lightness is not perceptual, so 'equal' numbers mismatch to the eye.

The fix

Trust OKLCH L as perceived lightness — that's the point of the model.

Two hues at the same OKLCH L actually look equally light

Why: L is perceptually uniform, so equal L means equal apparent lightness.

Build your dark-mode steps by moving L evenly

Why: Even L steps give even contrast — the habit to unlearn is treating L like an arbitrary slider.

42. Break it on purpose: reading OKLCH L like HSL lightness

Break the constraint

Discussion prompt

The rule this trap just fixed:

L is perceptually uniform, so equal L means equal apparent lightness.

Now break it on purpose. Build a case that violates it and follow the consequences until something visibly fails. Where does the failure first show up — and would you have noticed it if you had not been looking?

Hint: The dangerous rules are the ones whose violation still produces an answer. If yours fails loudly, try to find one that fails quietly.

Answer:

You're carrying the HSL habit where 50% is just a slider position, not a perceived value.

43. Answer it before you see the options: Check: tuning an OKLCH knob

Prediction

Predict first

Which single change gives a lighter version of the same violet?

Answer it in your own words, now, with nothing to choose from. The options are on the next slide — and picking the right one off a list is an easier skill than producing it.

Correct: Raise L: oklch(0.68 0.24 285)

Why: L is lightness, so raising it from 0.55 to 0.68 makes the color lighter while keeping chroma and hue fixed — same violet, lighter.

44. Check: tuning an OKLCH knob

Check

Aster's primary is oklch(0.55 0.24 285). Gordon wants the same violet hue, just a bit lighter for a hover state.

Check your understanding

Which single change gives a lighter version of the same violet?

  • A. Raise L: oklch(0.68 0.24 285) (correct)
  • B. Raise H: oklch(0.55 0.24 320)
  • C. Raise C: oklch(0.55 0.34 285)
  • D. Lower L: oklch(0.42 0.24 285)

Answer: A

Why: L is lightness, so raising it from 0.55 to 0.68 makes the color lighter while keeping chroma and hue fixed — same violet, lighter.

Why B tempts people
H is the hue angle; moving it to 320 rotates the color toward magenta rather than lightening the existing violet.
Why C tempts people
C is chroma/saturation; raising it makes the violet more vivid, not lighter.
Why D tempts people
Lowering L makes the violet darker, which is the opposite of what a lighter hover state needs.

45. Why shadcn shipped OKLCH by default

Concept

When you scaffold a fresh shadcn project, the generated globals.css is already full of oklch(...) values — not hex, not HSL. This is deliberate.

Tailwind v4's default palette is authored in OKLCH so its shade ramps (50→950) step evenly, and shadcn inherits that. Your theme edits stay in the same space, so a value you tune reads the same way the framework's own colors do.

46. What rests on this: Why shadcn shipped OKLCH by default

Socratic

Discussion prompt

When you scaffold a fresh shadcn project, the generated globals.css is already full of oklch(...) values — not hex, not HSL. This is deliberate.

Suppose that were not true. What is the first thing in Session 3: Design Tokens & Theming — the Heart of It that would stop working?

Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.

47. One --radius to Rule Them All

Section

Section 3

48. Radius is a token too

Concept

Color isn't the only thing that gets a token. Corner rounding follows the exact same idea: name it once, reference it everywhere, retune from one place.

Everything you learned about semantic color — one edit cascading through the kit — applies to --radius. It's the shape half of the same system.

49. The single radius token

Concept

Corner rounding is a token too. shadcn ships one base variable, --radius, defaulting to 0.625rem. Every rounded utility derives from it.

--radius — The base corner-radius token (default 0.625rem). rounded-sm / md / lg / xl are all computed from it with calc(), so one number sets the whole corner scale.

50. How the scale is built

Worked example

Figure (svg): Radius scale steps

The @theme inline block computes the scale off --radius with calc().

--radius: 0.625rem;

--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);

So rounded-sm, rounded-md, rounded-lg, rounded-xl all slide together when you change the one base.

51. Where does each piece belong: Session 3: Design Tokens & Theming — the…

Sorting

Sort into buckets

These are the pieces of Session 3: Design Tokens & Theming — the Heart of It, out of order. Put each one back under the part of the lesson it belongs to.

The Three Token Layers
What a design token is; The three layers, top to bottom; Roles, not paints
OKLCH — The Color Model
Reading oklch(L C H); Aster's primary, decoded; Why perceptual uniformity matters
One --radius to Rule Them All
Radius is a token too; The single radius token; How the scale is built
s1
The Three Token Layers is where Session 3: Design Tokens & Theming — the Heart of It puts What a design token is, The three layers, top to bottom, Roles, not paints. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s2
OKLCH — The Color Model is where Session 3: Design Tokens & Theming — the Heart of It puts Reading oklch(L C H), Aster's primary, decoded, Why perceptual uniformity matters. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s3
One --radius to Rule Them All is where Session 3: Design Tokens & Theming — the Heart of It puts Radius is a token too, The single radius token, How the scale is built. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.

52. Aster: sharpen every corner at once

Worked example

Aster's design wants slightly tighter corners. Gordon changes one number.

/* before */  --radius: 0.625rem;
/* after  */  --radius: 0.4rem;
utilitybefore (0.625rem)after (0.4rem)
rounded-sm0.625rem - 4px0.4rem - 4px
rounded-md0.625rem - 2px0.4rem - 2px
rounded-lg0.625rem0.4rem
rounded-xl0.625rem + 4px0.4rem + 4px

Buttons, cards, inputs, the pricing panel — every corner in the kit tightens together, in proportion.

53. Watch it run: Aster: sharpen every corner at once

Pattern

Step through it

Step through Aster: sharpen every corner at once one row at a time. What is driving the change, and what would the row after the last one be?

  1. Step 1: utility is rounded-sm
  2. Step 2: utility is rounded-md
  3. Step 3: utility is rounded-lg
  4. Step 4: utility is rounded-xl

54. One dial, a whole set of dials

Intuition

Picture a graphic-EQ where all the sliders are ganged to one master knob. --radius is that master. rounded-sm/md/lg/xl are the ganged sliders, each offset by a fixed ±px via calc().

Turn the master and the whole set moves together, keeping their relative spacing. You never reach for the individual sliders — that's the point of a derived scale.

55. Why offsets, not multiples

Concept

The scale uses ±px offsets (calc(var(--radius) - 4px)), not multiples. That keeps the steps visually tight and predictable at every base value, instead of ballooning apart as --radius grows.

At --radius: 0.625rem (~10px), sm is ~6px and xl is ~14px — a gentle spread. Set the base to a large value and the spread stays a fixed 8px window, so corners never look wildly mismatched.

56. Something is wrong here: setting radius on one component

Anomaly

Predict first

A student writes this, and it looks reasonable:

You want tighter corners, so you hard-code border-radius: 0.4rem on the button only.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: You bypassed the token and set a literal on one component.

Change the token --radius, not one component.

Why: You bypassed the token and set a literal on one component.

57. Trap: setting radius on one component

Trap

The trap

You want tighter corners, so you hard-code border-radius: 0.4rem on the button only.

Override the button's radius directly

Why: You bypassed the token and set a literal on one component.

Cards and inputs still use the old scale

Why: The corner scale desyncs — the button no longer matches the rest, and the next radius change won't touch it.

The fix

Change the token --radius, not one component.

Set --radius: 0.4rem in :root

Why: The whole calc() scale slides, keeping every corner proportional.

Button, card, input all tighten together

Why: One number, one consistent system — never per-component literals.

58. Answer it before you see the options: Check: radius scale

Prediction

Predict first

What happens to rounded-md, and what did he touch?

Answer it in your own words, now, with nothing to choose from. The options are on the next slide — and picking the right one off a list is an easier skill than producing it.

Correct: rounded-md becomes calc(1rem - 2px); he changed only the base token

Why: rounded-md is defined as calc(var(--radius) - 2px), so it re-evaluates against the new base: calc(1rem - 2px). Editing the single --radius token updates the whole scale.

59. Check: radius scale

Check

Given --radius-md: calc(var(--radius) - 2px) and --radius: 0.625rem, Gordon sets --radius to 1rem.

Check your understanding

What happens to rounded-md, and what did he touch?

  • A. rounded-md becomes calc(1rem - 2px); he changed only the base token (correct)
  • B. Nothing changes; --radius-md is fixed at 0.625rem - 2px
  • C. Only rounded-lg changes, because lg equals --radius exactly
  • D. He must edit rounded-sm/md/lg/xl each by hand to update them

Answer: A

Why: rounded-md is defined as calc(var(--radius) - 2px), so it re-evaluates against the new base: calc(1rem - 2px). Editing the single --radius token updates the whole scale.

Why B tempts people
--radius-md is not a frozen value; it is a calc() referencing var(--radius), so it recomputes whenever the base changes.
Why C tempts people
Every step (sm, md, lg, xl) is defined off var(--radius), so all of them shift, not just lg.
Why D tempts people
The scale is derived with calc() precisely so you never hand-edit each step — one base token drives them all.

60. tweakcn: Tune, Export, Round-Trip

Section

Section 4

61. What tweakcn.com is

Concept

tweakcn.com is a free visual theme editor built for shadcn/ui. You tune the semantic colors and --radius with sliders and pickers, preview real components live, and then export.

Two exports matter: a ready-to-paste globals.css (with :root, .dark, and @theme inline) and a Figma-compatible theme you can import into the Variables panel.

That's the whole promise of this session: design the theme visually, ship the exact same tokens to both your code and your Figma file.

62. What rests on this: What tweakcn.com is

Socratic

Discussion prompt

Two exports matter: a ready-to-paste globals.css (with :root, .dark, and @theme inline) and a Figma-compatible theme you can import into the Variables panel.

Suppose that were not true. What is the first thing in Session 3: Design Tokens & Theming — the Heart of It that would stop working?

Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.

63. A mixing desk for your tokens

Intuition

tweakcn is a mixing desk for the token layer. Every channel is a semantic role; every fader is an OKLCH knob; the master is --radius. You never touch the raw tape (primitives) or the individual instruments (components) — you ride the desk.

And 'bounce to disk' is the export: one press writes the exact same mix to two formats — globals.css for code, a theme file for Figma.

64. The round trip

Concept

Figure (svg): tweakcn exports to code and Figma

Tune once in tweakcn → export the globals.css for aster-site and the Figma theme for the design file.

Because both sides come from the same token values, your Figma variables and your CSS variables finally agree. No more 'the design says one violet, the build ships another'.

This is the seam between Sessions 1–2 (Figma) and the coded site — the tokens are the shared contract.

65. Teach it back: The round trip

Explain it

Discussion prompt

Explain The round trip to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.

Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.

Answer:

Tune once in tweakcn → export the globals.css for aster-site and the Figma theme for the design file.

66. What has to happen first: The tweakcn workflow, step by step

Ranking

Put in order

Put the moves of The tweakcn workflow, step by step into the order they have to happen.

  1. Open tweakcn and pick the primary color
  2. Drag the radius slider
  3. Flip to the dark tab and adjust
  4. Click Export

Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. You're editing the semantic role, in OKLCH — the live preview retints buttons, links, and rings at once.

67. The tweakcn workflow, step by step

Worked example

The loop is always the same four steps — tune, preview, tweak, export.

Open tweakcn and pick the primary color

Why: You're editing the semantic role, in OKLCH — the live preview retints buttons, links, and rings at once.

Drag the radius slider

Why: Every corner in the preview updates together — you're moving the one --radius token.

Flip to the dark tab and adjust

Why: Same roles, dark values — you verify both modes before exporting.

Click Export

Why: Out come a globals.css and a Figma-compatible theme carrying identical token values.

68. Work backwards from the answer: The tweakcn workflow, step by step

Reverse engineer

Discussion prompt

Work backwards. The example finished here:

Click Export

What was it asked to do, and what must it have been given? Reconstruct the problem from its answer.

Hint: Every quantity in the result had to enter somewhere. Account for each one.

Answer:

The loop is always the same four steps — tune, preview, tweak, export.

69. Aster's exported globals.css

Worked example

Here's the shape tweakcn exports for Aster — :root for light, .dark for dark, @theme inline to expose the tokens to Tailwind.

@import "tailwindcss";
@custom-variant dark (&:is(.dark *));

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.55 0.24 285);
  --primary-foreground: oklch(0.985 0 0);
}

:root holds the light token values. --primary is Aster's violet; its -foreground is near-white for contrast.

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.65 0.2 285);
  --primary-foreground: oklch(0.145 0 0);
}

.dark overrides the same roles with dark values — note --primary is a lighter violet here so it reads on a dark surface.

70. The @theme inline block

Worked example

:root/.dark hold raw values. @theme inline publishes them to Tailwind so utilities like bg-primary and rounded-lg actually resolve.

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
}

--color-primary: var(--primary) is what makes bg-primary and text-primary work; the radius lines wire up the corner scale.

blockjob
:rootlight token values
.darkdark overrides of the same roles
@theme inlineexpose tokens to Tailwind utilities

71. Fill in: job for The @theme inline block

Comparison

Comparison matrix

From The @theme inline block: refill the job column from what you know. The rest of the table is as it appeared.

blockjob
:rootlight token values
.darkdark overrides of the same roles
@theme inlineexpose tokens to Tailwind utilities

72. Tokens as the Figma↔code contract

Concept

In Figma, the imported theme lands as Variables (a violet swatch literally named primary, a number named radius). In code, the same names live as CSS custom properties. Because tweakcn generated both, they carry identical values.

So a designer's primary variable and a developer's --primary are guaranteed to mean the same violet. The token layer is the shared contract — no more translation drift at handoff.

Figma VariableCSS tokenTailwind
primary--primarybg-primary
foreground--foregroundtext-foreground
radius--radiusrounded-lg

73. What has to happen first: Verifying the round trip

Ranking

Put in order

Put the moves of Verifying the round trip into the order they have to happen.

  1. In Figma, open the primary Variable
  2. In globals.css, read --primary in :root
  3. If they match, the contract holds

Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. It should read Aster's violet — the same color you set in tweakcn.

74. Verifying the round trip

Worked example

After importing, sanity-check that Figma and code agree. Read one value on each side and compare.

In Figma, open the primary Variable

Why: It should read Aster's violet — the same color you set in tweakcn.

In globals.css, read --primary in :root

Why: It should be oklch(0.55 0.24 285) — byte-for-byte the tweakcn export.

If they match, the contract holds

Why: Design and build now reference one source of truth; a future retint updates both from tweakcn.

75. Work backwards from the answer: Verifying the round trip

Reverse engineer

Discussion prompt

Work backwards. The example finished here:

If they match, the contract holds

What was it asked to do, and what must it have been given? Reconstruct the problem from its answer.

Hint: Every quantity in the result had to enter somewhere. Account for each one.

Answer:

After importing, sanity-check that Figma and code agree. Read one value on each side and compare.

76. Something is wrong here: theming light but forgetting .dark

Anomaly

Predict first

A student writes this, and it looks reasonable:

You paste Aster's violet into :root only and call it done.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: Light mode now shows the new brand violet — it looks finished.

Set the role in both :root and .dark.

Why: Light mode now shows the new brand violet — it looks finished.

77. Trap: theming light but forgetting .dark

Trap

The trap

You paste Aster's violet into :root only and call it done.

Set --primary in :root

Why: Light mode now shows the new brand violet — it looks finished.

Toggle to dark mode

Why: .dark still holds the old --primary, so dark mode shows the previous color. Half your users see stale branding.

The fix

Set the role in both :root and .dark.

Set --primary in :root (light) and .dark (a lighter violet)

Why: Both modes now carry the brand, each tuned for its surface.

Verify light AND dark before shipping

Why: A retheme isn't done until both modes are checked.

78. Which of these survive contact with Session 3: Design Tokens & Theming — the…?

Two truths and a lie

Sort into buckets

Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.

Holds up
Get this layer right and the rest of shadcn/ui is easy. Get it wrong and you'll fight color drift forever.; You don't rewrite the script to recast the lead — you point 'the lead' at a different actor. Retheming is recasting a role, not repainting every prop.; Primitives are too low to edit — they're shared raw paint, so touching one splatters everywhere. Components are too high — editing one leaves its siblings behind.
Breaks
You want Aster's buttons violet, so you go edit the primitive violet-600 value itself.; You darken --primary to a deep violet and leave --primary-foreground alone.
sound
These are stated as this lesson states them — each one survives the edge cases Session 3: Design Tokens & Theming — the Heart of It puts it through.
flawed
Each of these is lifted from a trap in this deck: reasonable-sounding, and wrong in a way that only shows up once you rely on it.

79. The Retheme Recipe in Action

Section

Section 5

80. Run the recipe on Aster

Concept

You've met all four token ideas — layers, OKLCH, radius, tweakcn. Now watch the recipe drive a real Aster change end to end: find the role → edit :root → mirror .dark → verify both.

:root { --primary: oklch(0.55 0.24 285); }   /* 1-2: role, light */
.dark { --primary: oklch(0.65 0.2 285); }      /* 3: mirror in dark */
/* 4: preview both — buttons, links, rings all violet */

No component was touched, no primitive was edited — and both modes now wear Aster's brand.

81. By analogy: Run the recipe on Aster

Analogy

Discussion prompt

Explain Run the recipe on Aster by analogy to something with no shadcn/ui + Figma in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.

Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.

Answer:

You've met all four token ideas — layers, OKLCH, radius, tweakcn. Now watch the recipe drive a real Aster change end to end: find the role → edit :root → mirror .dark → verify both.

82. Rule out three: Check: spot the broken retheme

Elimination

Eliminate the wrong options

Which step of the retheme recipe did he skip?

3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.

  • A. Mirror the role in .dark, then verify both modes
  • B. Find the semantic role
  • C. Edit the primitive violet-600 as well
  • D. Set border-radius on each button

Survives elimination: A

Why: He edited --primary in :root (light) but never updated .dark, and he only verified light mode. Dark mode still holds the old --primary, so the recipe's mirror-and-verify-both step was skipped.

83. Check: spot the broken retheme

Check

Gordon set --primary to Aster's violet in :root, previewed light mode, and pushed. QA reports the buttons are still the old color in dark mode.

Check your understanding

Which step of the retheme recipe did he skip?

  • A. Mirror the role in .dark, then verify both modes (correct)
  • B. Find the semantic role
  • C. Edit the primitive violet-600 as well
  • D. Set border-radius on each button

Answer: A

Why: He edited --primary in :root (light) but never updated .dark, and he only verified light mode. Dark mode still holds the old --primary, so the recipe's mirror-and-verify-both step was skipped.

Why B tempts people
He did find the right role — he edited the semantic --primary, not a primitive or a component; the miss was in dark mode.
Why C tempts people
Editing the primitive is off the recipe entirely; retheming happens at the semantic role, not by touching palette primitives.
Why D tempts people
Radius is unrelated to a color-in-dark-mode bug, and per-component literals are exactly what the recipe forbids.

84. Your Turn: Theme Aster

Section

Section 6 · Hands-on

85. The build: Aster's brand theme

Concept

You'll craft Aster's theme in tweakcn and apply it back in Figma. Do each milestone in tweakcn, watch the live preview, then read the self-check before moving on.

#milestoneresult
1Pick the primary in tweakcnbuttons/links/rings shift
2Set --radius to 0.625remevery corner updates
3Tune the dark-mode variantdark reads correctly
4Export globals.css + import Figma themecode and Figma agree

86. Break it if you can: The build: Aster's brand theme

Counterexample

Discussion prompt

You'll craft Aster's theme in tweakcn and apply it back in Figma. Do each milestone in tweakcn, watch the live preview, then read the self-check before moving on.

That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.

Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.

87. Milestone 1 — pick the primary

Worked example

Your turn: in tweakcn, set the primary semantic color to Aster's brand violet. Say out loud which components should move when you do.

Hint: you're setting the semantic primary role (not a primitive), in OKLCH. Mid lightness, high chroma, hue ~285.

--primary: oklch(0.55 0.24 285);
self-checkshould be
primary buttonsviolet
active linksviolet
focus ringsviolet

88. Milestone 2 — set the radius

Worked example

Your turn: set --radius to Aster's base 0.625rem and watch the corners. Predict which elements change before you slide it.

Hint: one token drives the whole scale — you should not be touching individual components.

--radius: 0.625rem;
/* rounded-sm/md/lg/xl all follow via calc() */
self-checkshould be
buttons, cards, inputsall update together
corner proportionsstay consistent across sizes

89. Milestone 3 — tune the dark variant

Worked example

Your turn: switch tweakcn to the dark theme and tune --primary so it reads on a dark surface. Should dark --primary be lighter or darker than light mode's?

Hint: on a dark background you usually raise L (and often ease C) so the violet stays visible — same role, different value in .dark.

.dark {
  --primary: oklch(0.65 0.2 285);
  --primary-foreground: oklch(0.145 0 0);
}
self-checkshould be
dark --primary Lhigher than light (0.65 vs 0.55)
dark button labelreadable on the fill

90. Milestone 4 — export and import

Worked example

Your turn: export the globals.css from tweakcn, paste the :root and .dark OKLCH values into aster-site/app/globals.css, then import the Figma theme into the Variables panel.

Hint: paste the token values into :root/.dark; the @theme inline block should already wire them to Tailwind. In Figma, import the exported theme so the Variables panel matches.

:root  { --primary: oklch(0.55 0.24 285); }
.dark  { --primary: oklch(0.65 0.2 285); }
/* Figma Variables panel: import the exported theme */
self-checkshould be
globals.css :root primaryoklch(0.55 0.24 285)
globals.css .dark primaryoklch(0.65 0.2 285)
Figma Variables primarymatches the CSS value

91. Milestone 5 — tune a secondary role

Worked example

Your turn: Aster's feature-grid panels feel too stark. In tweakcn, warm up --muted slightly in both modes. Which panels should shift, and which shouldn't?

Hint: nudge --muted's hue toward warm and check the dark tab too — only surfaces that reference --muted should move.

:root { --muted: oklch(0.97 0.01 285); }
.dark  { --muted: oklch(0.27 0.01 285); }
self-checkshould be
feature panelsslightly warmer
primary buttonsunchanged
both modesverified

92. What each one costs: Milestone 5 — tune a secondary role

Trade off

Comparison matrix

From Milestone 5 — tune a secondary role: every row here is a choice with a cost. Fill the should be column, then say which row you would actually pick and what you give up for it.

self-checkshould be
feature panelsslightly warmer
primary buttonsunchanged
both modesverified

93. Show it off

Worked example

Preview Aster's navbar, hero, feature grid, pricing, and footer in both modes. Every violet, every corner, both themes — all flowing from your handful of semantic tokens.

If the light hero and the dark hero both wear Aster's brand, and the corners match across every card — you rethemed a whole kit from semantic variables, and your code and Figma agree.

surfacelightdark
hero buttonviolet 0.55violet 0.65
card corners0.625rem scale0.625rem scale
focus ringvioletviolet

94. Fill in: dark for Show it off

Comparison

Comparison matrix

From Show it off: refill the dark column from what you know. The rest of the table is as it appeared.

surfacelightdark
hero buttonviolet 0.55violet 0.65
card corners0.625rem scale0.625rem scale
focus ringvioletviolet

95. Homework

Concept

Lock in Aster's palette and radius. Finalize --primary, its -foreground, and the other semantic roles in tweakcn for both light and dark, and settle --radius.

Export the OKLCH values for Session 6. Save the exact :root and .dark OKLCH numbers — Session 6 (handoff & code) will drop them straight into the coded aster-site.

96. Connect it up: Session 3: Design Tokens & Theming — the Heart of It

Connect it up

Draw it

One page, no notation unless you need it: draw how these connect — The Three Token Layers · OKLCH — The Color Model · One --radius to Rule Them All · tweakcn: Tune, Export, Round-Trip · The Retheme Recipe in Action · Your Turn: Theme Aster. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.

97. What you can do now

Recap

movewhere
change the brand color--primary in :root and .dark
keep text readable on itthe paired -foreground
retint the whole corner scaleone --radius
design + export the themetweakcn → globals.css + Figma

The recipe: find the semantic role → edit once in :root → mirror in .dark → verify light AND dark. Never a primitive, never a single component.

Next session: components, blocks, and pages — assembling Aster's sections from the themed pieces you just built.

Sources

  1. shadcn/ui — Theming (CSS variables, :root/.dark, @theme inline)
  2. Tailwind CSS v4 — Colors & the oklch() color space
  3. tweakcn — visual theme editor for shadcn/ui
  4. MDN — oklch() CSS color function
  5. Aster theme values verified against a fresh npx shadcn init globals.css and tweakcn export. — Author verification run, 2026-07-03 (shadcn/ui + Figma series, Session 3).

Want this taught 1-on-1? Alexander tutors shadcn/ui + Figma — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108