---
title: "ADR 0001. Base UI, not Radix"
description: "The primitive layer is Base UI everywhere, chosen for its animation contract and for keeping exactly one primitive library in the tree."
url: "https://opsinjs.pensievelabs.org/project/decisions/0001-base-ui-not-radix"
source: "https://opsinjs.pensievelabs.org/project/decisions/0001-base-ui-not-radix.md"
section: "Project"
kind: "project"
reviewed: "2026-09-02"
reviewer: "engineering"
aliases: ["why base ui", "radix", "primitives"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="project" />

## Status [#status]

**Accepted.** 2026-09-02.

## Context [#context]

Every interactive component in this system needs unstyled behaviour underneath
it: focus management, dismissal, portalling, the accessibility tree. Writing that
by hand is how design systems ship subtly broken dialogs.

Two candidates were real. Radix is the mature default and what most of the shadcn
ecosystem assumed for years. Base UI is the successor line from the same
tradition, and what matters most for this project is that the documentation
engine we chose ships in two builds, one on each. `fumadocs-ui` depends on Radix;
`@fumadocs/base-ui` is the same library with the same API and byte-identical CSS,
depending on Base UI instead.

Three things made this more than a preference.

**The animation contract.** opsinjs treats motion as tokens and documents, per
component, exactly what each transition degrades to under
`prefers-reduced-motion`. See
[Reduced motion](../../foundations/motion/reduced-motion.mdx). That requires the
primitive to expose its enter and exit states as attributes you can style and
observe. Base UI's `data-starting-style` and `data-ending-style`, alongside
`data-open` and `data-closed`, are exactly that contract, and they are documented
as a styling surface rather than as an implementation detail.

**One library, not two.** The documentation site renders shadcn chrome
components and fumadocs chrome on the same page, and it will eventually render
opsinjs components there too. With `fumadocs-ui` on Radix and our components on
Base UI, that page would contain two primitive libraries, two portal
implementations and two focus managers, and the resulting bugs would be
interaction bugs, which are the expensive kind to reproduce.

**`data-*` as public surface.** The [versioning policy](../versioning-policy.mdx)
treats data attributes as a covered surface, and
[Data attributes](../../reference/generated/data-attributes.mdx) documents them.
Committing to that is much easier when the attributes come from a primitive that
also treats them as contract.

## Decision [#decision]

**Use Base UI (`@base-ui/react`) as the only primitive library, including for the
documentation chrome.** `fumadocs-ui` is installed as an alias of
`@fumadocs/base-ui` so that the documentation engine's API is unchanged while the
dependency tree contains no Radix at all.

## Consequences [#consequences]

* **One primitive library in the tree.** One portal implementation, one focus
  manager, one set of state attributes to document.
* **Base UI's data attributes become documented contract** in every component
  page's data-attributes section. Attributes inherited unchanged are delegated
  with a link rather than re-documented; opsinjs documents only what it adds.
* **Base UI is younger than Radix**, and its 1.x line may still move. This is the
  accepted risk. It is bounded by the fact that we consume it through components
  we ship as source, so a breaking upstream change is a diff a consumer can see
  rather than a silent transitive upgrade.
* **Radix-only third-party components cannot be dropped in.** Anything that
  reaches into Radix's context is unusable here and would have to be rebuilt. An
  add-on built for a Radix dialog is one such thing.
* **shadcn components must be the Base UI variants.** Copying a Radix-era snippet
  from the internet into this repository will not work, and that is a papercut
  every contributor hits once.

## Alternatives considered [#alternatives-considered]

**Radix, the safe default.** Rejected because it forces either two primitive
libraries in the tree or a fork of the documentation engine, and because its
enter/exit state convention would have to be re-documented as our own contract
rather than delegated.

**A headless rebuild of our own.** Rejected outright. Sidebar, command palette,
table of contents and scroll-spy are weeks of work that belong to `RangeBar`, and
the token bridge already gives us the styling control that would have been the
only real argument for it.

**Both, with an adapter layer.** Rejected as the worst of the three: an adapter
over two primitive libraries is a third library, unversioned and undocumented.

## Revisiting this [#revisiting-this]

Revisit if Base UI's release cadence stalls, if a primitive we need does not
exist and is not planned upstream, or if a breaking 2.x lands with no migration
path. The signal to watch is not popularity; it is whether the state attributes
we have documented as public surface remain stable.

<LastUpdated />

<Reviewed />
