Skip to content

refactor(ui): move the UI components from Radix to Base UI - #1123

Merged
EhabY merged 1 commit into
mainfrom
ui-base-ui-migration
Sep 23, 2026
Merged

EhabY merged 1 commit into
mainfrom
ui-base-ui-migration

Conversation

@EhabY

@EhabY EhabY commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Replaces the five @radix-ui/* packages in packages/ui with @base-ui/react. Styling and behavior stay as they were. Where Base UI differs, this PR corrects it or makes the API stricter, as listed in the following sections.

Why now

Nothing outside packages/ui imports it yet, and the menus and Select appear only in stories and tests, so the swap won't get cheaper than this.

What the switch buys

  • Five dependencies become one.
  • Base UI's context menu reuses the menu's parts, so ContextMenu and DropdownMenu keep only their root, trigger, and surface, and share every item as Menu*. The menuPart factory that bridged the two Radix scopes is gone.
  • Every positioner names --available-height, --anchor-width, and --transform-origin the same way, so the per-surface CSS mappings collapse into one overlay rule.
  • Radix-only workarounds are deleted: TooltipScope (a Base UI tooltip works without a provider), the jsdom pointer-capture and scrollIntoView stubs, the Select scrollbar overrides, and the tree's hidden hover anchor <span>. The tree's shared hover bubble now positions against a virtual anchor measured from the tree.

API changes

  • MenuContent, DropdownMenuContent, ContextMenuContent: these take side, align, sideOffset, and alignOffset for placement, and forward every other prop, including ref, to the menu element.
  • SelectContent: className and style dress the panel, and every other prop goes to the listbox.
  • Select: requires items and takes a single value. Base UI builds the trigger label from items, so without it the trigger shows the raw value. The type now catches that, and SelectProps is exported.
  • MenuLabel: builds on Menu.GroupLabel and names the new MenuGroup or the MenuRadioGroup it sits in. Outside a group, Base UI throws.
  • TooltipProvider: delayDuration is now delay, matching the underlying prop.
  • onValueChange and onOpenChange: these gain a second eventDetails argument.
  • Hover delegate: HoverDelegateScope, HoverDelegate, and HoverTarget are no longer exported. Only Tree uses them, so the public tooltip API is Tooltip and TooltipProvider.

Corrections on top of Base UI

  • Every overlay uses a fixed positioner. A fixed positioner gets its own layer, which keeps text antialiasing greyscale instead of fringing into subpixel color.
  • The select list and context menu align to the start of their anchor, as they did with Radix. The context menu sets side explicitly, which also skips Base UI's context-menu offsets.
  • The select list scrolls a viewport inside the padded panel, so the last row sits against the padding instead of under the corner radius.
  • Base UI sets no role="tooltip" or aria-describedby on tooltips, and names a select option from its whole contents. Tooltips get both attributes back, and an option with a description is labelled by its text alone.

Performance

Tree rows still hand their hovers to one shared bubble instead of mounting a tooltip each. In a jsdom benchmark with 2,000 rows (mount plus one rerender), sharing costs about 40 ms with either library, a tooltip per row costs about 300 ms with Radix and about 225 ms with Base UI, and Base UI's detached triggers cost about 220 ms. The React Compiler memoizes the delegate, so moving the hover re-renders no rows.

Verification

  • pnpm typecheck, pnpm lint, pnpm test (2756 passed), pnpm build, and pnpm storybook:build pass.
  • In Chrome, every overlay, item, and label in the menu, select, and VS Code parity stories lands on the same pixel before and after the API changes, and the tree hover lands where it did with the anchor <span>.
  • test/webview/ui/overlays.test.tsx checks that each surface forwards its props to the right element and carries the classes, data-open, and positioner variables the stylesheets read.

Stories drive a component into the state Pixel photographs, so their play functions only interact and wait. The checks nothing else covered moved to unit tests.

@EhabY
EhabY force-pushed the ui-base-ui-migration branch 2 times, most recently from 16b6bb0 to 06dc41e Compare September 22, 2026 16:09
@EhabY
EhabY marked this pull request as ready for review September 22, 2026 17:51
@EhabY
EhabY force-pushed the ui-base-ui-migration branch from 543fd11 to 04d6a76 Compare September 23, 2026 08:36
Base UI serves the menu, context menu, select, and tooltip from one
package, and its context menu reuses the menu's own parts. ContextMenu
and DropdownMenu now share one set of Menu* items, keeping only their
root, trigger, and surface. Every positioner names its available-height,
anchor-width, and transform-origin variables the same way, so the
per-surface CSS mappings collapse into one overlay rule.

Styling and behavior stay as they were, verified by measuring both
builds side by side. Where Base UI differs, this change corrects it:

- Every overlay positions with a fixed strategy. A fixed positioner gets
  its own layer, which keeps the text on greyscale antialiasing instead
  of subpixel fringing.
- The select list and context menu align to their anchor's start, and
  the context menu sets its side explicitly to skip Base UI's
  context-menu offsets.
- The select list scrolls a viewport inside the padded panel, so the
  last row sits against the padding rather than under the corner radius.
- Select.Icon renders a literal arrow as default children, so the
  trigger renders the codicon directly.
- Base UI sets no tooltip role or aria-describedby and names a select
  option from its whole contents, so the tooltip regains both and an
  option is labelled by its text rather than its description.

The workarounds Radix needed are gone: TooltipScope, since a Base UI
tooltip works without a provider; the jsdom pointer-capture and
scrollIntoView stubs; and the tree's hidden hover anchor span. Tree rows
still hand their hovers to one shared bubble, because a Base UI trigger
per row costs about five times as much to mount. The bubble now
positions against a virtual anchor measured from the tree.

The hover delegate types are no longer exported. Only Tree uses them,
so the public tooltip API is Tooltip and TooltipProvider.

The overlay APIs are stricter than the Radix ones were:

- MenuContent and SelectContent forward every prop to the menu or
  listbox element, and take only side, align, and offsets for placement.
- Select requires items, since Base UI builds the trigger label from
  them and would otherwise show the raw value, and takes a single value.
- MenuLabel builds on Menu.GroupLabel and names the MenuGroup or
  MenuRadioGroup it sits in.

Stories drive a component into the state Pixel photographs, so their
play functions only interact and wait. The checks nothing else covered
moved to unit tests.
@EhabY
EhabY force-pushed the ui-base-ui-migration branch from 04d6a76 to 21fc063 Compare September 23, 2026 08:54
@EhabY
EhabY requested a review from jakehwll September 23, 2026 08:56
@EhabY EhabY changed the title refactor(ui): move the overlay components from Radix to Base UI refactor(ui): move the UI components from Radix to Base UI Sep 23, 2026
@EhabY
EhabY merged commit 737d4e5 into main Sep 23, 2026
14 of 15 checks passed
@EhabY
EhabY deleted the ui-base-ui-migration branch September 23, 2026 15:30
@EhabY EhabY mentioned this pull request Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants