We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide12 min read

Radix UI, or behaviour without appearance

Radix provides unstyled React components with accessibility built in. How composition works, the project's state after acquisition, and what Base UI means for it.

Radix UI, or behaviour without appearance

Radix solves a problem that looks minor and consumes a surprising amount of time: writing a dialog, a dropdown, or a tooltip so that it works correctly for every user.

The library supplies components stripped of styling and equipped with full behaviour: keyboard handling, attributes for screen readers, focus trapping inside a dialog, closing on the escape key, and correct placement of elements floating above content. You add the appearance yourself, however you like.

That division proved apt enough that the library became the foundation for many component kits, including today's most popular option based on copying code into your own project.

The project's state in mid 2026

Start with what carries the most practical weight for this library right now, and what older material does not mention.

Radix is not deprecated and still works. The project passed into another company's hands, though, and release pace slowed noticeably, particularly for more complex components.

In parallel Base UI appeared, built by some of the same people who built Radix, alongside teams behind other well known libraries. Releases there ship monthly, with a full time team working on the project.

In July 2026 the most popular component kit built on this layer switched its default for new projects to Base UI, while Radix remains fully supported and new components ship for both.

The practical conclusion comes in two parts. A project running on Radix needs no urgent migration, since the library is stable and still maintained. A new project deserves a deliberate choice between the two options rather than taking Radix as a given.

Composition instead of configuration

How components are built is the differentiator here and deserves understanding, since it carries over to both libraries.

Code
TypeScript
import { Dialog } from 'radix-ui'

