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
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.
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:
oklch(L C H), and explain why Tailwind v4 + shadcn adopted it.--radius.globals.css and a Figma theme.:root and .dark OKLCH values into your kit — and verify both modes.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.
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.
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.
Concept
Four moves, each one feeding the next:
oklch(L C H) — perceptually even color.globals.css + Figma theme.Matching
Match the pairs
From Today's roadmap — match each one to what it actually does. The descriptions have been shuffled.
oklch(L C H) — perceptually even color.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.
Section
Section 1
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.
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.
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.
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.
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 edit | what changes |
|---|---|
| --primary (once, in :root) | button + links + rings, together |
| nothing else | no per-component hunting |
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 edit | what changes |
|---|---|
| --primary (once, in :root) | button + links + rings, together |
| nothing else | no per-component hunting |
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.
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.
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.
Concept
shadcn/ui ships a fixed vocabulary of semantic roles. You'll edit these constantly, so learn what each one colors.
| semantic token | what it colors |
|---|---|
| --background / --foreground | page surface / default text on it |
| --primary | main brand action — buttons, active links |
| --secondary | lower-emphasis buttons / surfaces |
| --muted | subtle backgrounds, disabled areas |
| --accent | hover highlights, subtle emphasis |
| --destructive | delete / danger actions |
| --border / --input / --ring | hairlines / field borders / focus ring |
| --card / --popover | raised surfaces (with their own -foreground) |
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 token | what it colors |
|---|---|
| --background / --foreground | page surface / default text on it |
| --primary | main brand action — buttons, active links |
| --secondary | lower-emphasis buttons / surfaces |
| --muted | subtle backgrounds, disabled areas |
| --accent | hover highlights, subtle emphasis |
| --destructive | delete / danger actions |
| --border / --input / --ring | hairlines / field borders / focus ring |
| --card / --popover | raised surfaces (with their own -foreground) |
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.
| role | Aster surface |
|---|---|
| --background | navbar, page |
| --muted | feature-grid panels |
| --accent | 'new' badge, hovers |
| --destructive | cancel-plan button |
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.
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.
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.
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.
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.
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.
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.
Ranking
Put in order
These are the steps of The retheme recipe, scrambled. Put them back in order before the next slide shows you.
--primary, not a primitive, not a component.:root — plus its -foreground partner if contrast shifts..dark — the same role, tuned for a dark surface.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.
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:
--primary, not a primitive, not a component.:root — plus its -foreground partner if contrast shifts..dark — the same role, tuned for a dark surface.Find the role → edit once → mirror in dark → verify both. If you touched a primitive or a single component, you're off the 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:
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.
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.
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?
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.
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.
| base | its -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.
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.
Section
Section 2
Concept
Every token value in a modern shadcn theme is written in OKLCH: oklch(L C H).
0 (black) to 1 (white).0 (gray) to ~0.4 (vivid).0–360 around the color wheel.Aster's brand primary is oklch(0.55 0.24 285) — mid lightness, high chroma, hue 285 (violet).
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 */| want | turn this knob |
|---|---|
| lighter/darker violet | L (up = lighter) |
| more/less punchy | C (up = more saturated) |
| shift toward blue or magenta | H (down toward blue, up toward magenta) |
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.
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.
| L | role it could fill |
|---|---|
| 0.95 | subtle violet background |
| 0.55 | --primary |
| 0.40 | pressed / active state |
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.
| model | lightness channel is perceptual? |
|---|---|
| hex | no channel — opaque |
| HSL | no — 50% varies wildly by hue |
| OKLCH | yes — L matches the eye |
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.
| value | reads as |
|---|---|
| oklch(1 0 0) | white |
| oklch(0.145 0 0) | near-black |
| oklch(0.55 0.24 285) | vivid violet |
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.
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.
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.
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.
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.
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?
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.
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.
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.
Section
Section 3
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.
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.
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.
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.
Worked example
Aster's design wants slightly tighter corners. Gordon changes one number.
/* before */ --radius: 0.625rem;
/* after */ --radius: 0.4rem;| utility | before (0.625rem) | after (0.4rem) |
|---|---|---|
| rounded-sm | 0.625rem - 4px | 0.4rem - 4px |
| rounded-md | 0.625rem - 2px | 0.4rem - 2px |
| rounded-lg | 0.625rem | 0.4rem |
| rounded-xl | 0.625rem + 4px | 0.4rem + 4px |
Buttons, cards, inputs, the pricing panel — every corner in the kit tightens together, in proportion.
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?
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.
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.
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.
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.
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.
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.
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?
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.
Section
Section 4
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.
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.
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.
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.
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.
Ranking
Put in order
Put the moves of The tweakcn workflow, step by step into the order they have to happen.
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.
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.
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.
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.
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.
| block | job |
|---|---|
| :root | light token values |
| .dark | dark overrides of the same roles |
| @theme inline | expose tokens to Tailwind utilities |
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.
| block | job |
|---|---|
| :root | light token values |
| .dark | dark overrides of the same roles |
| @theme inline | expose tokens to Tailwind utilities |
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 Variable | CSS token | Tailwind |
|---|---|---|
| primary | --primary | bg-primary |
| foreground | --foreground | text-foreground |
| radius | --radius | rounded-lg |
Ranking
Put in order
Put the moves of Verifying the round trip into the order they have to happen.
primary Variableglobals.css, read --primary in :rootWhy: 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.
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.
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.
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.
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.
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.
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.
violet-600 value itself.; You darken --primary to a deep violet and leave --primary-foreground alone.Section
Section 5
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.
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.
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.
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.
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?
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.
Section
Section 6 · Hands-on
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.
| # | milestone | result |
|---|---|---|
| 1 | Pick the primary in tweakcn | buttons/links/rings shift |
| 2 | Set --radius to 0.625rem | every corner updates |
| 3 | Tune the dark-mode variant | dark reads correctly |
| 4 | Export globals.css + import Figma theme | code and Figma agree |
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.
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-check | should be |
|---|---|
| primary buttons | violet |
| active links | violet |
| focus rings | violet |
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-check | should be |
|---|---|
| buttons, cards, inputs | all update together |
| corner proportions | stay consistent across sizes |
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-check | should be |
|---|---|
| dark --primary L | higher than light (0.65 vs 0.55) |
| dark button label | readable on the fill |
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-check | should be |
|---|---|
| globals.css :root primary | oklch(0.55 0.24 285) |
| globals.css .dark primary | oklch(0.65 0.2 285) |
| Figma Variables primary | matches the CSS value |
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-check | should be |
|---|---|
| feature panels | slightly warmer |
| primary buttons | unchanged |
| both modes | verified |
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-check | should be |
|---|---|
| feature panels | slightly warmer |
| primary buttons | unchanged |
| both modes | verified |
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.
| surface | light | dark |
|---|---|---|
| hero button | violet 0.55 | violet 0.65 |
| card corners | 0.625rem scale | 0.625rem scale |
| focus ring | violet | violet |
Comparison
Comparison matrix
From Show it off: refill the dark column from what you know. The rest of the table is as it appeared.
| surface | light | dark |
|---|---|---|
| hero button | violet 0.55 | violet 0.65 |
| card corners | 0.625rem scale | 0.625rem scale |
| focus ring | violet | violet |
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.
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.
Recap
oklch(L C H), and trust L as real perceived lightness.--radius.globals.css (:root/.dark/@theme inline) plus a Figma theme.| move | where |
|---|---|
| change the brand color | --primary in :root and .dark |
| keep text readable on it | the paired -foreground |
| retint the whole corner scale | one --radius |
| design + export the theme | tweakcn → 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.
Want this taught 1-on-1? Alexander tutors shadcn/ui + Figma — $55/session, free consultation.