Command Palette

Search for a command to run...

Headless UI Components: Why Using Libraries like shadcn/ui or Radix UI Makes a Difference

Headless UI Components: Why Using Libraries like shadcn/ui or Radix UI Makes a Difference

A practical guide to headless UI in React: what it is, why it matters, and how to build accessible, themeable components with shadcn/ui and Radix UI in Next.js.

8 min read·Component Architecture

Headless UI is behavior without opinions about style. Instead of shipping pre-styled components, headless libraries give you accessibility‑correct primitives—focus traps, ARIA roles, keyboard interactions—so your team controls visuals via CSS, Tailwind, or tokens. Two of the most popular approaches in the React ecosystem are Radix UI (low‑level primitives) and shadcn/ui (copy‑paste, ownable components built on top of Radix and Tailwind).

This article explains why headless UI matters, how it compares to “styled” kits, and how to integrate shadcn/ui and Radix UI into a scalable Next.js architecture with tokens, theming, testing, and performance in mind.

Related reading:


Table of Contents

  1. What is headless UI (and why now)?
  2. Headless vs styled component libraries
  3. Radix UI vs shadcn/ui: which one to pick?
  4. Install & set up (pnpm + Next.js)
  5. Tokens and theming with CSS variables and Tailwind
  6. Compose accessible primitives (Dialog, Dropdown, Tooltip)
  7. shadcn/ui: own your components
  8. SSR & App Router considerations
  9. Testing a11y and behavior
  10. Performance & bundle hygiene
  11. Common pitfalls & how to avoid them
  12. Migration playbook (from styled kits to headless)
  13. Decision matrix: Radix, shadcn/ui, Headless UI, React Aria, Ark UI
  14. FAQ

What is headless UI (and why now)?

Headless UI libraries ship logic + accessibility, leaving styling to the consumer. You get:

  • Correct ARIA roles/props, keyboard navigation, and focus management.
  • Unstyled outputs that drop into your design system without fighting defaults.
  • Composability that scales from simple buttons to complex composite widgets (menus, comboboxes, dialogs).

As teams adopt design tokens, Tailwind CSS, and App Router (RSC), headless UI keeps your bundle lean and visuals consistent while preserving a11y guarantees.


Headless vs styled component libraries

Styled kits (e.g., MUI, Ant Design, Chakra) ship visuals and structure out of the box. They are fast to start with but can be expensive to re‑theme deeply.

Headless (Radix UI, React Aria, Headless UI, Ark UI) ship behaviors only. You bring styles and tokens, which:

  • Keeps branding consistent across apps.
  • Avoids wrestling with opinionated CSS or theme overrides.
  • Prevents vendor‑lock on “that look.”

When to prefer headless: You have a unique brand, plan multi‑product reuse, or need strict accessibility across bespoke components.


Radix UI vs shadcn/ui: which one to pick?

  • Radix UI: Low‑level, unstyled primitives (Dialog, Popover, Dropdown Menu, Tabs, Tooltip, etc.). You compose and style with your system.
  • shadcn/ui: A generator that copies ownable components into your codebase. Under the hood they use Radix primitives + Tailwind with clean variants and tokens.

Choose Radix UI if you want maximum control and plan to craft a bespoke design system from primitives up.
Choose shadcn/ui if you want a solid, modern base you can own and customize, with Tailwind already wired in.


Install & set up (pnpm + Next.js)

# Radix primitives
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tooltip
 
# shadcn/ui (scaffold into your repo)
pnpm dlx shadcn@latest init
pnpm dlx shadcn add button dialog input dropdown-menu tooltip

Add Tailwind if you haven’t:

pnpm add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Point your tailwind.config.ts at app, components, and any generated paths.


Tokens and theming with CSS variables and Tailwind

Headless shines when paired with tokens. Define CSS variables for color, spacing, radius, and motion, then map Tailwind to those variables.

// tailwind.config.ts (excerpt)
export default {
  darkMode: ["class", '[data-theme="dark"]'],
  content: ["./app/**/*.{ts,tsx,mdx}", "./components/**/*.{ts,tsx,mdx}"],
  theme: {
    extend: {
      colors: {
        bg: { DEFAULT: "var(--color-bg-default)" },
        fg: {
          DEFAULT: "var(--color-fg-default)",
          muted: "var(--color-fg-muted)",
        },
        brand: { DEFAULT: "var(--color-brand-primary)" },
      },
      borderRadius: {
        sm: "var(--radius-sm)",
        md: "var(--radius-md)",
        lg: "var(--radius-lg)",
      },
    },
  },
};

Switch themes by toggling class="dark" or data-theme="dark" on <html>.


Compose accessible primitives (Dialog, Dropdown, Tooltip)

Radix Dialog (unstyled behavior) + Tailwind classes for visuals:

"use client";
import * as Dialog from "@radix-ui/react-dialog";
 
