Session 6: Design-to-Code Handoff

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

What this lesson covers

The lesson, slide by slide

1. Design-to-Code Handoff

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.

2. What you will be able to do

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:

3. What survived from Session 5: Composition — Responsive Layouts, Blocks & Pages?

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.

4. Where this session sits

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.

5. Break it if you can: Where this session sits

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.

6. Today's roadmap

Concept

Four stops, each one feeding the next:

Scaffold
init sets up Tailwind + components/ui/; add copies pieces in.
Theme in code
:root/.dark hold tokens; @theme inline exposes them to Tailwind.
Handoff paths
Dev Mode & Code Connect, plugins/AI, or honest manual mapping.
Read the source
Open button.tsx, find cva, edit a variant — you own it.

7. Scaffolding a Project

Section

Section 1

8. init sets the stage

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.

9. By analogy: init sets the stage

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.

10. Scaffold the Aster project

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 init

When 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 createdwhy it matters
components.jsonpaths + style config shadcn reads on every add
components/ui/where copied component source will land
globals.cssthe :root/.dark tokens you will overwrite with Aster's

11. Fill in: why it matters for Scaffold the Aster project

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 createdwhy it matters
components.jsonpaths + style config shadcn reads on every add
components/ui/where copied component source will land
globals.cssthe :root/.dark tokens you will overwrite with Aster's

12. add copies components in

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.

13. Teach it back: add copies components in

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.

14. Add the components the Aster page needs

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 avatar

Now 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.tsx

Miss one? Just run add again with the missing name — it drops another file in beside the others.

15. Something is wrong here: expecting add to install a locked package

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.

16. Trap: expecting add to install a locked package

Trap

The 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.

The fix

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.

17. components.json — the config add reads

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.

18. What rests on this: components.json — the config add reads

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.

19. Picture it first: add is a photocopier, not a subscription

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.

20. add is a photocopier, not a subscription

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.

21. Add is re-runnable and additive

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 dialog

So 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.

22. Where the Theme Lives in Code

Section

Section 2

23. One file: globals.css

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.

24. What rests on this: One file: globals.css

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.

25. Two regions, two jobs

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.

26. Take the definitions apart: globals.css vs @theme inline

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.

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.
@theme inline
A Tailwind v4 block that maps existing CSS variables to Tailwind's theme names (e.g.; var(--primary)) so utilities like bg-primary resolve.; it does not define their color values.
b1
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.
b2
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.

27. The real globals.css shape

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.

28. The round trip

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.

29. Why semantic tokens make one paste enough

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.

30. Paste Aster's tweakcn values in

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.

31. Something is wrong here: pasting theme values into @theme inline

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.

32. Trap: pasting theme values into @theme inline

Trap

The 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.

The fix

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.

33. Why is this step legal: Toggle to dark in production

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.

34. Trap: forgetting the .dark block

Trap

The 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.

The fix

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.

35. Break it on purpose: forgetting the .dark block

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.

36. One --radius drives the whole corner scale

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.

37. What rests on this: One --radius drives the whole corner scale

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.