export function ConfirmDialog({ children }) {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button className="px-4 py-2 bg-red-600 text-white rounded">
          Delete account
        </button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50" />
        <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg">
          <Dialog.Title className="text-lg font-semibold">
            Are you sure?
          </Dialog.Title>
          <Dialog.Description className="text-sm text-gray-600">
            This action cannot be undone.
          </Dialog.Description>
          {children}
          <Dialog.Close asChild>
            <button>Cancel</button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

A component is not one element with twenty properties but a set of parts you assemble yourself. That means you can change the layout, add your own elements inside, and style each part separately, without fighting a fixed template.

The property allowing you to substitute your own element for the default is the most important mechanism here. Thanks to it the button opening the dialog is your button, with your classes and your code, and the library only adds behaviour and accessibility attributes to it.

Component state is exposed as attributes in the page markup, so styling states happens through ordinary selectors, without holding it in application state.

Code
CSS
[data-state='open'] { animation: appear 150ms ease-out; }
[data-state='closed'] { animation: vanish 100ms ease-in; }

What you get in exchange for no styling

Listing this concretely pays off, since from outside it looks like "the same thing I would write myself", and the difference lies in details easily forgotten.

Keyboard handling matching what users expect: arrows in lists, an escape key closing the dialog, tab moving only inside an open dialog, space and enter working where they should.

Attributes for assistive technology, including links between a label, a description, and a control, plus announcing state changes. That is the part easiest to get wrong, since the mistake shows no visible symptom.

Returning focus to the element that opened a dialog after it closes. A detail whose absence only someone working by keyboard notices, and then notices painfully.

Placing elements floating above content while accounting for viewport edges, scrolling, and available space on each side. Writing that yourself looks like an hour of work and takes a week.

Handling cases nobody thinks about: clicks outside the area, several dialogs stacked, blocking page scroll beneath an open dialog, behaviour on touch devices.

The project's four parts

The name misleads, since it covers several things with different purposes, and online material blends them together.

The base layer is the unstyled components described above. That is the part discussed most often and the one other libraries build on.

Separately there is a styled component set, ready to use without writing any appearance of your own. That is the equivalent of complete component libraries and shares nothing with the unstyled philosophy beyond common origin.

The third part is a colour system: a set of scales designed so that shades carrying the same number play the same role regardless of hue. It can be used entirely on its own, including in a project sharing nothing else with the rest, and that is its most common use.

The fourth is an icon set in a consistent style, likewise independent of the others.

Separating those four when hunting for documentation saves a lot of confusion, since a tutorial about styled components will not help with the base layer and the reverse.

Elements floating above content

One category of component deserves a moment, since that is where the gap between the library and your own implementation runs widest.

A dropdown, a tooltip, and a context menu must appear where they fit. If the button sits near the window's bottom edge, the menu should open upwards. If near the right, it should shift left. If the page scrolls, the element should follow whatever it belongs to, or disappear.

Placement within the page structure adds to that. An element rendered inside a container with hidden overflow gets clipped, so the library moves it to the end of the document and maintains the logical link despite the physical distance.

The same category covers the drawer sliding in from a screen edge, which Radix does not ship in its own set. Most reach for Vaul, built on the dialog here and adding gestures and snap points. Check the project's maintenance status before adding it, since the author has announced they are no longer developing it.

Code
TypeScript
<Popover.Portal>
  <Popover.Content
    side="bottom"
    align="start"
    sideOffset={8}
    collisionPadding={16}
    className="rounded-lg border bg-white p-4 shadow-lg"
  >
    <Popover.Arrow className="fill-white" />
    {content}
  </Popover.Content>
</Popover.Portal>

That solves a problem which in a hand written implementation shows up as a menu cut in half and gets patched by setting ever higher layer values. The named side and alignment are a preference rather than a command: with no room the element flips to the other side by itself, and the edge padding in the last property keeps it from sticking to the window border.

The third layer is keyboard and screen reader behaviour: linking the opening element to the opened one, announcing expansion, arrow navigation wrapping at list ends, closing and returning focus.

The sum of those makes writing a correct dropdown a matter of days rather than an afternoon. That is precisely the value people reach for this library for, and the reason even teams with their own design system build it on this layer rather than from scratch.

Radix against the alternatives

OptionStrengthWeaknessPick it when
Radix UIMature, enormous deployment base, stableSlower release pace, some newer components missingA project already built on it
Base UIActive development, the same authors, newer componentsShorter history, less material onlineA new project started today
shadcn/uiReady components to copy, styling includedA layer above the above, not an alternativeA fast start with a ready look
MantineComplete styled set, many ready elementsIts own styling system to learnAn admin panel, an internal application

The third row needs explaining, since it gets confused with the others. It is not a competing library but a set of ready components built on the layer from the first two rows, copied into your project along with code and styles. Choosing it, you also choose one of the layers underneath.

Choosing between the first two rows comes down to the project's age. Leave existing Radix code alone, since the library is stable and supported. Start a new project on Base UI, since that is where development goes, where missing components appear, and where the most popular kit's default now points.

Migration and coexistence

If you are weighing a move, knowing how large the work is and whether it is needed at all pays off.

Both libraries' interfaces are similar, since they grew from the same thinking, while not being identical. Component part names, how state is passed, and some properties differ, so migration means rewriting component by component rather than swapping an import.

The good news is that both libraries can run side by side in one project. That lets you move code gradually, starting with components that need changes anyway, rather than planning one large rewrite.

An order that works in practice: the simplest and most used components first, since you learn the differences on them more cheaply. Then those where you find something missing in your current version. Leave the most elaborate for last, since the differences run largest there.

Do not migrate because something is newer. Migrate when you need a component your current version lacks, or when you hit a bug with no prospect of a fix.

Forms and accessibility in practice

The library gives a solid foundation, and the biggest difference still comes from a few things you must add yourself.

A label linked to its control is the basic one, easily forgotten with custom components. A visual marker above a field does not suffice, since a screen reader will not read it together with the field without an explicit link between them.

Code
TypeScript
<label htmlFor="email">Email address</label>
<input
  id="email"
  type="email"
  aria-describedby={error ? 'email-error' : undefined}
  aria-invalid={error ? true : undefined}
/>
{error && (
  <p id="email-error" role="alert">
    {error}
  </p>
)}

An error message must be linked too, and differently from the label. Without that a screen reader user learns the form failed validation but not which field failed. The role on the paragraph in the last line additionally makes the message announced the moment it appears, rather than only once the reader's cursor reaches it.

Focus order should match visual order. A layout rearranged by styles so that the visually first field comes third in the markup works correctly with a mouse and confusingly with a keyboard.

Colour contrast remains entirely yours, since the library contributes no colours. Grey text on lighter grey looks tidy in a mockup and can be unreadable on a screen in sunlight.

The last thing is checking all of it in practice. Walking through a form using only the keyboard takes two minutes and catches more problems than any automated tool.

Code
JavaScript
document.addEventListener('focusin', (e) => {
  console.log(e.target.tagName, e.target.id || e.target.className)
})

Pasted into the console, that snippet prints every focus change, so you see directly whether the order matches the layout on screen and whether closing a dialog returned focus where it came from. It also shows the places where focus vanishes entirely, which is the case after which keyboard navigation stops working altogether.

Common mistakes

The first is assuming the library handles accessibility entirely. It supplies behaviour and attributes, while colour contrast, sensible labels, and focus order in a form remain yours.

The second is skipping the property allowing your own element to be substituted. Without it you get a button nested inside a button, which is invalid and breaks the behaviour.

The third is following old installation instructions. The documentation now tells you to install one radix-ui package and import the parts from it, while the separate @radix-ui/react-* packages are the legacy path described in material predating the unification. If you want the bundler to drop what you do not use, import from the subpath, radix-ui/popover for instance, rather than reverting to separate packages.

The fourth is fighting default styles that do not exist. If something looks wrong, the cause lies in your style code rather than the library, since it contributes no appearance at all.

The fifth is starting a new project on Radix without checking the state of both libraries. Development now heads towards Base UI, and that decision deserves making deliberately.

The sixth is planning migration as one large operation. Both libraries run side by side, so moving gradually is cheaper and safer.

FAQ

Is Radix UI still being developed?

Yes, but more slowly than before. The project is supported and stable, while release pace has dropped and some newer components appear elsewhere first. For existing projects it remains a solid choice.

What is Base UI relative to Radix?

A newer library built by some of the same people, with more active development and monthly releases. It is not an official successor, while new components now land there and the most popular ready component kit points its default there.

How does it differ from shadcn/ui?

They are two different layers. Radix gives behaviour without appearance, shadcn/ui is ready styled components built on that layer and copied into your project. Using the second, you also use the first or its newer equivalent.

Must I use a particular styling system?

No. The library imposes nothing, so it works with utility classes, styled components, plain stylesheets, and modules. Component state exposed as attributes lets you style states with ordinary selectors.

Is migrating an existing project worthwhile?

Not merely because something is newer. It is worthwhile when you need a component your current version lacks, or when you hit a bug with no prospect of a fix. Both libraries run side by side, so the move can be gradual.

Documentation sits on the project site, and the code in the GitHub repository.