opsinjs
RoadmapDecisions

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.

Edit this page

Last read through against the system on 2026-09-02. Due for review every 12 months; expiry is reported by pnpm run check:freshness.

On this page