ADR 0001. Base UI, not Radix
The primitive layer is Base UI everywhere, chosen for its animation contract and for keeping exactly one primitive library in the tree.
Status
Accepted. 2026-09-02.
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. 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
treats data attributes as a covered surface, and
Data attributes documents them.
Committing to that is much easier when the attributes come from a primitive that
also treats them as contract.
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
- 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
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
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.
Last read through against the system on 2026-09-02. Due for review every 12 months; expiry is reported by pnpm run check:freshness.