EmpireUI
Get Pro
← Blog9 min read#headless ui#radix#react

Headless UI Patterns in React: Radix, Ark UI, Base UI Compared

Radix, Ark UI, and Base UI all promise headless accessible components — but they make very different bets on API design, WAI-ARIA coverage, and DX.

developer writing React component code on laptop screen at desk

What "Headless" Actually Means (And What It Doesn't)

Headless UI is a pattern, not a product. The idea is simple: give me all the logic — keyboard navigation, focus management, ARIA attributes, open/close state — but let me own every pixel of the visual output. No default styles, no !important wars, no wrestling with a third-party CSS file.

In practice, that split between behavior and presentation turns out to be harder to draw than it sounds. Where does "behavior" end and "default layout" begin? A popover needs to be positioned. A combobox needs a list that appears near the trigger. Every headless library makes a slightly different call about where to stop, and those decisions ripple through your entire component API.

The three libraries this article covers — Radix UI (v1.1+), Ark UI (v3.x), and MUI Base UI (v1.0-beta) — all call themselves headless but they've answered that question in meaningfully different ways. Worth knowing which camp a library falls into before you're three sprints deep.

Quick aside: "unstyled" and "headless" get used interchangeably in blog posts, but they're not quite the same. Unstyled just means no CSS ships. Headless implies the component surface is logic-only, often via render props or compound component patterns. Some libraries are both; some are only one.

Radix UI: The One Everyone Reaches For First

Radix has been the default choice since around 2022, and honestly, the DX is still hard to beat. You get a compound component API that feels natural — <Dialog.Root>, <Dialog.Trigger>, <Dialog.Content> — and zero styling opinions. Drop it into any project and it stays out of your way.

The WAI-ARIA coverage in Radix is thorough. Dialogs trap focus correctly, select menus announce options to screen readers, tooltips link via aria-describedby. You don't have to think about most of it, which is the point. By 2026, WCAG 2.2 is the baseline most teams target, and Radix passes the non-trivial patterns without you writing 80 lines of keyboard handler code yourself.

import * as Dialog from '@radix-ui/react-dialog';

export function ConfirmModal({ onConfirm }: { onConfirm: () => void }) {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button className="btn-primary">Delete item</button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="dialog-overlay" />
        <Dialog.Content className="dialog-content">
          <Dialog.Title>Are you sure?</Dialog.Title>
          <Dialog.Description>This can't be undone.</Dialog.Description>
          <button onClick={onConfirm}>Yes, delete</button>
          <Dialog.Close asChild>
            <button>Cancel</button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

The asChild prop is Radix's secret weapon. It merges props onto your own element instead of wrapping it in an extra DOM node — no <button> inside a <button>, no broken semantics, no extra 4px of mystery padding. Other libraries have since copied this pattern, which tells you something.

That said, Radix does have gaps. Form primitives are missing — you won't find a date picker or combobox with async search in the core package. The ecosystem has filled some of this (shadcn/ui being the obvious one), but if you need a full primitive set out of one package, Radix isn't quite there yet.

Ark UI: Full Primitive Set, State Machine Core

Ark UI from Chakra UI's team takes a different architectural bet: every primitive is powered by Zag.js state machines under the hood. If you've ever debugged a menu that half-opened because two event handlers fired in the wrong order, you understand why this matters. State machines make invalid states literally unrepresentable.

The component catalogue is much wider than Radix. In v3.x you get a date picker, color picker, number input, pin input, splitter, tree view — primitives that Radix simply doesn't ship. For a design system that needs to cover complex data-entry flows, that gap matters. A lot.

import { DatePicker } from '@ark-ui/react/date-picker';

export function AppDatePicker() {
  return (
    <DatePicker.Root>
      <DatePicker.Label>Appointment date</DatePicker.Label>
      <DatePicker.Control>
        <DatePicker.Input />
        <DatePicker.Trigger>
          <CalendarIcon />
        </DatePicker.Trigger>
      </DatePicker.Control>
      <DatePicker.Positioner>
        <DatePicker.Content>
          <DatePicker.View view="day">
            <DatePicker.Context>
              {(api) => (
                <>
                  <DatePicker.ViewControl>
                    <DatePicker.PrevTrigger>Prev</DatePicker.PrevTrigger>
                    <DatePicker.ViewTrigger>
                      <DatePicker.RangeText />
                    </DatePicker.ViewTrigger>
                    <DatePicker.NextTrigger>Next</DatePicker.NextTrigger>
                  </DatePicker.ViewControl>
                  <DatePicker.Table>...</DatePicker.Table>
                </>
              )}
            </DatePicker.Context>
          </DatePicker.View>
        </DatePicker.Content>
      </DatePicker.Positioner>
    </DatePicker.Root>
  );
}

Look, that's more JSX than most people want to write for a date picker. Ark's compound API is verbose by design — every sub-part is an explicit component you compose, which gives you surgical control but can make simple use cases feel over-engineered. Whether that tradeoff is worth it depends entirely on how customized your designs get.

One more thing — Ark UI supports React, Solid, and Vue from one machine core. If your team has any cross-framework requirements, or you're building primitives that need to work in a Solid Islands architecture alongside your React app, Ark is basically the only headless option that handles that without a full rewrite.

MUI Base UI: The Underdog Worth Watching

Base UI (the package formerly known as MUI Base, now at v1.0-beta as of 2026) is Material UI's headless layer extracted into its own package. It ships with no MUI styles, no Material Design tokens, nothing — just the logic. React team members have even contributed to it, and it shows in how carefully the React 18 concurrent features are handled.

The API style differs from both Radix and Ark. Base UI leans on hooks more heavily. You can use useSelect, useMenu, useSlider directly if you want full control, or you can use the component wrappers. That dual surface is genuinely useful when you're integrating into an existing design system that already has its own DOM structure you can't change.

import { useSelect } from '@base-ui-components/react/select';

function CustomSelect({ options }: { options: string[] }) {
  const { getButtonProps, getListboxProps, value } = useSelect({
    options,
    defaultValue: options[0],
  });

  return (
    <div>
      <button {...getButtonProps()}>{value ?? 'Pick one'}</button>
      <ul {...getListboxProps()}>
        {options.map((opt) => (
          <li key={opt}>{opt}</li>
        ))}
      </ul>
    </div>
  );
}

In practice, Base UI is still maturing. The v1.0 beta has been "coming soon" for a while, some components lack the depth of Radix's ARIA implementation, and community resources are sparse compared to Radix's ecosystem. If you're starting a new project today, it's a risk. If you're already on MUI and want to drop Material Design, it's the obvious migration path.

Worth noting: Base UI is the library backing some of the accessible patterns you'll see referenced in newer React docs. That proximity to the React team means it'll probably track React 19 features faster than independent libraries.

Accessibility: Where They Actually Differ

All three libraries advertise accessibility. The difference is in depth and correctness. Radix has the most battle-tested implementation — it's been in production in thousands of apps since 2021, and the edge cases have been found and fixed. The <Select> component alone has had 40+ issues filed and resolved around screen reader quirks in Safari + VoiceOver.

Ark's state machines give you a formal correctness guarantee that the others can't match. If the machine spec says a combobox should announce filtered count changes to aria-live, it will, every time, without an event handler racing against a state update. That said, the machine specs themselves still have gaps in a few components.

For accessible components in general — not just headless libraries — the react-aria-guide covers the WAI-ARIA patterns you should actually test. And if you're building a full design system, the component-api-design article gets into how to think about prop surfaces that don't break accessibility.

One thing none of them solve well: color contrast. That's on you. If you're building a glassmorphism or aurora design system where backgrounds shift dynamically, you need to test contrast at every state yourself — no headless library is going to catch that your frosted panel drops below 4.5:1 against a light background.

Picking the Right One for Your Project

Honestly, for most React projects in 2026, Radix is still the right call. The ecosystem is unmatched — shadcn/ui, cmdk, vaul, and a dozen other highly-used packages are built on it. You get instant familiarity for new team members, great docs, and solid accessibility out of the box. It's boring in the best way.

Go with Ark UI if: you need a date picker, color picker, or pin input without writing one from scratch; you're building for multiple frameworks; or you want state machine guarantees on complex interactive patterns. The verbosity is real, but it's the cost of that level of control.

Base UI is worth watching if you're already in the MUI ecosystem or you specifically need the hooks API to graft accessibility logic onto existing DOM structures you can't change. It's not production-ready for greenfield projects yet, but it will be.

Quick decision matrix:

| Need                          | Pick        |
|-------------------------------|-------------|
| Solid ecosystem + shadcn/ui   | Radix       |
| Date/color/pin primitives     | Ark UI      |
| Multi-framework (React+Solid) | Ark UI      |
| Hooks-first API               | Base UI     |
| Migrating from MUI            | Base UI     |
| Maximum community resources   | Radix       |

When you're done picking your primitive layer, you still need to style the output. Tools like the gradient generator and box shadow generator are useful when you're defining the visual tokens that go on top of whatever headless primitives you choose. The components give you the skeleton; your design system gives them the skin.

Patterns That Work Across All Three

Regardless of which library you pick, a few compositional patterns make headless components much more maintainable at scale. The first is wrapping primitives in your own component early. Don't scatter <Dialog.Root> throughout your app — create a <AppDialog> wrapper immediately, so you can swap the underlying primitive later without touching 60 files.

// apps/web/components/ui/dialog.tsx
import * as RadixDialog from '@radix-ui/react-dialog';
import { cn } from '@/lib/utils';

export const Dialog = RadixDialog.Root;
export const DialogTrigger = RadixDialog.Trigger;

export function DialogContent({
  children,
  className,
  ...props
}: React.ComponentPropsWithoutRef<typeof RadixDialog.Content>) {
  return (
    <RadixDialog.Portal>
      <RadixDialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm" />
      <RadixDialog.Content
        className={cn(
          'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
          'w-full max-w-md rounded-2xl bg-white p-6 shadow-xl',
          className
        )}
        {...props}
      >
        {children}
      </RadixDialog.Content>
    </RadixDialog.Portal>
  );
}

The second pattern: keep your styling co-located with your wrapper, not scattered across Tailwind classes in your pages. A dialog should know it uses backdrop-blur-sm and rounded-2xl — that's design system knowledge, not page knowledge. If you're building a glassmorphism system, check out the glassmorphism components and glassmorphism generator to define those visual tokens properly before you apply them.

Third: test with a keyboard and a screen reader before you ship, every sprint. Headless libraries give you the ARIA wiring, but you can still break it — a misplaced div wrapping a button, a z-index stacking context that hides a focus ring, a custom scroll container that breaks arrow key navigation. Automated tools catch maybe 30% of real accessibility issues.

None of this is glamorous. But if you've ever filed a support ticket because a modal couldn't be closed with Escape, or watched a user abandon a form because the date picker was unusable on mobile, you know that getting the interaction primitives right is worth the upfront investment.

FAQ

Is Radix UI the same as shadcn/ui?

No. Radix is the headless primitive library — it handles behavior and accessibility with no styles. shadcn/ui is a collection of pre-styled components built on top of Radix, using Tailwind CSS. You can use Radix without shadcn.

Can I use Ark UI with Next.js App Router?

Yes. Ark UI's React package is compatible with App Router. Mark components using Ark primitives with 'use client' since they rely on browser events and state — same as any interactive React component.

What's the difference between headless UI and React Aria?

React Aria (from Adobe) is a hooks-only library focused on accessibility primitives — no component wrappers at all. Headless UI libraries like Radix and Ark ship compound components that include the hooks internally. React Aria gives more control; headless component libraries give more convenience.

Do headless components work with CSS-in-JS libraries?

Yes, all three libraries are styling-agnostic. You can use Tailwind, CSS Modules, styled-components, vanilla CSS — whatever you're already using. They only inject a minimal amount of inline style for positioning (popovers, tooltips), which you can usually override.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Compound Component Pattern in React: Context + Sub-ComponentsReact Component API Design: Props, Variants and Compound PatternsHeadless UI Libraries in 2026: Radix, Headless UI, Ark ComparedReact Modal / Dialog: Headless UI, Radix UI and the Vanilla Way