Skip to content

UI Components — April 2026

A Practical Guide to Radix UI: Headless Primitives, Styling, and the Multiple-Selection Question

Headless Components That Actually Work

✏️

Here is a question that sounds simple: build a dropdown menu. Clicking a trigger opens a panel of options. Selecting an option closes the panel. Done, right? Now add keyboard navigation — Arrow keys to move between options, Enter to select, Escape to close. Add focus trapping so Tab does not escape the open menu. Add screen reader announcements so the menu is usable without vision. Add positioning logic so the menu does not overflow the viewport.

Then add animation states for open and close, submenus with hover intent detection, and typeahead so typing "s" jumps to the first option starting with "s." That "simple" dropdown just became 400 lines of JavaScript and you have not written a single line of CSS yet. This is the problem Radix UI solves.

What Radix UI Actually Is

Radix UI is a collection of unstyled, accessible React component primitives. It gives you the behaviour and accessibility of complex interactive components — dialogs, dropdown menus, popovers, tabs, accordions, sliders, and more — without any opinions about how they look. You bring all the styles. Radix handles all the hard engineering.

This is what "headless" means in practice: the component manages state, keyboard interaction, focus management, ARIA attributes, and positioning. You manage colours, spacing, borders, and animation.

Does Radix UI Select Support Multiple Selection?

No — and this is the question that catches almost everyone building a form with Radix. The Select primitive is single-value by design: its value and onValueChange props work with one string, and there is no multiple prop. It models the classic single-choice select box, and that scope is deliberate.

The good news is that Radix gives you the right pieces to compose a multi-select yourself. The cleanest route is DropdownMenu with CheckboxItem — you get keyboard navigation, typeahead, and ARIA semantics for free, and you keep the menu open across toggles by cancelling the default select-and-close behaviour:

import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useState } from 'react';

const OPTIONS = ['Design', 'Build', 'SEO', 'Hosting'];

export function MultiSelect() {
  const [selected, setSelected] = useState<string[]>([]);

  const toggle = (option: string) =>
    setSelected((prev) =>
      prev.includes(option)
        ? prev.filter((item) => item !== option)
        : [...prev, option],
    );

  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        {selected.length ? selected.join(', ') : 'Select services'}
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content>
          {OPTIONS.map((option) => (
            <DropdownMenu.CheckboxItem
              key={option}
              checked={selected.includes(option)}
              onCheckedChange={() => toggle(option)}
              onSelect={(event) => event.preventDefault()} // keep the menu open
            >
              {option}
            </DropdownMenu.CheckboxItem>
          ))}
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

If you need search-as-you-type over long option lists, the other common pattern is Popover wrapping your own filterable checkbox list — the approach most shadcn/ui multi-select comboboxes take. Either way, keep the trigger text summarising the current selection, because that summary is what screen readers and sighted users alike rely on to know the state.

Why Not Just Build It Yourself?

You can. And for simple components — a toggle, a basic accordion — you probably should. But for anything involving focus management, positioning, or complex keyboard interaction, building it yourself means replicating hundreds of hours of accessibility engineering — and then maintaining that replication as ARIA patterns and browser behaviours shift underneath you.

A Radix Dialog handles focus trapping, restores focus to the trigger on close, locks body scroll, supports nested dialogs, announces content to screen readers, closes on Escape, and closes when clicking outside. Building this correctly from scratch takes days. Getting it wrong means your modal is inaccessible to anyone using a keyboard or screen reader — which is both a usability failure and, in many jurisdictions, a legal liability.

The Compound Component Pattern

Radix uses a compound component API that might feel unusual if you are used to config-object libraries. Instead of passing an array of items to a single component, you compose smaller pieces:

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

<Dialog.Root>
  <Dialog.Trigger asChild>
    <button>Edit profile</button>
  </Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay className="fixed inset-0 bg-black/40" />
    <Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded bg-white p-6">
      <Dialog.Title>Edit profile</Dialog.Title>
      <Dialog.Description>Update your details below.</Dialog.Description>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

This looks verbose at first glance, but it gives you complete control over the DOM structure and styling of every piece. You can put anything between the trigger and the content. You can wrap pieces in your own components. You can conditionally render parts. The composition model is what makes Radix endlessly flexible where config-driven libraries hit walls — the multi-select above is exactly that flexibility in action.

Styling Radix With Tailwind Data Attributes

Radix exposes data attributes on its components that reflect their state — data-state="open" on an accordion item, data-highlighted on a menu item during keyboard navigation, data-disabled on a disabled element. In Tailwind, you target these directly with the data attribute modifier:

<DropdownMenu.Item className="px-3 py-2 data-[highlighted]:bg-blue-50 data-[disabled]:opacity-50">
  Build
</DropdownMenu.Item>

This means your interactive styles are co-located with your layout styles — no separate CSS files, no styled-components, no className toggling in JavaScript. It is an absurdly clean pattern, and it works for animation too: pair data-[state=open] with your transition utilities and the open/close states style themselves.

Controlled vs Uncontrolled State

Every Radix primitive supports both modes through the same prop pattern. Leave a component uncontrolled and it manages its own state internally — pass defaultValue or defaultOpen for the starting point and let it run. Pass value and onValueChange (or open and onOpenChange for overlays) and you own the state: the component renders whatever you tell it and reports every interaction back to you.

Uncontrolled is the right default for most UI — less wiring, less state to babysit. Reach for controlled when the component has to coordinate with something outside itself: a dialog that must stay open while a mutation is in flight, tabs synced to the URL, or the multi-select above, where the selection lives in your form state rather than inside the menu. The upgrade path between modes is a prop swap, not a rewrite.

Where Radix Fits in Your Stack

If you are using shadcn/ui, you are already using Radix — shadcn components are built on Radix primitives. If you are building your own component library, Radix is the foundation you should start from. If you are building a one-off project and you need a dialog or a dropdown, Radix lets you add just that component without pulling in an entire UI framework.

Each primitive is independently installable. Need a tooltip? Install @radix-ui/react-tooltip. That is 8KB gzipped — not a 200KB component library.

Which Radix Primitives to Learn First

Start with Dialog, DropdownMenu, and Popover. These three cover 80% of the interactive overlay patterns you will encounter. Once comfortable, add Tabs, Accordion, and Select — now that you know exactly where Select's single-value boundary sits.

Then make sure you understand the Portal component and when to use it. The answer: almost always for overlays, because it renders to document.body and avoids z-index stacking context issues — the class of bug that otherwise eats an afternoon of debugging some ancestor's transform or overflow rule.

The Verdict

Radix is not glamorous. It does not give you a beautiful component out of the box. It does not have a marketing site with animated demos. What it gives you is the engineering foundation that makes building beautiful, accessible, production-grade components possible without spending weeks on the parts that users never see but absolutely depend on. That trade-off is worth it every single time.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.