Skip to content

Evaluate migrating UI primitives from Radix UI to Base UI #167

Description

@Azganoth

Question

Should Leafdown progressively migrate its existing UI primitive layer from radix-ui to @base-ui/react, remain on Radix, or defer the decision until a concrete Base UI capability is needed?

Distinguish shadcn’s recommendation for new projects from the value and risk of changing an established desktop application.

Context

In July 2026, shadcn made Base UI the default for new projects, citing Base UI 1.6 stability, adoption, and continued development. The same announcement says Radix is not deprecated, remains fully supported, and existing applications do not need to migrate. Base UI as the default

The surrounding releases show that shadcn now treats the primitive library as a project choice:

  • Complete Base UI component documentation and side-by-side examples shipped in January. Base UI documentation
  • Blocks became available for both Radix and Base UI in February. Blocks for Radix and Base UI
  • CLI v4 added explicit base selection and tooling for inspecting component differences. shadcn CLI v4
  • React Aria later became a third supported base while Base remained the default and existing projects remained unchanged. React Aria support
  • Radix continues to receive accessibility, React 19, behavior, and tree-shaking improvements. Radix releases

shadcn also provides a dedicated Radix UI → Base UI agent migration skill. It supports hand-owned wrappers, migrates components progressively while both libraries coexist, checks consumer API differences, flags behavior changes, and produces per-component verification reports. If migration is selected, Leafdown should use this workflow instead of treating the change as a codemod. Dedicated migration workflow

Leafdown is not currently a CLI-managed shadcn project. As of 2026-08-02, pnpm dlx shadcn@latest info --json reports no components.json, preset, or detected installed components. Therefore, the new default does not change Leafdown automatically and migration would not be a configuration flip.

The current repository:

  • Depends on radix-ui@^1.6.7.
  • Contains 16 owned wrappers importing radix-ui; 13 are reachable from current application code. Checkbox, ToggleGroup, and its internal Toggle dependency have no application consumers.
  • Already has shared state-attribute variants that recognize both Radix and Base UI conventions in App.css.
  • Currently records Radix as the preferred accessible primitive library in src/AGENTS.md. Selecting Base UI would therefore change durable project direction.

The main migration risk is behavioral rather than visual:

  • Base UI replaces asChild with render.
  • Popup anatomy changes from Content to Portal > Positioner > Popup.
  • Focus and dismissal interception move from content callbacks to root change-event details and initialFocus/finalFocus.
  • Menu items replace onSelect with onClick and explicit closeOnClick; checkbox and radio menu items have different close defaults.
  • Base UI Menubar delegates its menus to the separate Menu primitive.
  • Radix Popover Anchor becomes the Base UI Positioner’s anchor input.
  • Base UI Separator is always semantic, while Leafdown’s wrapper defaults to decorative separators.

These differences directly touch three behavior-sensitive areas:

  • The editor context popup, including its virtual selection anchor, manual focus ownership, toolbar navigation, tooltips, and nested menu.
  • The application command menubar, including disabled commands, submenus, checkbox/radio items, keyboard navigation, and menu-closing behavior.
  • The dialog wrapper, which prevents titlebar drag and window-control interactions from dismissing dialogs in the Tauri window.

Related context

No existing Leafdown issue mentioning Radix, Base UI, shadcn, or primitive migration was found.

Exit criteria

  • The active and unused Radix wrapper inventory is recorded.
  • The behavior and API differences relevant to Leafdown are documented.
  • The dialog, command menubar, and editor context popup have automated verification evidence, and the manual pass is named as outstanding. No prototype was built; the migration was implemented directly.
  • Bundle and dependency comparisons are recorded.
  • The outcome selects Radix, progressive migration, or a defined deferral trigger.
  • The outcome records the conclusion, tradeoffs, rejected alternatives, and supporting evidence links.
  • No follow-up issues were created; the selected work is one pull request.
  • Required updates to src/AGENTS.md or other owning documentation are identified.

Outcome

Decision: migrate, delivered as a single change. Leafdown moves its UI primitive layer from radix-ui to @base-ui/react, adopts shadcn CLI project management, and replaces sonner with Base UI Toast. Implemented manually in #182.

Baseline: 3b678ee, 2026-08-07.

Revalidated assumptions

Verified against the manifests, the code, and upstream sources on 2026-08-07. Corrections to Context above:

  • Base UI is at 1.7.0, not 1.6. npm view @base-ui/react version reports 1.7.0, published 2026-08-04. Minor releases have shipped monthly since 1.0.0 on 2025-12-11. Base UI releases records no breaking changes in 1.6.0 or 1.7.0; the last breaking changes were in 1.0.0-rc.0.
  • The package name is confirmed, and the old one is retired. @base-ui/react is correct. npm view @base-ui-components/react deprecated returns Package was renamed to @base-ui/react, with that name frozen at 1.0.0-rc.0. The migration must not follow older @base-ui-components/react documentation or examples.
  • Radix is current and active. radix-ui@1.6.7 is both the installed version and the latest published stable; 1.7.0-rc builds were still shipping on 2026-07-31. Radix releases shows fixes through 2026-07-20 covering Dialog, Tooltip, dismissable layers, React 19.2 compatibility, and per-primitive subpath entry points. No deprecation or maintenance-mode notice exists. The migration is therefore elective, not forced.
  • shadcn's stance is unchanged. Base UI as the default still states that Radix is not being deprecated and that existing projects do not need to migrate.
  • The wrapper inventory is confirmed: 16 wrappers import radix-ui, 13 have application consumers, and Checkbox, ToggleGroup, and its internal Toggle dependency have none.

