The capstone session of the shadcn/ui and Figma series. Gordon scaffolds a real shadcn project with npx shadcn init and add, learns where the theme lives in code - globals.css, with :root, .dark, and @theme inline - and pastes in his tweakcn OKLCH values. The session then compares the three handoff paths honestly: Dev Mode with Code Connect, Figma-to-code plugins and AI, and manual mapping. Finally he reads a component's source, the cva variants in button.tsx, so that he can customize it, because he owns the file. It ends with a fully scaffolded your-turn build that renders the Aster landing page in light and dark, and with the series capstone.
Subject: shadcn/ui + Figma · 97 slides · applied lesson
Open the interactive version of this deck · Homework for this lesson
Title
Session 6 · Capstone
Scaffold a real shadcn project, drop your Figma theme into globals.css, and read the component source well enough to make it your own.
Objectives
Five sessions in, you have a Figma design of the Aster landing page built on the shadcn kit with your own OKLCH tokens. Today you turn it into a running project. By the end you can:
npx shadcn@latest init and add pieces with add.globals.css, :root/.dark, and @theme inline.bg-primary retints everything.button.tsx), find the cva variants, and edit one because you own the file.Warm-up
Discussion prompt
Before we open Session 6: Design-to-Code Handoff: without looking back, what was the main idea of Session 5: Composition — Responsive Layouts, Blocks & Pages, 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 5 of 6 (60 slides): components → blocks → pages, responsive design in Figma (breakpoints, constraints, min/max width, wrapping Auto Layout), building the usual suspects (navbar, hero, feature grid, pricing, footer), and holding spacing + type consistent across breakpoints by staying on the scale. Your-turn: compose the Aster homepage at two breakpoints.
Concept
You already did the design work. This session is the bridge: the same tokens, the same components, now expressed as real files on disk that a browser runs.
The through-line from Session 1 returns: shadcn components are copied into your repo — you own the source. That single fact is what makes today's customisation honest instead of hopeful.
Counterexample
Discussion prompt
You already did the design work. This session is the bridge: the same tokens, the same components, now expressed as real files on disk that a browser runs.
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:
The through-line from Session 1 returns: shadcn components are copied into your repo — you own the source. That single fact is what makes today's customisation honest instead of hopeful.
Concept
Four stops, each one feeding the next:
init sets up Tailwind + components/ui/; add copies pieces in.:root/.dark hold tokens; @theme inline exposes them to Tailwind.button.tsx, find cva, edit a variant — you own it.Section
Section 1
Concept
npx shadcn@latest init is the one-time setup. It wires Tailwind, creates the components directory, and writes a starting globals.css with the default token set already in :root and .dark.
init — The shadcn setup command. It configures Tailwind, creates components.json (paths + style config), sets up the components/ui directory, and seeds globals.css with the default OKLCH theme.
Analogy
Discussion prompt
Explain init sets the stage 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:
npx shadcn@latest init is the one-time setup. It wires Tailwind, creates the components directory, and writes a starting globals.css with the default token set already in :root and .dark.
Worked example
From an empty aster-site app, run init once. It asks a couple of setup questions, then writes the config and the starting stylesheet.
cd aster-site
npx shadcn@latest initWhen it finishes you have three things that matter: a components.json, a components/ui/ folder (empty for now), and a globals.css holding the default tokens.
| what init created | why it matters |
|---|---|
| components.json | paths + style config shadcn reads on every add |
| components/ui/ | where copied component source will land |
| globals.css | the :root/.dark tokens you will overwrite with Aster's |
Comparison
Comparison matrix
From Scaffold the Aster project: refill the why it matters column from what you know. The rest of the table is as it appeared.
| what init created | why it matters |
|---|---|
| components.json | paths + style config shadcn reads on every add |
| components/ui/ | where copied component source will land |
| globals.css | the :root/.dark tokens you will overwrite with Aster's |
Concept
Figure (svg): Registry to components/ui copy diagram
npx shadcn@latest add … fetches the source for each named component and writes the real .tsx files into components/ui/. It is a copy, not a locked package in node_modules.
This is Session 1's "own the code" made literal: after add, the file is yours to read and edit.
Explain it
Discussion prompt
Explain add copies components in 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:
npx shadcn@latest add … fetches the source for each named component and writes the real .tsx files into components/ui/. It is a copy, not a locked package in node_modules.
Worked example
The Aster page has a navbar, hero, feature grid, pricing, and footer. That maps to a handful of primitives. Add them all in one command.
npx shadcn@latest add button card input badge avatarNow the folder has one real file per component. Peek at the tree — every entry is editable source, not a dependency.
components/
└── ui/
├── avatar.tsx
├── badge.tsx
├── button.tsx
├── card.tsx
└── input.tsxMiss one? Just run add again with the missing name — it drops another file in beside the others.
Anomaly
Predict first
A student writes this, and it looks reasonable:
Treating shadcn add like npm install — a versioned dependency you import and cannot touch.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: There's nothing there. shadcn didn't install a package; it copied a file into your project.
Treat add as a scaffold: it writes editable source into your repo.
Why: There's nothing there. shadcn didn't install a package; it copied a file into your project.
Trap
Treating shadcn add like npm install — a versioned dependency you import and cannot touch.
Look in node_modules/@shadcn/button for the code
Why: There's nothing there. shadcn didn't install a package; it copied a file into your project.
Conclude "I can't change the button, it's the library"
Why: The mental model is wrong, so you never look in the one place the code actually lives.
Treat add as a scaffold: it writes editable source into your repo.
Open components/ui/button.tsx
Why: The real component source is right there in your project, versioned in your git.
Edit it like any file you wrote
Why: You own the copy — changing a class here changes your button, and no upgrade will silently overwrite it.
Concept
init writes a components.json at the project root. Every add reads it to know where to put files and which style to generate.
{
"style": "new-york",
"tailwind": { "css": "app/globals.css", "baseColor": "neutral" },
"aliases": { "components": "@/components", "ui": "@/components/ui" }
}The aliases.ui path is why imports read @/components/ui/button — the @ maps to your project root. Change it once here and every future add follows.
Socratic
Discussion prompt
init writes a components.json at the project root. Every add reads it to know where to put files and which style to generate.
Suppose that were not true. What is the first thing in Session 6: Design-to-Code Handoff that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Answer:
The aliases.ui path is why imports read @/components/ui/button — the @ maps to your project root. Change it once here and every future add follows.
Picture it
Figure (svg): Rented package versus owned copy diagram
Discussion prompt
Read the picture before the words. What is this showing, and what is the one thing it is built to make obvious? Commit to an answer, then read on.
Hint: Name the parts, then say what changes between them — and if nothing changes, say what is being held still.
Answer:
A package like MUI is a subscription: you rent the code, it lives elsewhere, and an upgrade rewrites it under you. shadcn's add is a photocopier: it hands you a printed copy that's now yours to mark up.
Intuition
A package like MUI is a subscription: you rent the code, it lives elsewhere, and an upgrade rewrites it under you. shadcn's add is a photocopier: it hands you a printed copy that's now yours to mark up.
Figure (svg): Rented package versus owned copy diagram
Consequence: your edits survive because nothing upstream owns the file anymore. Updating means re-running add on purpose, not a silent overwrite.
Concept
add isn't a one-shot. Run it again anytime to pull in a component you skipped — it drops a new file beside the others and won't disturb the ones you've edited.
# forgot the dialog for the pricing modal?
npx shadcn@latest add dialogSo the workflow is honest and incremental: scaffold the obvious pieces now, add more as the Aster page grows. Your edits to existing files stay put.
Section
Section 2
Concept
The whole theme lives in globals.css. Two regions do the work: :root and .dark hold the raw token values, and @theme inline maps those values to Tailwind color names.
globals.css — The single stylesheet shadcn generates that holds your design tokens. :root and .dark define the semantic variables per mode; @theme inline exposes them to Tailwind's utility classes.
Socratic
Discussion prompt
The whole theme lives in globals.css. Two regions do the work: :root and .dark hold the raw token values, and @theme inline maps those values to Tailwind color names.
Suppose that were not true. What is the first thing in Session 6: Design-to-Code Handoff that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Answer:
globals.css: The single stylesheet shadcn generates that holds your design tokens. :root and .dark define the semantic variables per mode; @theme inline exposes them to Tailwind's utility classes.
Concept
Figure (svg): Token value, map, and class layer diagram
:root/.dark are the values layer — the actual OKLCH numbers, one set for light, one for dark.
@theme inline is the map layer — it says "the Tailwind color primary is whatever --primary currently is." It holds no colors of its own; it just points.
@theme inline — A Tailwind v4 block that maps existing CSS variables to Tailwind's theme names (e.g. --color-primary: var(--primary)) so utilities like bg-primary resolve. It references tokens; it does not define their color values.
Definition probe
Sort into buckets
Every line below is part of the definition of globals.css or of @theme inline — one or the other, never both. Put each where it belongs.
Worked example
Here is the shape init writes, trimmed. Notice: --primary gets a value in :root and .dark, and is only referenced in @theme inline.
@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);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.65 0.2 285);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--radius-lg: var(--radius);
}The Aster violet oklch(0.55 0.24 285) sits in :root — a value. @theme inline never gets a color; it only wires the name.
Intuition
One brand color, three costumes, all the same identity: a Figma variable, a CSS variable, and a Tailwind class.
Figure (svg): Figma variable to CSS variable to Tailwind class round trip
In Figma you named a variable Primary. In code it's --primary. In markup it's bg-primary. Handoff is mostly just keeping those three in sync.
Concept
Components never name a raw color. button.tsx says bg-primary, not bg-[oklch(0.55...)]. That indirection is why pasting one value retints everything.
Primitive palette values feed semantic roles (--primary, --muted, --border), which split into modes (:root vs .dark). Change a semantic value and every component pointing at it moves — that's the whole reason handoff is a paste, not a hunt-and-replace.
Worked example
In Session 3 you tuned Aster in tweakcn and exported globals.css. Copy its :root and .dark blocks over the defaults init wrote — values only, both blocks.
:root {
--radius: 0.625rem;
--primary: oklch(0.55 0.24 285);
--primary-foreground: oklch(0.985 0 0);
/* ...rest of Aster's light tokens... */
}
.dark {
--primary: oklch(0.65 0.2 285);
/* ...rest of Aster's dark tokens... */
}Leave @theme inline untouched — it already maps the names. The moment --primary changes, every bg-primary on the page follows.
Anomaly
Predict first
A student writes this, and it looks reasonable:
Dropping the Aster OKLCH values into the @theme inline block because it mentions primary.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: That block is the map layer — hard-coding a color there breaks the link to --primary and to dark mode.
Put color values in :root/.dark; leave @theme inline as references.
Why: That block is the map layer — hard-coding a color there breaks the link to --primary and to dark mode.
Trap
Dropping the Aster OKLCH values into the @theme inline block because it mentions primary.
Write --color-primary: oklch(0.55 0.24 285) in @theme inline
Why: That block is the map layer — hard-coding a color there breaks the link to --primary and to dark mode.
Dark mode stops switching
Why: @theme inline runs once; it doesn't re-read .dark. Your primary is now frozen at the light value.
Put color values in :root/.dark; leave @theme inline as references.
Set --primary: oklch(...) in :root and again in .dark
Why: Values belong in the mode blocks — one per mode, so switching modes swaps them.
Keep --color-primary: var(--primary) in @theme inline
Why: The map stays a pointer, so bg-primary tracks whichever mode is active.
Explain it to yourself
Discussion prompt
In Trap: forgetting the .dark block this move is made:
Toggle to dark in production
Why is that legal? Name the rule or definition it rests on before you read on.
Hint: If you can only say "because that is what you do", the rule is the thing to go and find.
Answer:
Aster's dark mode reverts to the default neutral theme — the brand violet vanishes.
Trap
Pasting only the :root values and skipping .dark — light looks perfect, so it seems done.
Ship with .dark still holding the default tokens
Why: You overwrote light but left dark on shadcn's stock values.
Toggle to dark in production
Why: Aster's dark mode reverts to the default neutral theme — the brand violet vanishes.
Paste both blocks from tweakcn — :root and .dark.
Overwrite :root and .dark together
Why: Each mode carries its own token values, so both are on-brand.
Toggle dark and confirm the violet holds
Why: Dark now uses Aster's darker --primary, not the default.
Break the constraint
Discussion prompt
The rule this trap just fixed:
Each mode carries its own token values, so both are on-brand.
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 overwrote light but left dark on shadcn's stock values.
Concept
Colors aren't the only tokens in globals.css. --radius is a single number that the corner scale derives from — set it once and rounded-sm/md/lg/xl all shift together.
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}Aster's base is --radius: 0.625rem in :root. That's the same base radius you set in Figma — the round trip again, this time for corners instead of color.
Socratic
Discussion prompt
Colors aren't the only tokens in globals.css. --radius is a single number that the corner scale derives from — set it once and rounded-sm/md/lg/xl all shift together.
Suppose that were not true. What is the first thing in Session 6: Design-to-Code Handoff that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Answer:
Aster's base is --radius: 0.625rem in :root. That's the same base radius you set in Figma — the round trip again, this time for corners instead of color.
Worked example
You set base radius 0.625rem in Figma. In code it becomes --radius, and every rounded-* class derives from it. Change the one number, watch the whole scale move.
| Figma corner | --radius (0.625rem) | Tailwind class | resolves to |
|---|---|---|---|
| subtle | calc(--radius − 4px) | rounded-sm | ~0.375rem |
| base | var(--radius) | rounded-lg | 0.625rem |
| roomy | calc(--radius + 4px) | rounded-xl | ~0.875rem |
Want Aster softer everywhere? Bump --radius to 0.75rem in :root — cards, buttons, inputs all round up at once, no per-component edits.
Trade off
Comparison matrix
From Aster's radius, from Figma to class: every row here is a choice with a cost. Fill the resolves to column, then say which row you would actually pick and what you give up for it.
| Figma corner | --radius (0.625rem) | Tailwind class | resolves to |
|---|---|---|---|
| subtle | calc(--radius − 4px) | rounded-sm | ~0.375rem |
| base | var(--radius) | rounded-lg | 0.625rem |
| roomy | calc(--radius + 4px) | rounded-xl | ~0.875rem |
Anomaly
Predict first
A student writes this, and it looks reasonable:
Aster's cards should be a touch rounder, so you edit the card and type a literal radius.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: One component now ignores the token scale and sits at a fixed pixel value.
Change the one variable the whole scale derives from.
Why: One component now ignores the token scale and sits at a fixed pixel value.
Trap
Aster's cards should be a touch rounder, so you edit the card and type a literal radius.
Set rounded-[12px] on the card
Why: One component now ignores the token scale and sits at a fixed pixel value.
Later bump --radius for a softer brand
Why: The card doesn't move with everything else — it's hard-coded, so it drifts out of sync.
Change the one variable the whole scale derives from.
Raise --radius in :root
Why: rounded-sm/md/lg/xl are calc() offsets of it, so they all shift together.
Cards, buttons, inputs round up at once
Why: Every corner stays on one scale — no component left behind.
Section
Section 3
Concept
There is no single "export" button that gives you a production site. There are three honest paths from Figma to code, and they trade speed against trust.
You will usually blend them: a plugin for a rough draft, Code Connect for shared vocabulary, manual mapping for the parts that must be exactly right.
Concept
Dev Mode turns Figma into an inspector: measurements, tokens, and per-layer specs for developers. Code Connect goes further — it links a Figma design component to its real code counterpart.
Code Connect — A Figma feature that maps a design component to its actual code component, so Dev Mode shows the real import and usage (e.g. the Button from components/ui/button) instead of a generic autogenerated snippet.
The payoff: when you inspect the Aster button in Dev Mode, you see the true import { Button } line your repo uses — shared vocabulary between design and code.
Socratic
Discussion prompt
The payoff: when you inspect the Aster button in Dev Mode, you see the true import { Button } line your repo uses — shared vocabulary between design and code.
Suppose that were not true. What is the first thing in Session 6: Design-to-Code Handoff that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Answer:
Code Connect: A Figma feature that maps a design component to its actual code component, so Dev Mode shows the real import and usage (e.g. the Button from components/ui/button) instead of a generic autogenerated snippet.
Worked example
Without Code Connect, Dev Mode shows a generic autogenerated snippet for the Aster button — divs and guessed styles, not how your repo actually uses it.
<div class="button" style="background:#7c5cff">
Start a project
</div>With Code Connect linking the Figma component to components/ui/button, Dev Mode shows the real import and usage a developer would paste in.
import { Button } from "@/components/ui/button"
<Button>Start a project</Button>Same button, honest snippet. Design and code now share one vocabulary — the Button in Figma is the Button in your repo.
Sorting
Sort into buckets
These are the pieces of Session 6: Design-to-Code Handoff, out of order. Put each one back under the part of the lesson it belongs to.
Concept
Figma-to-code plugins and AI agents generate markup straight from a frame. They are fast and a genuine head start — but imperfect: guessed class names, off-scale spacing, structure that isn't quite your token system.
Rule for this path: treat the output as a draft, and verify it against your tokens. Never ship a generated snippet without reading it.
Concept
The most reliable path is also the plainest: read the Figma variable, write the Tailwind class. Primary fill → bg-primary. 16px padding → p-4. Gap 8 → gap-2.
It's slower per element, but it produces code that already speaks your token system — nothing to un-guess later. Everything you learned in Sessions 2–4 is exactly this skill.
Worked example
Take the Aster hero's call-to-action. Read it in Figma, write the classes. Every property has a home on the scale you already know from Sessions 2–4.
| Figma property | value | Tailwind class |
|---|---|---|
| fill | Primary | bg-primary |
| text | Primary/On | text-primary-foreground |
| padding | 16px × 24px | py-4 px-6 |
| corner | base radius | rounded-lg |
<Button className="px-6 py-4">Start a project</Button>Notice bg-primary and rounded-lg are already the Button's defaults — manual mapping often just confirms the component is right, then adds the spacing.
Concept
Pick per situation, not per dogma:
| path | what it gives you | when to use it |
|---|---|---|
| Dev Mode & Code Connect | real specs + the actual import for each component | handing off to a dev, or keeping design and code in one vocabulary |
| Plugin / AI agent | a fast, imperfect first draft of markup | a rough starting point — always verify against your tokens |
| Manual mapping | clean code that already uses your tokens | the parts that must be exactly right; small, high-value sections |
Anomaly
Predict first
A student writes this, and it looks reasonable:
A plugin exports the Aster hero. It renders close enough, so you paste it in and move on.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: The AI hard-coded a hex and an off-scale padding instead of bg-primary and p-4.
Use the export as a draft, then map it onto your token system.
Why: The AI hard-coded a hex and an off-scale padding instead of bg-primary and p-4.
Trap
A plugin exports the Aster hero. It renders close enough, so you paste it in and move on.
Accept bg-[#7c5cff] and p-[15px] from the export
Why: The AI hard-coded a hex and an off-scale padding instead of bg-primary and p-4.
Change the brand later in globals.css
Why: The hero doesn't move — it never referenced your token, so retheming skips it.
Use the export as a draft, then map it onto your token system.
Read the output and replace hard-codes
Why: Swap bg-[#7c5cff] → bg-primary, p-[15px] → p-4 — back onto the scale.
Retheme in globals.css and re-check
Why: Now the hero retints with everything else, because it points at --primary.
Ranking
Put in order
These are the steps of The handoff recipe, scrambled. Put them back in order before the next slide shows you.
npx shadcn@latest init.npx shadcn@latest add ….:root and .dark OKLCH values into globals.css.Primary → bg-primary).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
Every design-to-code handoff in this session follows the same six moves, in order:
npx shadcn@latest init.npx shadcn@latest add ….:root and .dark OKLCH values into globals.css.Primary → bg-primary).Edge cases
Discussion prompt
The handoff 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:
Every design-to-code handoff in this session follows the same six moves, in order:
Elimination
Eliminate the wrong options
After npx shadcn@latest add button, where does the Button component's source live, and can you edit it?
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: shadcn's add copies the real component source into components/ui/ in your repo. The file is yours — versioned in your git and free to edit, which is what makes customising it straightforward.
Check
You run npx shadcn@latest add button. Think about where the code ends up.
Check your understanding
After npx shadcn@latest add button, where does the Button component's source live, and can you edit it?
Answer: A
Why: shadcn's add copies the real component source into components/ui/ in your repo. The file is yours — versioned in your git and free to edit, which is what makes customising it straightforward.
Prediction
Predict first
Where should the actual OKLCH color values for --primary go so both modes work?
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: As values in :root and again in .dark; @theme inline just references them
Why: The value belongs in each mode block — :root for light, .dark for dark — so switching modes swaps them. @theme inline only maps --primary to the Tailwind name bg-primary; it holds no color of its own.
Check
You have Aster's exported tokens and want dark mode to switch correctly.
Check your understanding
Where should the actual OKLCH color values for --primary go so both modes work?
Answer: A
Why: The value belongs in each mode block — :root for light, .dark for dark — so switching modes swaps them. @theme inline only maps --primary to the Tailwind name bg-primary; it holds no color of its own.
Prediction
Predict first
For a small, high-value section that has to be exactly right and on your token system, which handoff path fits best?
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: Manual mapping — read each Figma variable, write the matching Tailwind class
Why: For a small section that must be exact and token-correct, manual mapping wins: reading the Figma variable and writing the Tailwind class produces code that already speaks your token system, with nothing to un-guess. It's slower per element but most reliable.
Check
A section of the Aster page — the pricing table — must match the design exactly, with tokens wired correctly the first time.
Check your understanding
For a small, high-value section that has to be exactly right and on your token system, which handoff path fits best?
Answer: A
Why: For a small section that must be exact and token-correct, manual mapping wins: reading the Figma variable and writing the Tailwind class produces code that already speaks your token system, with nothing to un-guess. It's slower per element but most reliable.
Section
Section 4
Concept
Before editing, know the three parts of a shadcn component file so you know which part to touch:
| part | what it does | you edit it to... |
|---|---|---|
| Radix import | behavior: focus, keyboard, ARIA | rarely — it's the accessible base |
| cva variants | the Tailwind class strings per look | change styling / add variants |
| the component fn | wires props to the classes | add a prop or default |
For theming and customisation, the cva block is your workspace — the Radix part gives behavior you usually keep as-is.
Socratic
Discussion prompt
Before editing, know the three parts of a shadcn component file so you know which part to touch:
Suppose that were not true. What is the first thing in Session 6: Design-to-Code Handoff that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Answer:
For theming and customisation, the cva block is your workspace — the Radix part gives behavior you usually keep as-is.
Concept
Because add copied button.tsx into your repo, customising it starts with reading it. The part that controls how each variant looks is a cva call near the top of the file.
cva — class-variance-authority — a helper that maps variant/size names to Tailwind class strings. shadcn components call cva to define their default, secondary, outline, etc. looks; each variant is just an editable string.
Explain it
Discussion prompt
Explain You own the file — so read it 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:
Because add copied button.tsx into your repo, customising it starts with reading it. The part that controls how each variant looks is a cva call near the top of the file.
Intuition
Figure (svg): cva variant name to class string lookup table
Think of cva as a lookup table: you hand it a variant name, it hands back a class string. variant="outline" in JSX just looks up the outline row.
This is the same idea as Figma variant props from Session 5 — a named choice that swaps a whole style. Here the swap is a string of Tailwind classes, and the table is right there in your file.
Analogy
Discussion prompt
Explain cva is a lookup table 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:
Think of cva as a lookup table: you hand it a variant name, it hands back a class string. variant="outline" in JSX just looks up the outline row.
Worked example
Open components/ui/button.tsx and find the cva object. Each variant is a Tailwind class string — nothing magic, just classes.
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3",
lg: "h-10 px-6",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)The default variant already reads bg-primary — so it's on Aster's violet the moment your token is in globals.css, no edit needed.
Worked example
Want a softer corner across all buttons? The base string near the top has rounded-md — change it to rounded-lg. Because it's your file, the edit sticks.
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-lg text-sm font-medium",
{ /* ...variants... */ }
)Need a brand button the kit doesn't ship? Add a variant — a new key with its own class string — then use variant="brand" in JSX.
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
brand: "bg-accent text-accent-foreground shadow-sm hover:opacity-90",
}No forking a library, no override hacks — you edited the source you own.
Concept
The interactive states you designed in Figma — hover, focus, disabled — are just prefixed classes in the same cva string. hover:bg-primary/90 is the hover skin; focus-visible:ring-* is the keyboard focus ring.
default:
"bg-primary text-primary-foreground shadow-xs \
hover:bg-primary/90 \
focus-visible:ring-ring/50 focus-visible:ring-[3px] \
disabled:pointer-events-none disabled:opacity-50"focus-visible (not plain focus) is deliberate — the ring shows for keyboard users, not on every mouse click. That accessibility behavior comes from the Radix base; the ring's look is this Tailwind string you can tune.
Anomaly
Predict first
A student writes this, and it looks reasonable:
Wanting a bigger button radius, but assuming the component is off-limits because it "came from shadcn."
It is wrong. Say what breaks — and say it before you turn the page.
Correct: You're overriding from outside because you believe you can't touch the source.
Remember add copied the file — it's yours.
Why: You're overriding from outside because you believe you can't touch the source.
Trap
Wanting a bigger button radius, but assuming the component is off-limits because it "came from shadcn."
Wrap the button in extra divs and fight it with !important
Why: You're overriding from outside because you believe you can't touch the source.
End up with brittle, hard-to-read overrides
Why: Effort spent working around a file you were always allowed to edit.
Remember add copied the file — it's yours.
Open button.tsx and edit the cva string
Why: Change rounded-md to rounded-lg at the source — one clean edit.
Every button updates, no overrides
Why: The variant strings are the styling API; editing them is the intended way to customise.
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.
init writes a components.json at the project root. Every add reads it to know where to put files and which style to generate.; Consequence: your edits survive because nothing upstream owns the file anymore. Updating means re-running add on purpose, not a silent overwrite.shadcn add like npm install — a versioned dependency you import and cannot touch.; Dropping the Aster OKLCH values into the @theme inline block because it mentions primary.Elimination
Eliminate the wrong options
In your copied button.tsx, what's the cleanest way to make every button's corners rounder?
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: button.tsx was copied into your repo, so the cva class string is your styling API. Changing rounded-md to rounded-lg there updates every button cleanly, with no overrides and nothing to fight.
Check
You want all default buttons to have a larger corner radius, site-wide.
Check your understanding
In your copied button.tsx, what's the cleanest way to make every button's corners rounder?
Answer: A
Why: button.tsx was copied into your repo, so the cva class string is your styling API. Changing rounded-md to rounded-lg there updates every button cleanly, with no overrides and nothing to fight.
Section
Section 5 · Hands-on
Concept
Five milestones take the Aster page from a fresh project to a themed, responsive site in light and dark. Run each command yourself (or let your tutor drive the terminal), and check the result before moving on.
| # | milestone | you should see |
|---|---|---|
| 1 | init the project | components.json, components/ui/, globals.css |
| 2 | add the components | one .tsx per component in components/ui/ |
| 3 | paste the theme | Aster violet in :root and .dark |
| 4 | drop in the page & run | the composed Aster page in the browser |
| 5 | verify light + dark | both modes match the Figma design |
Picture it
Figure (svg): init to add to theme to run to verify pipeline
Discussion prompt
Read the picture before the words. What is this showing, and what is the one thing it is built to make obvious? Commit to an answer, then read on.
Hint: Name the parts, then say what changes between them — and if nothing changes, say what is being held still.
Answer:
The whole build is a straight line — each milestone's output is the next one's input. Nothing loops back.
Intuition
The whole build is a straight line — each milestone's output is the next one's input. Nothing loops back.
Figure (svg): init to add to theme to run to verify pipeline
If your tutor is driving the terminal, follow along and predict each milestone's output before it appears — that's the real check that you understood the handoff.
Counterexample
Discussion prompt
The whole build is a straight line — each milestone's output is the next one's input. Nothing loops back.
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:
If your tutor is driving the terminal, follow along and predict each milestone's output before it appears — that's the real check that you understood the handoff.
Worked example
Your turn: scaffold the project. Before you run it, say aloud what three things init should create.
Hint: one command, run once from inside the aster-site folder. It configures Tailwind and seeds the stylesheet.
cd aster-site
npx shadcn@latest initSelf-check — what files appear?
| appears | role |
|---|---|
| components.json | paths + style config |
| components/ui/ | empty for now — target for add |
| globals.css | default :root/.dark tokens |
Worked example
Your turn: add the primitives the Aster page needs — navbar, hero, feature grid, pricing, footer all draw from the same handful.
Hint: one add command, space-separated names: button, card, input, badge, avatar.
npx shadcn@latest add button card input badge avatarSelf-check: the folder now has real, editable files.
| call | result |
|---|---|
| add button card input badge avatar | 5 .tsx files in components/ui/ |
| open button.tsx | readable source, yours to edit |
Comparison
Comparison matrix
From Milestone 2 — add the components: refill the result column from what you know. The rest of the table is as it appeared.
| call | result |
|---|---|
| add button card input badge avatar | 5 .tsx files in components/ui/ |
| open button.tsx | readable source, yours to edit |
Worked example
Your turn: open globals.css and paste Aster's tweakcn :root and .dark OKLCH values over the defaults. Both blocks — don't skip .dark.
Hint: values only, in the mode blocks. Leave @theme inline exactly as it is.
:root {
--radius: 0.625rem;
--primary: oklch(0.55 0.24 285);
/* ...Aster light tokens... */
}
.dark {
--primary: oklch(0.65 0.2 285);
/* ...Aster dark tokens... */
}Self-check: did you touch the right region?
| block | should hold |
|---|---|
| :root / .dark | the OKLCH values (edited) |
| @theme inline | var(--…) references (unchanged) |
Worked example
Your turn: compose the sections into a page and start the dev server. The components are on Aster's violet already because the token is in globals.css.
Hint: import the primitives, assemble navbar → hero → feature grid → pricing → footer, then run the dev server.
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
export default function AsterPage() {
return (
<main>
{/* navbar · hero · feature grid · pricing · footer */}
<Button>Start a project</Button>
</main>
)
}npm run devSelf-check: the Aster page loads and the primary button is violet — your token flowed all the way to the pixel.
Worked example
Your turn: toggle the .dark class on and off and compare each mode side-by-side with the Figma frames. This is where a missed .dark block shows up.
Hint: check the primary, the background/foreground contrast, and the corner radius in both modes.
| check | light | dark |
|---|---|---|
| primary button | oklch(0.55 0.24 285) | oklch(0.65 0.2 285) |
| bg / text contrast | readable | readable |
| corner radius | matches 0.625rem | matches 0.625rem |
If both columns match your Figma design — you shipped the round trip. Figma tokens became CSS variables became rendered pixels, in both modes.
Worked example
Your turn: the feature grid should stack on mobile and spread to three columns on desktop. Resize the browser and confirm it reflows at the md breakpoint (768px).
Hint: the grid is grid-cols-1 md:grid-cols-3 — one column by default, three from md up. That's the Figma breakpoint expressed as a responsive prefix.
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* three feature cards */}
</div>| viewport | columns | matches Figma frame |
|---|---|---|
| < 768px (mobile) | 1 | stacked |
| ≥ 768px (desktop) | 3 | spread |
Concept
When verify fails, the fix is almost always one of a short list. Walk it top to bottom before touching component source.
| symptom | likely cause | fix |
|---|---|---|
| dark reverts to neutral | skipped the .dark block | paste tweakcn's .dark values |
| one element off-brand | a hard-coded hex, not bg-primary | map it back to the token |
| dark never switches | colors put in @theme inline | move values to :root/.dark |
| corners inconsistent | a rounded-[..px] override | use the --radius scale |
Every one of these is a trap you've already seen today — verify is where they surface.
Trade off
Comparison matrix
From If a mode doesn't match — where to look: every row here is a choice with a cost. Fill the likely cause column, then say which row you would actually pick and what you give up for it.
| symptom | likely cause | fix |
|---|---|---|
| dark reverts to neutral | skipped the .dark block | paste tweakcn's .dark values |
| one element off-brand | a hard-coded hex, not bg-primary | map it back to the token |
| dark never switches | colors put in @theme inline | move values to :root/.dark |
| corners inconsistent | a rounded-[..px] override | use the --radius scale |
Worked example
Step back and name what you built across the whole series:
A responsive, custom-themed Aster landing page — designed in Figma with the shadcn kit and your own brand tokens, then rendered in a real shadcn/ui project with your OKLCH theme in globals.css, responsive across breakpoints in light and dark.
Matching
Match the pairs
From Capstone — the Aster landing page — match each one to what it actually does. The descriptions have been shuffled.
Why: Designed, Themed in code, Verified are easy to tell apart while they are sitting next to their descriptions and much harder afterwards, which is what this checks.
Worked example
The words that carry today. Keep these in your back pocket.
| term | one-liner |
|---|---|
| init | one-time scaffold: Tailwind + components dir + globals.css |
| add | copies editable component source into components/ui/ |
| globals.css | holds the theme: :root/.dark values + @theme inline map |
| @theme inline | maps CSS variables to Tailwind names — no colors of its own |
| Code Connect | links a Figma component to its real code counterpart |
| cva | maps variant names to Tailwind class strings you can edit |
Comparison
Comparison matrix
From Pocket glossary: refill the one-liner column from what you know. The rest of the table is as it appeared.
| term | one-liner |
|---|---|
| init | one-time scaffold: Tailwind + components dir + globals.css |
| add | copies editable component source into components/ui/ |
| globals.css | holds the theme: :root/.dark values + @theme inline map |
| @theme inline | maps CSS variables to Tailwind names — no colors of its own |
| Code Connect | links a Figma component to its real code counterpart |
| cva | maps variant names to Tailwind class strings you can edit |
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — Scaffolding a Project · Where the Theme Lives in Code · Handoff Paths · Reading a Component's Source · Your Turn: Render Aster. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
init and add components with add — copied source you own.:root/.dark hold values, @theme inline holds the map.cva to customise it.| move | how |
|---|---|
| scaffold | npx shadcn@latest init |
| add pieces | npx shadcn@latest add button card … |
| set the theme | paste OKLCH into :root and .dark |
| map the rest | Figma variable → Tailwind class |
| customise | edit the cva string in components/ui/ |
Homework: capstone review and polish — walk your Aster site in both modes and across breakpoints, fix any token that hard-coded instead of referencing, and confirm every section reads straight from globals.css. You now speak fluent Tailwind, Radix, and tokens — and you shipped it.
Want this taught 1-on-1? Alexander tutors shadcn/ui + Figma — $55/session, free consultation.