Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~26 min· topic 5 of 6

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" or size="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.

App.jsx (MUI)whole filejsx
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.

ConfirmDelete.jsx (Radix Primitives)whole filejsx
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.

Terminalwhole filebash
$ 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.tsx

04Variants 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">.

src/components/ui/button.tsx (shortened)whole filetsx
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.

Pick by asking the right questionsdiagram
Rendering diagram…

Try it yourself

  1. 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. 2

    Support vertical tabs

    Make useTabs also handle ArrowDown and ArrowUp, and add "aria-orientation": "vertical" to the list props. Predict what a screen reader user gains from aria-orientation.

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

Headless: one behaviour hook, two completely different looks New tab

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

Press Run to see the page.

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: Overview
A copy-in Button with variants (the shadcn/ui idea) New tab

Callers 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

btn btn-solid btn-md
btn btn-danger btn-sm

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

terminal
$ Keyboard
── what you'll see ──
Pressing Tab now stops on every tab button before reaching the panel. The page still looks identical and the console shows 0 for every tabIndex.

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-highlighted in Radix; data-pressed, data-focus-visible, data-selected in React Aria Components) precisely so styling stays in CSS: Tailwind's data-[state=open]: variants or [data-state="open"] selectors in a CSS Module.

  • ▸

    Radix's asChild prop (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 with cloneElement and 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. 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. 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 the sx prop. 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. 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 in data-state="open" attributes for you to style. React Aria (from Adobe) offers hooks (useButton) and the react-aria-components package, 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. 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, one useTabs hook 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. 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, cva for variants and the cn helper 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. 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

01

Why use a component library for dialogs, menus or comboboxes instead of building them yourself?

02

What is the difference between a styled library, a headless library and copy-in components? Give an example of each.

03

Explain the prop-getter pattern of headless hooks.

04

How would you choose a component library for a new product?

Practice

01

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"].

02

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.