Two couplings not listed in Context, both removed rather than ported:

  • Radix positioning internals were load-bearing in three places — the editor context popup resolved [data-radix-popper-content-wrapper] to drive its repositioning transition, a stylesheet styled that wrapper, and a test asserted against it. Base UI's Positioner is an element the application owns, so all three bindings disappear. This is the migration's clearest structural gain.
  • Four wrappers hardcoded Radix transform-origin custom propertiesPopover, Tooltip, DropdownMenu, and Menubar each consumed --radix-*-content-transform-origin, six occurrences. Base UI exposes --transform-origin on the positioner.

Baseline

pnpm check:frontend passes at 3b678ee: oxfmt, tsc -b, oxlint type-aware, build, and vitest run --coverage at 90.79% statements, 81.59% branches, 92.17% functions, 92.08% lines.

Artifact Raw Gzip
dist/assets/index-*.js 1,781.26 kB 444.39 kB
dist/assets/index-*.css 92.98 kB 14.04 kB

Dependency footprint: radix-ui@1.6.7 resolved to 60 @radix-ui/* packages, about 5.4 MB installed. The application ships as a single JavaScript chunk dominated by the editor and syntax-highlighting stack, and Radix's share of it was not isolated.

Rejected alternatives

  • Defer with a trigger, or stay on Radix. Cheaper on cost alone, since Radix is current and unblocking, but both leave Leafdown on a diverging ecosystem default and keep the [data-radix-popper-content-wrapper] coupling indefinitely. The migration cost is accepted against that.
  • Progressive migration, with Radix and Base UI coexisting across several changes. Coexistence doubles the primitive surface, the state-attribute conventions, and the reviewer's burden for its duration, and the cost is concentrated in re-verifying interaction behavior that is easier to judge once, whole, than in partial states.
  • Permanent mixed Radix/Base policy. Coexistence is a within-branch mechanism only.

Scope delivered

  • All 13 active wrappers moved to @base-ui/react; Checkbox, ToggleGroup, and Toggle were deleted rather than migrated.
  • sonner replaced by Base UI Toast. src/lib/toast.ts owns the toast manager and the notify* API so callers outside the React tree keep working; the component owns provider, viewport, and presentation.
  • shadcn CLI project management adopted through components.json, which relocated cn to src/lib/utils.ts. The wrappers stay hand-owned.
  • Files exporting React components renamed to kebab-case, with the rule recorded in src/AGENTS.md.

How the open questions resolved

The decision was taken on the measured surface rather than on a prototype, so these were carried into implementation as risks:

  • The positioner does re-measure a virtual anchor on a change of anchor object identity. A memoized VirtualElement preserves the selection-following behavior that fix: move the context popup onto its selection when the selection changes #174 introduced, with no new mechanism needed.
  • Toolbar.Button defaulting focusableWhenDisabled to true was overridden in the wrapper, keeping disabled controls out of the roving sequence so vertical row movement and horizontal roving agree on what is reachable.
  • The Tauri titlebar and window-control exception is expressible through onOpenChange, by cancelling an outside-press change rather than a pointer-down event.
  • Base UI's composite items relay keyboard events to the toolbar root instead of letting them bubble. This was not anticipated: the editor context popup handles keys during the capture phase because a handler on the popup never sees Escape, Tab, or the vertical arrows.
  • Separators stay semantic. The Radix wrapper defaulted to decorative, but that was a template default rather than a Leafdown requirement, and all four call sites separate real content groups. No override was added.
  • The swap costs bundle size. JavaScript grew to 1,828.64 kB (gzip 465.53 kB) and CSS to 99.10 kB (gzip 15.32 kB). Base UI Toast ships no default styling or fixed toast types, so presentation Sonner supplied is now markup and CSS this repository owns.

Verification

#182 records the evidence. pnpm check:frontend passes, and focused tests prove dialog dismissal against the titlebar drag region, command menubar submenu and close behavior, the editor context popup's anchoring, focus ownership, row traversal and dismissal, and that every notify* call reaches the toast manager.

The three behavior-sensitive surfaces were also exercised by hand in pnpm tauri dev on Windows 11: dialog dismissal against real window drag and window controls, the command menubar keyboard walk, and the editor context popup in the real WebView. Linux and macOS remain unverified, as they are not the current polish target.

Documentation updated

  • docs/decisions.md carries the decision under Technical Decisions.
  • src/AGENTS.md records Base UI as the preferred primitive library, the relocated cn path, and the file naming rule.
  • docs/patterns.md no longer describes focus and traversal behavior in Radix's terms.
  • No changelog entry: the primitive migration is not user-facing, and the toast restyle was judged not notable.

Metadata

Metadata

Assignees

Labels

SpikeInvestigation needed before committing to implementation

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions