Session 4 of the Designing & Shipping with shadcn/ui in Figma series, in which Gordon learns to configure any kit component through its properties and to account for every interaction state. It explains what "headless" means - Radix supplies the behavior and accessibility while shadcn adds the Tailwind skin - and covers Figma component properties, where a variant property swaps a whole style and a boolean property toggles a single feature, demonstrated on the real Button variant and size set. It then works through the five interactive states that matter (default, hover, focus-visible ring, active, and disabled), with particular attention to focus-visible for keyboard accessibility. A scaffolded your-turn build brands the Aster primary Button and Input and documents all their states, alongside the configure-a-component pattern, four checks, and three traps drawn from real designer misconceptions.
Subject: shadcn/ui + Figma · 97 slides · applied lesson
Open the interactive version of this deck · Homework for this lesson
Title
Session 4 · shadcn/ui + Figma
What's actually inside a kit component - the headless behavior underneath, the properties on top, and every interaction state you have to design for.
Objectives
The big goal: configure any kit component through its properties instead of forking a new one, and account for every state it can be in. Concretely you'll be able to:
showIcon.focus-visible for the keyboard focus ring - and say why it isn't hover or plain :focus.Warm-up
Discussion prompt
Before we open Session 4: Radix & Component Anatomy: without looking back, what was the main idea of Session 3: Design Tokens & Theming — the Heart of It, 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 3 of 6, in 60 slides. It covers the three token layers, running from primitive to semantic to mode, and shows how to retheme a whole kit in light and dark by editing a handful of semantic variables. It explains OKLCH and why Tailwind v4 and shadcn adopted it, how one --radius drives the whole corner scale, and how to use tweakcn to dial in Aster's brand theme and export a matching globals.css. Traps cover theming primitives instead of semantics, and forgetting dark mode.
Concept
You've already themed Aster with tokens. Now you drop down to the level of a single component - how it's built, how you configure it, and how it behaves under the pointer and the keyboard.
The reason this matters: a component isn't one picture. It's a small machine with inputs (its properties) and modes (its states). You design the inputs and the modes, not one static frame.
Counterexample
Discussion prompt
You've already themed Aster with tokens. Now you drop down to the level of a single component - how it's built, how you configure it, and how it behaves under the pointer and the keyboard.
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 reason this matters: a component isn't one picture. It's a small machine with inputs (its properties) and modes (its states). You design the inputs and the modes, not one static frame.
Concept
Three parts, each building on the last, then a hands-on build:
Matching
Match the pairs
From Today's roadmap — match each one to what it actually does. The descriptions have been shuffled.
Why: Headless, Properties, States are easy to tell apart while they are sitting next to their descriptions and much harder afterwards, which is what this checks.
Section
Part 1
Concept
Figure (svg): A bare wireframe skeleton labeled Radix, plus a paint swatch labeled Tailwind, equals a finished styled button labeled shadcn
A shadcn component is two things fused together: Radix underneath supplies the behavior, and Tailwind on top supplies the look.
Radix is the bare skeleton - it works, but it has no paint. Tailwind is the paint. shadcn is the finished, painted component you actually drop into Aster.
You own the shadcn file, so you can tweak the paint freely. The skeleton underneath is the part you almost never touch.
Analogy
Discussion prompt
Explain Three layers, one component 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:
A shadcn component is two things fused together: Radix underneath supplies the behavior, and Tailwind on top supplies the look.
Concept
headless — A component that ships behavior and accessibility with NO styling. It manages focus, keyboard navigation, and ARIA roles/states, but renders no colors, spacing, or fonts - you supply all of that.
Radix Primitives are headless. A Radix Dialog traps focus, closes on Escape, and wires up role="dialog" and aria-modal - all correct - while looking like completely unstyled markup.
So the accessibility is built in for free. Your only job is to style it. That's the whole bargain: they own the hard, invisible behavior; you own the paint.
Explain it
Discussion prompt
Explain What "headless" actually gives you 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:
Radix Primitives are headless. A Radix Dialog traps focus, closes on Escape, and wires up role="dialog" and aria-modal - all correct - while looking like completely unstyled markup.
Concept
"Behavior + accessibility" isn't vague - it's a concrete list of things you'd otherwise hand-build and get wrong:
| Radix handles | example |
|---|---|
| focus management | focus is trapped inside an open dialog, returned on close |
| keyboard navigation | arrow keys move through a menu, Escape closes it |
| ARIA roles + states | role="dialog", aria-expanded, aria-checked set correctly |
None of that shows up as pixels, which is exactly why it's easy to forget it's there - and expensive to rebuild by hand.
Comparison
Comparison matrix
From What behavior actually rides underneath: refill the example column from what you know. The rest of the table is as it appeared.
| Radix handles | example |
|---|---|
| focus management | focus is trapped inside an open dialog, returned on close |
| keyboard navigation | arrow keys move through a menu, Escape closes it |
| ARIA roles + states | role="dialog", aria-expanded, aria-checked set correctly |
Worked example
Imagine hand-rolling a dropdown menu. The visible part - a styled box with rows - is the easy 20%. The invisible 80% is what Radix gives you:
| behavior | you'd hand-code | Radix does it |
|---|---|---|
| arrow-key navigation | keydown handlers, index math | yes |
| Escape / click-outside to close | listeners + cleanup | yes |
| focus return to trigger | save + restore focus | yes |
| aria-expanded / roles | set + sync every toggle | yes |
Each of those is a place teams ship bugs. Headless means you inherit the correct version and spend your time on the paint - which is the part you're actually good at.
Trade off
Comparison matrix
From What you'd have to build without headless: every row here is a choice with a cost. Fill the you'd hand-code column, then say which row you would actually pick and what you give up for it.
| behavior | you'd hand-code | Radix does it |
|---|---|---|
| arrow-key navigation | keydown handlers, index math | yes |
| Escape / click-outside to close | listeners + cleanup | yes |
| focus return to trigger | save + restore focus | yes |
| aria-expanded / roles | set + sync every toggle | yes |
Intuition
Figure (svg): Left: a plain dashed rectangle labeled Radix works no style. Right: the same rectangle filled and rounded labeled shadcn works and looks right.
Think of a wireframe versus a mockup. The wireframe already does the right thing - the tab order, the focus, the roles are all correct. It just looks like nothing yet.
You'd never ship the wireframe. But you'd be foolish to rebuild all its behavior from scratch either. shadcn keeps the wireframe's brain and gives it a face.
Socratic
Discussion prompt
Think of a wireframe versus a mockup. The wireframe already does the right thing - the tab order, the focus, the roles are all correct. It just looks like nothing yet.
Suppose that were not true. What is the first thing in Session 4: Radix & Component Anatomy that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Answer:
You'd never ship the wireframe. But you'd be foolish to rebuild all its behavior from scratch either. shadcn keeps the wireframe's brain and gives it a face.
Worked example
When you run npx shadcn@latest add button, the component lands in your repo at components/ui/button.tsx. It's not a locked dependency - it's your source.
npx shadcn@latest add button inputOpen it and you see the two layers plainly: a Radix (or native) element for behavior, wrapped in a Tailwind class string for the skin.
// the behavior element (Radix Slot / <button>)
// + a class string you fully control:
"bg-primary text-primary-foreground rounded-lg
hover:bg-primary/90 focus-visible:ring-2 ..."Because you own the file, branding Aster is editing a class string you can read - not fighting a library's theme API from the outside.
Anomaly
Predict first
A student writes this, and it looks reasonable:
You add a Radix primitive expecting it to look like a finished component out of the box.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: It works - focus is trapped, Escape closes it - but it has zero paint: no background, no radius, no spacing.
Treat Radix as behavior-only and add the Tailwind skin yourself (this is exactly what shadcn does).
Why: It works - focus is trapped, Escape closes it - but it has zero paint: no background, no radius, no spacing.
Trap
You add a Radix primitive expecting it to look like a finished component out of the box.
Drop in a bare Radix Dialog and render it
Why: It works - focus is trapped, Escape closes it - but it has zero paint: no background, no radius, no spacing.
Conclude "Radix is broken or ugly"
Why: It isn't broken. Radix is headless - styling was never its job. You just haven't added the skin.
Treat Radix as behavior-only and add the Tailwind skin yourself (this is exactly what shadcn does).
Use the shadcn version, which wraps Radix in Tailwind classes
Why: You keep Radix's focus/keyboard/ARIA and get a themed surface from bg-*, rounded-lg, p-*.
Tweak the classes to brand it
Why: Behavior stays correct; only the paint changes. That separation is the whole point of headless.
Section
Part 2
Concept
In Figma, a component's behavior is exposed through properties in the right panel. Two kinds do most of the work: variant props and boolean props.
variant prop — A property that swaps the component to a whole different named style. One variant prop can have many values - e.g. the Button's variant = default / secondary / destructive / outline / ghost / link.
boolean prop — A true/false property that toggles ONE feature on or off - e.g. showIcon. It doesn't swap the whole style; it just adds or removes a piece.
Definition probe
Sort into buckets
Every line below is part of the definition of headless or of variant prop — one or the other, never both. Put each where it belongs.
Concept
Figure (svg): A Figma-style properties panel listing variant, size, and a showIcon toggle with their values
shadcn's Button really exposes these, and they map straight to the code props:
| property | kind | values |
|---|---|---|
| variant | variant | default, secondary, destructive, outline, ghost, link |
| size | variant | default, sm, lg, icon |
| showIcon | boolean | true / false |
Notice: variant and size each swap a whole style. showIcon just adds or removes the leading icon - one feature, independent of which variant you picked.
Concept
The variant isn't decoration - it signals intent. Pick by what the button is for:
| variant | when to use |
|---|---|
| default | The primary action on a screen - the one thing you want clicked. |
| secondary | A supporting action next to the primary one. |
| destructive | Deletes or irreversibly changes data - warns the user. |
| outline | A quieter action; sits calmly on busy surfaces. |
| ghost | Minimal, chrome-free - toolbars, menus, icon rows. |
| link | Looks like a text link but is a real button. |
Socratic
Discussion prompt
The variant isn't decoration - it signals intent. Pick by what the button is for:
Suppose that were not true. What is the first thing in Session 4: Radix & Component Anatomy that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Intuition
Figure (svg): Four buttons showing sm, default, lg widths, and a square icon-only button
size is a second variant prop: default, sm, lg, and icon. It changes height, padding, and text size - not color.
icon is the special one: a square button sized for a single glyph, no label. It's how you get a clean icon button without inventing a new component.
Concept
This isn't a loose analogy. The variant you pick in Figma's panel maps one-to-one to a prop in the code - shadcn builds its variants with a helper called cva (class-variance-authority).
const buttonVariants = cva("base classes...", {
variants: {
variant: { default: "...", destructive: "...", outline: "..." },
size: { default: "...", sm: "...", lg: "...", icon: "..." },
},
})So variant and size in Figma are literally the variant and size keys here. Design and code speak the same vocabulary - that's what makes handoff clean.
Worked example
Watch what a boolean does versus a variant. Flipping showIcon adds or removes exactly one child - it doesn't touch the fill, size, or radius.
// showIcon = false
<Button variant="default">Save</Button>
// showIcon = true (same variant, same size)
<Button variant="default"><Check /> Save</Button>| property | showIcon = false | showIcon = true |
|---|---|---|
| fill / size / radius | unchanged | unchanged |
| leading icon | hidden | shown |
One boolean, orthogonal to the variant. That independence is exactly why you don't need ButtonWithIcon and ButtonNoIcon as separate components.
Worked example
Figure (svg): Three Aster buttons: a filled violet default, an outline, and a ghost, plus a small and large size
For Aster's hero, you want the primary call-to-action. In the panel, set variant = default, size = lg, showIcon = true.
That maps to one line of markup - the same three properties, now as class-driving props:
<Button variant="default" size="lg">
<Sparkles /> Start a project
</Button>The nav's "Log in", by contrast, is variant = ghost, size = default - same component, different property values. You never made a second button.
| use | variant | size |
|---|---|---|
| hero CTA | default | lg |
| nav Log in | ghost | default |
| delete row | destructive | sm |
Pattern
Step through it
Step through Dissecting the Aster Button one row at a time. What is driving the change, and what would the row after the last one be?
Concept
Notice shadcn's variant values: default, secondary, destructive, ghost. They name a role, not a color. None of them is called violet or red.
That's deliberate. destructive stays destructive even after you rebrand Aster's red to a different red. If you'd named it redButton, the name would lie the moment the theme changed.
Role names + tokens are the same discipline at two levels: the variant says what it's for, the token says what color that role is right now.
Socratic
Discussion prompt
Notice shadcn's variant values: default, secondary, destructive, ghost. They name a role, not a color. None of them is called violet or red.
Suppose that were not true. What is the first thing in Session 4: Radix & Component Anatomy that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Answer:
Role names + tokens are the same discipline at two levels: the variant says what it's for, the token says what color that role is right now.
Concept
Say you have 2 variants and you want each with and without an icon. You could build 4 separate components. Don't.
Instead: keep the variant prop (2 values) and add ONE boolean showIcon. That's 2 properties, and it covers all 4 combinations - variant multiplied by showIcon.
| approach | what you maintain | covers |
|---|---|---|
| 4 separate components | 4 frames to update | 4 fixed looks |
| variant + showIcon boolean | 1 component, 2 props | all 2 x 2 combinations |
Fewer components, no drift: fix a padding value once and every combination inherits it. Booleans are how a kit stays small while covering more cases.
Anomaly
Predict first
A student writes this, and it looks reasonable:
Aster needs a red delete button. You build ButtonRed as a brand-new component.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: Now there are two components. A radius change has to be made in both - and they'll drift apart.
Add a value to the existing variant prop instead of a new component.
Why: Now there are two components. A radius change has to be made in both - and they'll drift apart.
Trap
Aster needs a red delete button. You build ButtonRed as a brand-new component.
Duplicate Button, recolor it, name it ButtonRed
Why: Now there are two components. A radius change has to be made in both - and they'll drift apart.
Next week: ButtonGhost, ButtonIcon, and on it goes
Why: The kit bloats. Every fix multiplies across near-duplicates you have to keep in sync.
Add a value to the existing variant prop instead of a new component.
Set the Button's variant = destructive
Why: It's already in the set. One component, one more value - no duplicate to maintain.
Toggle showIcon or pick a size as needed
Why: Every combination comes from properties on one source of truth. Fixes land everywhere at once.
Break the constraint
Discussion prompt
The rule this trap just fixed:
Every combination comes from properties on one source of truth. Fixes land everywhere at once.
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:
Now there are two components. A radius change has to be made in both - and they'll drift apart.
Section
Part 3
Concept
Figure (svg): Five stacked buttons showing default, hover brighter, focus-visible with a ring, active pressed, and disabled dimmed
The same button is never just one picture. It moves through five interactive states, and a finished design shows all of them:
state — A distinct mode a component can be in based on interaction: default (resting), hover (pointer over it), focus-visible (keyboard-focused ring), active (being pressed), and disabled (not interactive).
Design only the default and you've designed one-fifth of the component.
Intuition
Figure (svg): A state diagram: default at center, arrows to hover on pointer, focus-visible on Tab, active on press, and disabled as a separate locked node
Picture the states as nodes. From default, a pointer takes you to hover, Tab to focus-visible, a press to active - then back.
disabled sits off to the side: it's a locked mode where none of the other transitions can fire. That's why disabled has to switch off pointer events, not just dim.
Socratic
Discussion prompt
Picture the states as nodes. From default, a pointer takes you to hover, Tab to focus-visible, a press to active - then back.
Suppose that were not true. What is the first thing in Session 4: Radix & Component Anatomy that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Concept
Tailwind gives each state a variant prefix. You attach the state's look to that hook:
| state | visual change | Tailwind hook |
|---|---|---|
| default | resting brand look | base classes (no prefix) |
| hover | slightly brighter or darker fill | hover:bg-primary/90 |
| focus-visible | a visible ring around it | focus-visible:ring-2 |
| active | pressed - a touch darker | active:bg-primary/80 |
| disabled | dimmed, not clickable | disabled:opacity-50 |
Sorting
Sort into buckets
These are the pieces of Session 4: Radix & Component Anatomy, out of order. Put each one back under the part of the lesson it belongs to.
Concept
focus-visible — The keyboard-only focus ring. It fires when an element is focused AND the browser judges a ring should show - typically Tab navigation - not on an ordinary mouse click. In Tailwind: focus-visible:ring-*.
Someone navigating Aster with the keyboard needs to see where they are. That ring is how they know the "Start a project" button is focused before they press Enter.
Designers forget it constantly, because with a mouse you never see it. Turn the mouse off and Tab through your page - if nothing lights up, the page is unusable by keyboard.
Intuition
Figure (svg): pointer maps to hover, Tab key maps to focus-visible, mouse click maps to plain focus which also fires and is the problem
Three different triggers, easy to conflate:
:focus = focused by any means, including a mouse click - so the ring lingers after clicking.That last one is why :focus feels ugly: click a button and a ring sticks around. focus-visible shows the ring only when it helps - keyboard users - and stays quiet for mouse clicks.
Worked example
The states aren't one-size-fits-all - each variant expresses them differently. A ghost button has no resting fill, so its hover is where the fill first appears.
<!-- ghost: transparent at rest, fill on hover -->
class="bg-transparent
hover:bg-accent hover:text-accent-foreground
focus-visible:ring-2 focus-visible:ring-ring"| variant | default | hover |
|---|---|---|
| default | solid --primary fill | bg-primary/90 (darker) |
| outline | border only, no fill | bg-accent appears |
| ghost | fully transparent | bg-accent appears |
focus-visible, though, stays the same everywhere: the ring is a consistent keyboard signal regardless of variant. Consistency there is a feature, not a bug.
Concept
Here's the payoff of the token work from earlier sessions: every state reads a semantic token, so you write the states once and rebranding retints all of them.
| state hook | reads token | on rebrand |
|---|---|---|
| bg-primary | --primary | new brand fill everywhere |
| hover:bg-primary/90 | --primary (90%) | hover follows automatically |
| focus-visible:ring-ring | --ring | ring recolors with the theme |
Change --primary once in globals.css and the default, hover, and active states all move together. You never hand-recolor five frames.
Comparison
Comparison matrix
From Tokens make five states nearly free: refill the on rebrand column from what you know. The rest of the table is as it appeared.
| state hook | reads token | on rebrand |
|---|---|---|
| bg-primary | --primary | new brand fill everywhere |
| hover:bg-primary/90 | --primary (90%) | hover follows automatically |
| focus-visible:ring-ring | --ring | ring recolors with the theme |
Worked example
Here's the default variant's class string, with one hook per state. Read it as five states, not one look.
class="bg-primary text-primary-foreground rounded-lg
hover:bg-primary/90
focus-visible:ring-2 focus-visible:ring-ring
active:bg-primary/80
disabled:opacity-50 disabled:pointer-events-none"Every state reads a token (bg-primary, ring-ring), so rebranding Aster retints all five at once. You wrote the states; the tokens do the coloring.
Note the disabled line does two things: dims it (opacity-50) AND turns off clicks (pointer-events-none).
Concept
A disabled button must look and behave unavailable. Reduced affordance alone isn't enough.
| job | why | Tailwind hook |
|---|---|---|
| reduce affordance | signal you can't use this yet | disabled:opacity-50 |
| stop interaction | clicks and hover must not fire | disabled:pointer-events-none |
Skip pointer-events-none and a dimmed button still responds to hover and clicks - it looks disabled but isn't. Both halves are required.
Socratic
Discussion prompt
A disabled button must look and behave unavailable. Reduced affordance alone isn't enough.
Suppose that were not true. What is the first thing in Session 4: Radix & Component Anatomy that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Answer:
Skip pointer-events-none and a dimmed button still responds to hover and clicks - it looks disabled but isn't. Both halves are required.
Anomaly
Predict first
A student writes this, and it looks reasonable:
You want a focus ring, so you attach it to hover - or to plain :focus.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: A keyboard user Tabs to the button and gets NO ring - hover only fires for the pointer.
Use focus-visible for the ring - it targets keyboard focus specifically.
Why: A keyboard user Tabs to the button and gets NO ring - hover only fires for the pointer. They can't see where they are.
Trap
You want a focus ring, so you attach it to hover - or to plain :focus.
Put the ring on hover:ring-2
Why: A keyboard user Tabs to the button and gets NO ring - hover only fires for the pointer. They can't see where they are.
Or fall back to focus:ring-2
Why: Now the ring also appears on every mouse click and lingers - the exact behavior everyone finds ugly and turns off.
Use focus-visible for the ring - it targets keyboard focus specifically.
Put the ring on focus-visible:ring-2 focus-visible:ring-ring
Why: Tab shows the ring for keyboard users; an ordinary mouse click doesn't - accessible and clean.
Keep hover for the fill only
Why: hover and focus-visible are different triggers doing different jobs - don't make one stand in for the other.
Worked example
Because you own button.tsx, you can read every state in one class string. Here's the shape of the real thing - grouped by state so it's legible:
class="inline-flex items-center justify-center rounded-md text-sm
bg-primary text-primary-foreground
hover:bg-primary/90
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
active:bg-primary/80
disabled:pointer-events-none disabled:opacity-50"Line 1 is layout + shape (shared by all states). Lines 2-7 are the five states. When you brand Aster, you're editing exactly these lines - nothing hidden.
focus-visible:outline-none removes the browser's default outline so the ring-2 you added is the only ring - a clean, single, on-brand focus indicator.
Concept
The most common real failure isn't a wrong hook - it's only designing the default. The button looks great in the mockup and falls apart in use.
No hover means it feels dead under the pointer. No focus-visible means it's invisible to keyboard users. No disabled means a form's submit stays clickable when it shouldn't be.
A component isn't done at the default frame. It's done when all five states are drawn and documented.
Section
Part 4
Concept
Everything so far - headless behavior, variant/boolean props, five states - isn't a Button quirk. It's the anatomy of every kit component. The Input is the next one Aster needs.
So when you meet a new component, you already know the questions: what behavior is headless underneath, what are its properties, and what does each of its states look like?
Socratic
Discussion prompt
Everything so far - headless behavior, variant/boolean props, five states - isn't a Button quirk. It's the anatomy of every kit component. The Input is the next one Aster needs.
Suppose that were not true. What is the first thing in Session 4: Radix & Component Anatomy that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Concept
Figure (svg): An input field with a resting border, a focused version with a ring, a dimmed disabled version, and a red-bordered error version
The Input's behavior is the browser's native <input> - shadcn styles it rather than wrapping a Radix primitive, but the split still holds: behavior underneath, Tailwind skin on top.
Its resting border reads --input; its focus ring reads --ring; its error treatment reads --destructive. Different tokens, same idea - the component just references roles.
Concept
Text inputs add one state the Button doesn't have: error. It's not decoration - a form field must be able to say "this value is wrong" both visually and to a screen reader.
| input state | visual | hook |
|---|---|---|
| default | quiet border | border-input |
| focus-visible | ring | focus-visible:ring-ring |
| disabled | dim + no typing | disabled:opacity-50 disabled:pointer-events-none |
| error | destructive border + ring | aria-invalid:border-destructive |
The aria-invalid hook does double duty: it drives the red styling AND tells assistive tech the field is invalid. One attribute, both audiences.
Socratic
Discussion prompt
Text inputs add one state the Button doesn't have: error. It's not decoration - a form field must be able to say "this value is wrong" both visually and to a screen reader.
Suppose that were not true. What is the first thing in Session 4: Radix & Component Anatomy that would stop working?
Hint: Follow it one step downstream. The answer is whatever was quietly relying on it.
Answer:
The aria-invalid hook does double duty: it drives the red styling AND tells assistive tech the field is invalid. One attribute, both audiences.
Intuition
Figure (svg): A card outline containing a labeled input and a button, showing small components nested inside a larger block
Configured components nest into bigger pieces. Aster's newsletter card is a Card holding a labeled Input and a Button - three configured components, one block.
That's the next session's topic. But notice: because each inner component already handles its own states, the block inherits correct behavior for free. Good anatomy compounds.
Explain it
Discussion prompt
Explain Components compose into blocks 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:
Configured components nest into bigger pieces. Aster's newsletter card is a Card holding a labeled Input and a Button - three configured components, one block.
Concept
A Card is a container - it's not interactive, so it has no hover, focus, active, or disabled. Its 'anatomy' is layout: padding, radius, border, and slots for a header, content, and footer.
The rule isn't 'always draw five states' - it's 'draw the states this component actually has.' Ask what a user can do to it. A Card: nothing. A Button: five things.
| component | interactive? | states to draw |
|---|---|---|
| Button | yes | all five |
| Input | yes | default, focus-visible, disabled, error |
| Card | no | just its default layout |
Analogy
Discussion prompt
Explain Not every component has five states 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:
A Card is a container - it's not interactive, so it has no hover, focus, active, or disabled. Its 'anatomy' is layout: padding, radius, border, and slots for a header, content, and footer.
Worked example
The size = icon Button has no text label. That's a real accessibility gap: a screen reader would announce nothing. This is a headless-limit you must fill yourself.
// Wrong: no accessible name
<Button size="icon"><Trash /></Button>
// Right: give it an aria-label
<Button size="icon" aria-label="Delete"><Trash /></Button>Radix gives you focus and keyboard handling, but it can't invent a label for your glyph. The aria-label is the one piece of accessibility that's on you.
Anomaly
Predict first
A student writes this, and it looks reasonable:
You ship Aster's delete icon button relying on the glyph alone to convey meaning.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: It looks fine and works with a mouse - the trash glyph reads as 'delete' to sighted users.
Add an aria-label so the button has an accessible name.
Why: It looks fine and works with a mouse - the trash glyph reads as 'delete' to sighted users.
Trap
You ship Aster's delete icon button relying on the glyph alone to convey meaning.
Render <Button size="icon"><Trash /></Button>
Why: It looks fine and works with a mouse - the trash glyph reads as 'delete' to sighted users.
A screen-reader user reaches it
Why: It announces as 'button' with no name. There's no text and no label, so its purpose is invisible to them.
Add an aria-label so the button has an accessible name.
Render <Button size="icon" aria-label="Delete">
Why: Now it announces as 'Delete, button' - the meaning the glyph carried is available to everyone.
Note this in the component's state doc
Why: The icon size always needs a label; document it so no one forgets on the next icon button.
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.
ButtonRed as a brand-new component.Section
Part 5 · Pattern
Ranking
Put in order
These are the steps of The configure-a-component recipe, scrambled. Put them back in order before the next slide shows you.
showIcon) and pick the size.--primary, --ring, etc., so branding is a token change, not per-component paint.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
Whenever Aster needs a component, configure the kit one - never fork a new component. Four moves, in order:
showIcon) and pick the size.--primary, --ring, etc., so branding is a token change, not per-component paint.| state | visual change | Tailwind hook |
|---|---|---|
| hover | fill shifts | hover:bg-primary/90 |
| focus-visible | keyboard ring appears | focus-visible:ring-2 |
| disabled | dim + no clicks | disabled:opacity-50 disabled:pointer-events-none |
Edge cases
Discussion prompt
The configure-a-component 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:
Whenever Aster needs a component, configure the kit one - never fork a new component. Four moves, in order:
Elimination
Eliminate the wrong options
Radix Primitives are described as "headless." What does that mean for what you get?
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: Headless means the primitive supplies behavior and accessibility - focus management, keyboard navigation, ARIA roles and states - with no visual styling. You (or shadcn on your behalf) add the Tailwind skin.
Check
A teammate says "let's use Radix so we don't have to style anything." What's wrong with that sentence?
Check your understanding
Radix Primitives are described as "headless." What does that mean for what you get?
Answer: A
Why: Headless means the primitive supplies behavior and accessibility - focus management, keyboard navigation, ARIA roles and states - with no visual styling. You (or shadcn on your behalf) add the Tailwind skin.
Prediction
Predict first
You want filled/outlined AND icon/no-icon, in any combination. Which property design is right?
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: A variant prop (filled/outline) plus a boolean prop showIcon.
Why: The style (filled vs outline) is a whole-style swap, so it's a variant prop. The icon is one independent feature that's on or off, so it's a boolean. Two props cover all 2x2 combinations from one component.
Check
Aster's buttons can be filled or outlined, and independently can show or hide a leading icon. How should you model that?
Check your understanding
You want filled/outlined AND icon/no-icon, in any combination. Which property design is right?
Answer: A
Why: The style (filled vs outline) is a whole-style swap, so it's a variant prop. The icon is one independent feature that's on or off, so it's a boolean. Two props cover all 2x2 combinations from one component.
Commit first
Predict first
You need a ring that shows for keyboard focus but NOT on ordinary mouse clicks. Which Tailwind hook?
Commit to an answer, then rate it — certain, fairly sure, or guessing — and write the rating down before you turn the page.
Correct: focus-visible:ring-2
Why: focus-visible fires when the browser judges a ring should show - keyboard navigation - and stays quiet for ordinary mouse clicks. That's exactly the keyboard-only ring you want.
The rating matters as much as the answer: confident-and-wrong is the combination that survives revision, because nothing about it feels like it needs revisiting.
Check
A keyboard user reports they can't tell which button is focused as they Tab through Aster. Which fix is correct?
Check your understanding
You need a ring that shows for keyboard focus but NOT on ordinary mouse clicks. Which Tailwind hook?
Answer: A
Why: focus-visible fires when the browser judges a ring should show - keyboard navigation - and stays quiet for ordinary mouse clicks. That's exactly the keyboard-only ring you want.
Prediction
Predict first
A button has only disabled:opacity-50. It looks dimmed. What's the remaining problem?
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: It still responds to hover and clicks - it needs disabled:pointer-events-none too.
Why: Opacity only changes the look. Without pointer-events-none the dimmed button still fires hover and click. A disabled state needs reduced affordance AND interaction turned off.
Check
Gordon styles Aster's disabled submit with disabled:opacity-50 and ships it. What did he miss?
Check your understanding
A button has only disabled:opacity-50. It looks dimmed. What's the remaining problem?
Answer: A
Why: Opacity only changes the look. Without pointer-events-none the dimmed button still fires hover and click. A disabled state needs reduced affordance AND interaction turned off.
Elimination
Eliminate the wrong options
An icon-only Button (size=icon) renders just a <Trash /> glyph. What's the accessibility gap you must fix?
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: Radix gives focus and keyboard behavior, but it can't name your glyph. With no text, a screen reader announces only 'button'. An aria-label provides the accessible name the visual glyph was carrying.
Check
Aster has a trash-icon button with no visible text. Radix handles its focus and keyboard. What still needs your attention?
Check your understanding
An icon-only Button (size=icon) renders just a <Trash /> glyph. What's the accessibility gap you must fix?
Answer: A
Why: Radix gives focus and keyboard behavior, but it can't name your glyph. With no text, a screen reader announces only 'button'. An aria-label provides the accessible name the visual glyph was carrying.
Section
Part 6 · Hands-on
Concept
You'll brand two Aster components - the Button and the Input - and document all five states for each. Configure the kit components; do not build new ones.
| # | milestone | deliverable |
|---|---|---|
| 1 | Aster primary Button | variant + size chosen, on brand |
| 2 | Document the 5 states | a table: state to visual change |
| 3 | Aster Input | focus-visible ring + disabled + error |
Counterexample
Discussion prompt
You'll brand two Aster components - the Button and the Input - and document all five states for each. Configure the kit components; do not build new ones.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Worked example
Your turn: configure the kit Button to be Aster's primary hero action. Pick the variant and the size, and make sure it reads the brand primary. Say out loud which variant matches "the one thing I want clicked."
Hint: the primary action is the default variant; a hero CTA wants size = lg. The fill should come from the --primary token (Aster's violet), not a hardcoded color.
<Button variant="default" size="lg">
Start a project
</Button>
// default variant fill = bg-primary = oklch(0.55 0.24 285)| self-check | should be |
|---|---|
| variant | default (the primary action) |
| size | lg (hero CTA) |
| fill source | --primary token, not a literal color |
Worked example
Your turn: for that Button, write down what changes in each of the five states. Don't ship until every row is filled - especially focus-visible.
Hint: hover shifts the fill, focus-visible adds a ring, active darkens on press, disabled dims and stops clicks. Attach each to its Tailwind hook.
| state | what changes | Tailwind hook |
|---|---|---|
| default | brand violet fill, rounded-lg | bg-primary text-primary-foreground |
| hover | fill slightly darker | hover:bg-primary/90 |
| focus-visible | ring appears (keyboard) | focus-visible:ring-2 focus-visible:ring-ring |
| active | fill darker on press | active:bg-primary/80 |
| disabled | dim + no clicks | disabled:opacity-50 disabled:pointer-events-none |
Self-check: did you include focus-visible (not hover, not plain focus) for the ring, and did disabled turn off pointer-events? If yes, all five states are covered.
Trade off
Comparison matrix
From Milestone 2 — document the five states: every row here is a choice with a cost. Fill the Tailwind hook column, then say which row you would actually pick and what you give up for it.
| state | what changes | Tailwind hook |
|---|---|---|
| default | brand violet fill, rounded-lg | bg-primary text-primary-foreground |
| hover | fill slightly darker | hover:bg-primary/90 |
| focus-visible | ring appears (keyboard) | focus-visible:ring-2 focus-visible:ring-ring |
| active | fill darker on press | active:bg-primary/80 |
| disabled | dim + no clicks | disabled:opacity-50 disabled:pointer-events-none |
Worked example
Your turn: brand the kit Input for Aster's contact form. Give it a visible focus-visible ring, a disabled treatment, and an error treatment using the destructive token. Same rule: configure, don't fork.
Hint: the resting border reads --input; the focus ring reads --ring; the error state swaps border and ring to --destructive. Disabled dims and blocks input.
class="border-input rounded-md
focus-visible:ring-2 focus-visible:ring-ring
disabled:opacity-50 disabled:pointer-events-none
aria-invalid:border-destructive aria-invalid:ring-destructive"| state | what changes | token / hook |
|---|---|---|
| default | quiet border | border-input |
| focus-visible | ring appears | focus-visible:ring-ring |
| disabled | dim + no typing | disabled:opacity-50 disabled:pointer-events-none |
| error | red border + ring | aria-invalid:border-destructive |
Worked example
Your turn: before you call the Button and Input done, verify them the way a real user would. Put the mouse down and drive with the keyboard only.
Hint: press Tab to move focus, look for the ring on each control, press Enter on the button, and confirm the disabled control is skipped entirely.
| do this | expect |
|---|---|
| Tab to the Button | a visible focus-visible ring |
| Tab to the Input | a visible focus-visible ring |
| Tab past a disabled control | it is skipped, no ring |
| click the Button with the mouse | NO lingering ring after |
If every row matches, your states are real - not just drawn. A ring that lingers after a mouse click means you used :focus instead of focus-visible; go fix that hook.
Worked example
Lay all your work on one Figma frame: the Button in its five states, then the Input in its four. A reviewer should read every state at a glance.
Figure (svg): A state sheet: a row of five button states across the top and a row of input states below, each labeled
If a teammate can see all nine states without asking you a question, the components are documented - not just designed.
Worked example
Homework: walk Aster's real page - navbar, hero, feature grid, pricing, footer - and assemble the states each interactive piece needs.
Cover: the nav links and menu (hover + focus-visible), the buttons across variants (all five states each), and the form fields in pricing/contact (focus-visible ring, disabled, error).
| piece | states to document |
|---|---|
| nav links | default, hover, focus-visible |
| hero + pricing buttons | default, hover, focus-visible, active, disabled |
| contact form fields | default, focus-visible, disabled, error |
Rule for all of it: configure the kit components via their properties and tokens. If you catch yourself making ButtonSomething, stop - that's a variant or a boolean.
Comparison
Comparison matrix
From Homework — assemble the states Aster needs: refill the states to document column from what you know. The rest of the table is as it appeared.
| piece | states to document |
|---|---|
| nav links | default, hover, focus-visible |
| hero + pricing buttons | default, hover, focus-visible, active, disabled |
| contact form fields | default, focus-visible, disabled, error |
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — What "Headless" Means · Component Properties · The States That Matter · Beyond the Button · The Recipe · Your Turn: Brand the Aster Kit. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
focus-visible for the keyboard ring, not hover or plain :focus.| state | visual change | Tailwind hook |
|---|---|---|
| default | resting brand look | base classes |
| hover | fill shifts | hover:bg-primary/90 |
| focus-visible | keyboard ring | focus-visible:ring-2 |
| active | pressed, darker | active:bg-primary/80 |
| disabled | dim + no clicks | disabled:opacity-50 disabled:pointer-events-none |
Next session: composition - assembling these configured components into blocks and pages, and making them reflow responsively.
Want this taught 1-on-1? Alexander tutors shadcn/ui + Figma — $55/session, free consultation.