38. Aster's radius, from Figma to class

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 classresolves to
subtlecalc(--radius − 4px)rounded-sm~0.375rem
basevar(--radius)rounded-lg0.625rem
roomycalc(--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.

39. What each one costs: Aster's radius, from Figma to class

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 classresolves to
subtlecalc(--radius − 4px)rounded-sm~0.375rem
basevar(--radius)rounded-lg0.625rem
roomycalc(--radius + 4px)rounded-xl~0.875rem

40. Something is wrong here: hard-coding a pixel radius per component

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.

41. Trap: hard-coding a pixel radius per component

Trap

The 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.

The fix

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.

42. Handoff Paths

Section

Section 3

43. Three ways across the gap

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.

44. Path 1 — Dev Mode & Code Connect

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.

45. What rests on this: Path 1 — Dev Mode & Code Connect

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.

46. What Code Connect changes in Dev Mode

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.

47. Where does each piece belong: Session 6: Design-to-Code Handoff

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.

Scaffolding a Project
init sets the stage; Scaffold the Aster project; add copies components in
Where the Theme Lives in Code
One file: globals.css; Two regions, two jobs; The real globals.css shape
Handoff Paths
Three ways across the gap; Path 1 — Dev Mode & Code Connect; What Code Connect changes in Dev Mode
s1
Scaffolding a Project is where Session 6: Design-to-Code Handoff puts init sets the stage, Scaffold the Aster project, add copies components in. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s2
Where the Theme Lives in Code is where Session 6: Design-to-Code Handoff puts One file: globals.css, Two regions, two jobs, The real globals.css shape. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s3
Handoff Paths is where Session 6: Design-to-Code Handoff puts Three ways across the gap, Path 1 — Dev Mode & Code Connect, What Code Connect changes in Dev Mode. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.

48. Path 2 — plugins & AI agents

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.

49. Path 3 — honest manual mapping

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.

50. Manual mapping in practice

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 propertyvalueTailwind class
fillPrimarybg-primary
textPrimary/Ontext-primary-foreground
padding16px × 24pxpy-4 px-6
cornerbase radiusrounded-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.

51. The three paths, compared honestly

Concept

Pick per situation, not per dogma:

pathwhat it gives youwhen to use it
Dev Mode & Code Connectreal specs + the actual import for each componenthanding off to a dev, or keeping design and code in one vocabulary
Plugin / AI agenta fast, imperfect first draft of markupa rough starting point — always verify against your tokens
Manual mappingclean code that already uses your tokensthe parts that must be exactly right; small, high-value sections

52. Something is wrong here: trusting an AI export verbatim

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.

53. Trap: trusting an AI export verbatim

Trap

The 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.

The fix

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.

54. Rebuild the recipe: The handoff recipe

Ranking

Put in order

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

  1. Scaffold: npx shadcn@latest init.
  2. Add the components the page needs with npx shadcn@latest add ….
  3. Paste the tokens: drop tweakcn's :root and .dark OKLCH values into globals.css.
  4. Map the rest: turn remaining Figma variables into Tailwind classes (Primary → bg-primary).
  5. Read the source of any component you need to customise.
  6. Verify the rendered page in both light and dark against the Figma design.

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.

55. The handoff recipe

Pattern

Every design-to-code handoff in this session follows the same six moves, in order:

  1. Scaffold: npx shadcn@latest init.
  2. Add the components the page needs with npx shadcn@latest add ….
  3. Paste the tokens: drop tweakcn's :root and .dark OKLCH values into globals.css.
  4. Map the rest: turn remaining Figma variables into Tailwind classes (Primary → bg-primary).
  5. Read the source of any component you need to customise.
  6. Verify the rendered page in both light and dark against the Figma design.

56. Where does it stop working: The handoff recipe

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:

57. Rule out three: Check: what does add actually do?

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.

  • A. In components/ui/button.tsx — a copied file you own and can edit
  • B. In node_modules — a locked package you import but shouldn't edit
  • C. Nowhere on disk; it's fetched from shadcn's CDN at runtime
  • D. Inside globals.css, alongside the theme tokens

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.

58. Check: what does add actually do?

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?

  • A. In components/ui/button.tsx — a copied file you own and can edit (correct)
  • B. In node_modules — a locked package you import but shouldn't edit
  • C. Nowhere on disk; it's fetched from shadcn's CDN at runtime
  • D. Inside globals.css, alongside the theme tokens

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.

Why B tempts people
That's the npm-package mental model. shadcn deliberately does NOT install a locked dependency; it copies editable source into your project.
Why C tempts people
Nothing is fetched at runtime. add writes a static .tsx file at install time; your app has no dependency on shadcn's servers afterward.
Why D tempts people
globals.css holds theme tokens, not component source. Components are .tsx files in components/ui/, separate from the stylesheet.

59. Answer it before you see the options: Check: where do the OKLCH values go?

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.

60. Check: where do the OKLCH values go?

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?

  • A. As values in :root and again in .dark; @theme inline just references them (correct)
  • B. Only in @theme inline, since that's what Tailwind reads
  • C. Only in :root; .dark inherits automatically
  • D. In components.json, which shadcn re-applies on build

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.

Why B tempts people
@theme inline is the map layer. Hard-coding a color there freezes it across modes and breaks the link to :root/.dark.
Why C tempts people
.dark does not inherit primary from :root for dark mode; if you skip it, dark reverts to whatever .dark already held (the defaults).
Why D tempts people
components.json stores paths and style config, not token values. The theme lives in globals.css.

61. Answer it before you see the options: Check: choosing a handoff path

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.

62. Check: choosing a handoff path

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?

  • A. Manual mapping — read each Figma variable, write the matching Tailwind class (correct)
  • B. Run it through an AI plugin and ship the output as-is
  • C. Rely on Code Connect to generate the finished pricing markup
  • D. Skip Figma and eyeball the colors from a screenshot

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.

Why B tempts people
Plugin output is a fast draft, not a finished artifact — shipping it as-is risks hard-coded hexes and off-scale spacing that skip your tokens.
Why C tempts people
Code Connect links a design component to existing code and improves inspection; it doesn't author a correct, token-mapped pricing section for you.
Why D tempts people
Eyeballing a screenshot discards the exact token values, guaranteeing off-brand colors and spacing — the opposite of exact.

63. Reading a Component's Source

Section

Section 4

64. Anatomy: what's inside a shadcn component

Concept

Before editing, know the three parts of a shadcn component file so you know which part to touch:

partwhat it doesyou edit it to...
Radix importbehavior: focus, keyboard, ARIArarely — it's the accessible base
cva variantsthe Tailwind class strings per lookchange styling / add variants
the component fnwires props to the classesadd a prop or default

For theming and customisation, the cva block is your workspace — the Radix part gives behavior you usually keep as-is.

65. What rests on this: Anatomy: what's inside a shadcn component

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.

66. You own the file — so read it

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.

67. Teach it back: You own the file — so read it

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.

68. cva is a lookup table

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.

69. By analogy: cva is a lookup table

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.

70. Inside button.tsx

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.

71. Change the radius, or add a variant

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.

72. The states live in the class strings too

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.

73. Something is wrong here: thinking you can't change the component

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.

74. Trap: thinking you can't change the component

Trap

The 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.

The fix

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.

75. Which of these survive contact with Session 6: Design-to-Code Handoff?

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
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.; 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.
Breaks
Treating 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.
sound
These are stated as this lesson states them — each one survives the edge cases Session 6: Design-to-Code Handoff 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.

76. Rule out three: Check: customising a variant

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.

  • A. Edit the cva class string, changing rounded-md to rounded-lg
  • B. Add a global CSS rule with !important targeting all buttons
  • C. Fork shadcn/ui on GitHub and publish your own package
  • D. Set --radius in @theme inline to a fixed pixel value

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.

77. Check: customising a variant

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?

  • A. Edit the cva class string, changing rounded-md to rounded-lg (correct)
  • B. Add a global CSS rule with !important targeting all buttons
  • C. Fork shadcn/ui on GitHub and publish your own package
  • D. Set --radius in @theme inline to a fixed pixel value

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.

Why B tempts people
!important overrides are brittle and unnecessary — you own the source, so edit it directly instead of fighting it from outside.
Why C tempts people
Forking and publishing a package throws away shadcn's whole point: the file is already local and editable. No package step is needed.
Why D tempts people
@theme inline maps variables to Tailwind names; hard-coding a value there is the wrong layer and doesn't target the button's own rounded-md class.

78. Your Turn: Render Aster

Section

Section 5 · Hands-on

79. The build: Aster, from Figma to running

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.

#milestoneyou should see
1init the projectcomponents.json, components/ui/, globals.css
2add the componentsone .tsx per component in components/ui/
3paste the themeAster violet in :root and .dark
4drop in the page & runthe composed Aster page in the browser
5verify light + darkboth modes match the Figma design

80. Picture it first: Five milestones, one arrow

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.

81. Five milestones, one arrow

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.

82. Break it if you can: Five milestones, one arrow

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.

83. Milestone 1 — init

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 init

Self-check — what files appear?

appearsrole
components.jsonpaths + style config
components/ui/empty for now — target for add
globals.cssdefault :root/.dark tokens

84. Milestone 2 — add the components

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 avatar

Self-check: the folder now has real, editable files.

callresult
add button card input badge avatar5 .tsx files in components/ui/
open button.tsxreadable source, yours to edit

85. Fill in: result for Milestone 2 — add the components

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.

callresult
add button card input badge avatar5 .tsx files in components/ui/
open button.tsxreadable source, yours to edit

86. Milestone 3 — paste the theme

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?

blockshould hold
:root / .darkthe OKLCH values (edited)
@theme inlinevar(--…) references (unchanged)

87. Milestone 4 — drop in the page & run

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 dev

Self-check: the Aster page loads and the primary button is violet — your token flowed all the way to the pixel.

88. Milestone 5 — verify light AND dark

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.

checklightdark
primary buttonoklch(0.55 0.24 285)oklch(0.65 0.2 285)
bg / text contrastreadablereadable
corner radiusmatches 0.625remmatches 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.

89. Bonus check — responsive across breakpoints

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>
viewportcolumnsmatches Figma frame
< 768px (mobile)1stacked
≥ 768px (desktop)3spread

90. If a mode doesn't match — where to look

Concept

When verify fails, the fix is almost always one of a short list. Walk it top to bottom before touching component source.

symptomlikely causefix
dark reverts to neutralskipped the .dark blockpaste tweakcn's .dark values
one element off-branda hard-coded hex, not bg-primarymap it back to the token
dark never switchescolors put in @theme inlinemove values to :root/.dark
corners inconsistenta rounded-[..px] overrideuse the --radius scale

Every one of these is a trap you've already seen today — verify is where they surface.

91. What each one costs: If a mode doesn't match — where to look

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.

symptomlikely causefix
dark reverts to neutralskipped the .dark blockpaste tweakcn's .dark values
one element off-branda hard-coded hex, not bg-primarymap it back to the token
dark never switchescolors put in @theme inlinemove values to :root/.dark
corners inconsistenta rounded-[..px] overrideuse the --radius scale

92. Capstone — the Aster landing page

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.

Designed
Figma frames on the shadcn kit, Aster tokens throughout.
Themed in code
tweakcn OKLCH values in :root and .dark.
Verified
Light and dark match, across breakpoints.

93. Which is which: Capstone — the Aster landing page

Matching

Match the pairs

From Capstone — the Aster landing page — match each one to what it actually does. The descriptions have been shuffled.

  • c1. Designed
  • c2. Themed in code
  • c3. Verified
  • b1. Figma frames on the shadcn kit, Aster tokens throughout.
  • b2. tweakcn OKLCH values in :root and .dark.
  • b3. Light and dark match, across breakpoints.

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.

94. Pocket glossary

Worked example

The words that carry today. Keep these in your back pocket.

termone-liner
initone-time scaffold: Tailwind + components dir + globals.css
addcopies editable component source into components/ui/
globals.cssholds the theme: :root/.dark values + @theme inline map
@theme inlinemaps CSS variables to Tailwind names — no colors of its own
Code Connectlinks a Figma component to its real code counterpart
cvamaps variant names to Tailwind class strings you can edit

95. Fill in: one-liner for Pocket glossary

Comparison

Comparison matrix

From Pocket glossary: refill the one-liner column from what you know. The rest of the table is as it appeared.

termone-liner
initone-time scaffold: Tailwind + components dir + globals.css
addcopies editable component source into components/ui/
globals.cssholds the theme: :root/.dark values + @theme inline map
@theme inlinemaps CSS variables to Tailwind names — no colors of its own
Code Connectlinks a Figma component to its real code counterpart
cvamaps variant names to Tailwind class strings you can edit

96. Connect it up: Session 6: Design-to-Code Handoff

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.

97. What you can do now

Recap

movehow
scaffoldnpx shadcn@latest init
add piecesnpx shadcn@latest add button card …
set the themepaste OKLCH into :root and .dark
map the restFigma variable → Tailwind class
customiseedit 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.

Sources

  1. shadcn/ui — Installation, init & add (components copied into your repo)
  2. shadcn/ui — Theming (globals.css, :root/.dark, @theme inline, OKLCH)
  3. Tailwind CSS v4 — Theme variables & @theme
  4. Figma — Dev Mode & Code Connect
  5. tweakcn — visual theme editor for shadcn/ui (export globals.css)

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

Book on Wyzant · Text (657) 465-8108