export function Modal({
  title,
  children,
}: {
  title: string;
  children: React.ReactNode;
}) {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button className="rounded-md bg-brand px-4 py-2 text-white">
          Open
        </button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out" />
        <Dialog.Content
          className="fixed left-1/2 top-1/2 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-bg p-6 shadow-xl focus:outline-none"
          aria-describedby={undefined}
        >
          <Dialog.Title className="text-lg font-semibold text-fg">
            {title}
          </Dialog.Title>
          <div className="mt-4">{children}</div>
          <Dialog.Close asChild>
            <button className="mt-6 rounded-md border border-fg/20 px-4 py-2">
              Close
            </button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Radix Dropdown Menu with keyboard navigation baked in:

"use client";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
 
export function UserMenu() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger className="rounded-md border px-3 py-2">
        Menu
      </DropdownMenu.Trigger>
      <DropdownMenu.Content className="z-50 min-w-[180px] rounded-md border bg-bg p-1 shadow-md">
        <DropdownMenu.Item className="rounded px-2 py-1.5 focus:bg-fg/10">
          Profile
        </DropdownMenu.Item>
        <DropdownMenu.Item className="rounded px-2 py-1.5 focus:bg-fg/10">
          Settings
        </DropdownMenu.Item>
        <DropdownMenu.Separator className="my-1 h-px bg-fg/10" />
        <DropdownMenu.Item className="rounded px-2 py-1.5 text-red-600 focus:bg-red-50">
          Sign out
        </DropdownMenu.Item>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  );
}

Radix Tooltip (respect reduced motion; keep focus visible):

"use client";
import * as Tooltip from "@radix-ui/react-tooltip";
 
export function Hint({
  label,
  children,
}: {
  label: string;
  children: React.ReactNode;
}) {
  return (
    <Tooltip.Provider delayDuration={120}>
      <Tooltip.Root>
        <Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
        <Tooltip.Content className="rounded-md bg-black px-2 py-1 text-xs text-white">
          {label}
          <Tooltip.Arrow className="fill-black" />
        </Tooltip.Content>
      </Tooltip.Root>
    </Tooltip.Provider>
  );
}

shadcn/ui: own your components

shadcn/ui copies components into your repo. You then:

  • Align tokens and classes with your Tailwind config.
  • Add variants (with class-variance-authority) for consistent theming.
  • Edit API/props to match your usage—since you own the source, it’s just code.
pnpm dlx shadcn add button dialog input tooltip dropdown-menu

Tip: Keep components small and composable. Put complex patterns (forms, page shells) in a /patterns folder, and expose primitives from /components.


SSR & App Router considerations

  • Keep interactive components as Client Components; render data on the server where possible to reduce hydration.
  • For route‑bound modals, use parallel routes (@modal) and intercepting routes ((..)segment) so dialogs have URLs and work with Back/Forward.
  • Use loading.tsx for skeletons and Suspense to stream late sections.

Testing a11y and behavior

  • jest-axe / axe-core: catch ARIA and contrast issues.
  • React Testing Library: assert roles, labels, and keyboard flows.
  • Playwright: test route‑level modals and menu navigation with Arrow/Enter/Escape.
  • Storybook: document variants and run a11y checks in CI.
import { render } from "@testing-library/react";
import { Modal } from "./Modal";
test("dialog has title and closes", () => {
  // open with user events, assert role="dialog" and title present
});

Performance & bundle hygiene

  • Prefer server‑rendered data with small client islands.
  • Avoid shipping multiple full UI kits—choose one and supplement with headless primitives.
  • Tree‑shake icons; lazy‑load heavy charts/editors.
  • Use transforms/opacity for animations; respect prefers-reduced-motion.

Common pitfalls & how to avoid them

  1. Inconsistent styling across instances → Centralize variants with cva and tokens.
  2. Focus traps broken → Use library primitives (Radix) rather than hand‑rolled modals.
  3. Menu/tooltip overlap issues → Ensure proper stacking context and portal containers.
  4. No URL for modals → Use App Router intercepting routes to make modals shareable.
  5. Global providers everywhere → Wrap only components that need client context.

Migration playbook (from styled kits to headless)

  1. Inventory tokens (colors, spacing, radius, typography).
  2. Replace dialogs, menus, tooltips first—high a11y risk and easy wins with Radix.
  3. Swap buttons/inputs to ownable shadcn/ui components with variants.
  4. Document in Storybook, add jest-axe checks, and release as a versioned package.
  5. Tackle complex widgets (combobox, table) with incremental refactors.

Decision matrix: Radix, shadcn/ui, Headless UI, React Aria, Ark UI

LibraryWhat it providesStylingA11yBest for
Radix UIUnstyled, accessible primitivesYour CSS/TailwindCustom design systems, full control
shadcn/uiOwnable components (Radix + Tailwind)Tailwind classesFast bootstrap with source ownership
Headless UIUnstyled components (by Tailwind Labs)Your CSS/TailwindTailwind‑first projects
React AriaLow-level a11y hooks (Adobe)Your stylingFine-grained control, ARIA hooks
Ark UIHeadless + multi-frameworkYour stylingFramework portability (React/Vue/Solid)

FAQ

Is headless UI slower to build?
Early on, a styled kit is faster. But headless saves time long‑term by preventing theme overrides and one‑off hacks.

Can I mix headless and styled libraries?
Yes—but keep one as the primary surface. Use headless for complex interactions you want to fully control.

How do I handle dark mode and themes?
Use CSS variables for tokens and toggle data-theme="dark" or a class on the root. Tailwind reads the same tokens, so every component updates instantly.

What about accessibility testing?
Pair headless primitives with jest-axe and Playwright. Test keyboard flows and reduced motion preferences.


Next steps

  • Install Radix and scaffold a Dialog + Dropdown + Tooltip set.
  • Generate shadcn/ui components; align Tailwind tokens and variants.
  • Add Storybook docs, a11y tests, and a simple CI pipeline before rolling into product.

Read more like this

Headless UI Components: Why Using Libraries like shadcn/ui or Radix UI Makes a Difference | Ruixen UI