Topic 11.5
Component Libraries and Headless UI
In one line
Component libraries give you ready-made, accessible buttons, dialogs, menus and tabs: styled kits like MUI and Chakra come with a look, headless libraries like Radix and React Aria give behaviour and accessibility with no styles, and shadcn/ui copies styled source files into your project so you own them.
Think of it like this
Furnishing a flat. You can buy a fully furnished flat (everything matches, move in today, but it looks like every other flat from that builder), buy flat-pack furniture frames and paint them yourself (sturdy, safe and tested, in your colours), or have a carpenter build pieces in your own home from a proven design (you own every plank and can change anything). These are the three kinds of React UI kits: styled libraries, headless libraries, and copy-in components.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Component library
- A package of ready-made UI components such as buttons, dialogs, menus and tables.
- Styled library
- A component library that comes with its own visual design, like MUI or Chakra.
- Headless library
- A library that gives components' behaviour and accessibility but no styles. You add the look.
- WAI-ARIA Authoring Practices
- The W3C guide describing how widgets like tabs, menus and dialogs must behave for keyboard and screen-reader users.
- Prop getter
- A function from a headless hook, like
getTabProps(i), that returns all the props one element needs. - Roving tabindex
- Only the active item in a group has
tabIndex={0}; the rest have-1, and arrow keys move between them. - Copy-in components
- Components whose source is copied into your project (shadcn/ui), so you own and edit the code.
- Variant
- A named visual option a component accepts, such as
variant="outline"orsize="sm".
Step by step
01A styled library: install, theme, use
MUI components look finished out of the box. A theme changes colours, fonts and shapes for every component at once; the sx prop styles one instance.
import { Button, ThemeProvider, createTheme } from "@mui/material";
const theme = createTheme({ palette: { primary: { main: "#0b7285" } } });
export default function App() {
return (
<ThemeProvider theme={theme}>
<Button variant="contained">Save</Button>
<Button variant="outlined" sx={{ ml: 1 }}>Cancel</Button>
</ThemeProvider>
);
}02A headless library: assemble the parts, style the states
Radix gives a dialog's behaviour (focus trap, Escape, portal, ARIA) and no styles. You add classes, and style open and closed states through data-state.
import * as Dialog from "@radix-ui/react-dialog";
export function ConfirmDelete({ onConfirm }) {
return (
<Dialog.Root>
<Dialog.Trigger className="btn btn-danger">Delete</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="overlay" />
<Dialog.Content className="dialog">
<Dialog.Title>Delete this file?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Close className="btn">Cancel</Dialog.Close>
<button className="btn btn-danger" onClick={onConfirm}>Delete</button>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
/* CSS: .dialog[data-state="open"] { animation: pop-in 150ms ease-out; } */03Copy-in components: run the CLI, own the files
shadcn/ui's CLI sets up components.json, the cn helper and your theme variables, then copies each component you ask for into components/ui/. Open the files: they're ordinary React and Tailwind code.
$ npx shadcn@latest init
$ npx shadcn@latest add button dialog
# Your project now contains (and you own):
# components.json
# src/lib/utils.ts -> the cn() helper
# src/components/ui/button.tsx
# src/components/ui/dialog.tsx04Variants are the component's style API
Inside the copied button.tsx, cva maps variant and size to Tailwind classes. To add a brand variant, you edit the object; every caller then uses <Button variant="brand">.
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",
outline: "border bg-background hover:bg-accent",
destructive: "bg-destructive text-white hover:bg-destructive/90",
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", icon: "size-9" },
},
defaultVariants: { variant: "default", size: "default" },
});05Pick by asking the right questions
Work down the questions in order. Accessibility is not optional, so it comes first; the look and the styling approach decide between the rest.
Try it yourself
- 1
Add a third skin
In the tabs example, render
<Tabs skin="boxed" />and write CSS for.boxed .tab[aria-selected="true"]. You change no JavaScript: that's the headless promise. - 2
Support vertical tabs
Make
useTabsalso handleArrowDownandArrowUp, and add"aria-orientation": "vertical"to the list props. Predict what a screen reader user gains fromaria-orientation. - 3
Own the variants
In the Button example, add
size: { icon: "btn-icon" }with CSS.btn-icon { width: 36px; height: 36px; padding: 0; }and use it for the settings button.
Code & diagrams
Each line shows the focused tab and every tab's `aria-selected`/`tabIndex`. Only the selected tab is reachable with Tab (roving tabindex), arrows wrap around, and the visible panel is labelled by its tab. Both skins style the same `aria-selected` attribute. Click into a tab bar and try the arrow keys, Home and End yourself.
Preview
Console
Nothing logged yet.Expected console output
ArrowRight -> Specs false/-1 true/0 false/-1
ArrowRight -> Reviews false/-1 false/-1 true/0
ArrowRight -> Overview true/0 false/-1 false/-1
visible panel: What the product does. | labelled by: OverviewCallers choose from named variants instead of passing colours. Add a `brand` entry to `variant` and use `<Button variant="brand">`: that one edit is the whole point of owning the file. The icon-only button has an `aria-label` so screen readers can name it.
Preview
Console
Nothing logged yet.Expected console output
btn btn-solid btn-md
btn btn-danger btn-smBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Make every tab focusable
In getTabProps, change tabIndex: i === selected ? 0 : -1 to tabIndex: 0.
Myth vs fact
Myth
Adding role attributes makes a custom widget accessible.
Fact
Roles promise behaviour: role="tab" tells a screen reader that arrow keys work. If you don't implement the keyboard handling, focus management and states, the role makes things worse. That behaviour is what headless libraries provide.
Myth
shadcn/ui is a component library dependency.
Fact
It's a CLI and a registry that copy source into your repo. There's no shadcn runtime package to upgrade; the copied files depend on Radix (or Base UI), Tailwind, cva and clsx/tailwind-merge.
Myth
Using MUI means every app looks like Google.
Fact
Themes can change colours, typography, shapes and component defaults a lot. But matching a very different design costs more effort than starting from a headless library.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Headless libraries expose state as attributes (
data-state,data-disabled,data-highlightedin Radix;data-pressed,data-focus-visible,data-selectedin React Aria Components) precisely so styling stays in CSS: Tailwind'sdata-[state=open]:variants or[data-state="open"]selectors in a CSS Module. - ▸
Radix's
asChildprop (also used by shadcn/ui) merges a component's props and behaviour onto its single child instead of rendering its own element, so<Dialog.Trigger asChild><Link …/></Dialog.Trigger>doesn't nest a button inside a link. It's implemented withcloneElementand ref merging. - ▸
React Aria normalises press events across mouse, touch, pen, keyboard and screen readers (
usePress), handles right-to-left layouts and localised date and number formats, which is why it's chosen for products with strict accessibility or internationalisation requirements. - ▸
With Server Components, interactive primitives must be Client Components (they use state and effects), but they can be leaves: a Server Component page can render a
"use client"<Dialog>and pass server-rendered children into it (Phase 20).
Remember this
- 1
Why not build everything yourself? A button is easy; a dialog is not. It must trap keyboard focus inside, return focus to the button that opened it, close on Escape, hide the rest of the page from screen readers, stop the page behind from scrolling, and render in a portal (Topic 8.5). A combobox (a searchable select) has dozens of keyboard and screen-reader rules. These rules come from the WAI-ARIA Authoring Practices, a guide describing how each widget pattern must behave. Libraries have tested them across browsers and assistive technology; your one-afternoon version usually hasn't.
- 2
Styled component libraries ship components with a complete visual design. MUI (Material UI) implements Google's Material Design and has a huge catalogue including data grids and date pickers; you customise it with a theme (
createTheme) and thesxprop. Chakra UI offers style props (<Box p={4} bg="teal.500">) and a theme of design tokens. Mantine and Ant Design are similar. They're the fastest path to a complete, consistent app, especially for internal tools. The costs: your app looks like the library unless you fight it, the bundle is bigger, and many use runtime CSS-in-JS (Topic 11.4). - 3
Headless UI libraries give you behaviour, state and accessibility with no styles. Radix Primitives expose parts you assemble (
Dialog.Root,Dialog.Trigger,Dialog.Content) and describe their state indata-state="open"attributes for you to style. React Aria (from Adobe) offers hooks (useButton) and thereact-aria-componentspackage, with very thorough keyboard, touch, internationalisation and screen-reader support. Headless UI (from the Tailwind team) and Base UI are others. You bring the look with any styling method from this phase. - 4
The headless pattern is worth knowing even without a library: a hook owns the behaviour and returns prop getters, functions like
getTabProps(i)that return everything an element needs (role,aria-selected,tabIndex,id, handlers). Your component spreads them onto whatever markup it likes. In the live example, oneuseTabshook powers two tab bars that look nothing alike, and both follow the ARIA tabs pattern: arrow keys move between tabs, only the selected tab is in the Tab order (roving tabindex), and each panel is labelled by its tab. - 5
shadcn/ui is not a package you install. Its CLI copies source files into your project (for example
components/ui/button.tsx), built from Radix (or Base UI) primitives, Tailwind classes,cvafor variants and thecnhelper from Topic 11.3. You own the code: change a variant, add a size, delete what you don't use. Upgrades are manual (you re-run the CLI and review the changes), which is the price of ownership. Variants are the shared idea across all these libraries: a component exposes a few named options (variant="outline",size="sm") instead of accepting arbitrary styles, which keeps the design consistent. - 6
Choosing one: start from accessibility (does it follow the ARIA patterns, is it tested with screen readers?), then design fit (do you have your own brand design, or is Material fine?), styling fit (does it match your approach: Tailwind, CSS Modules, CSS-in-JS?), Server Component support, bundle size, maintenance activity and documentation. A common modern default: Tailwind + shadcn/ui for product apps with a custom design, MUI or Mantine for data-heavy internal tools, React Aria when accessibility requirements are strict. Whatever you choose, wrap library components in your own (
<AppButton>) only where you need to enforce your design rules.
Explain it without notes
Why use a component library for dialogs, menus or comboboxes instead of building them yourself?
What is the difference between a styled library, a headless library and copy-in components? Give an example of each.
Explain the prop-getter pattern of headless hooks.
How would you choose a component library for a new product?
Practice
Write a headless useToggle hook for a switch that returns getSwitchProps() with role="switch", aria-checked and an onClick, and use it in a Switch component styled through [aria-checked="true"].
Using the mini cva from the second example, create badgeVariants with tone (neutral, success, danger) and size (sm, md), defaulting to neutral and sm. What does badgeVariants({ tone: "success" }) return?
Trade-offs
- ↔
Styled libraries are the fastest route to a complete UI, but customising them heavily is slow and their bundles and runtime styling cost more.
- ↔
Headless libraries and copy-in components give full design control with tested accessibility, but you write and maintain all the styles, and copy-in code doesn't upgrade itself.
Done when you can
I can explain why complex widgets should come from a tested library.
I can tell styled, headless and copy-in libraries apart and name examples.
I can write a headless hook with prop getters and a roving tabindex.
I can build a component with named variants.
I can compare libraries on accessibility, design fit, styling fit and Server Component support.