Skip to content

Move the OpenAPI package from react-aria to Base UI - #4473

Draft
nolannbiron wants to merge 15 commits into
nolann/openapi-serializable-contextfrom
nolann/openapi-base-ui
Draft

Move the OpenAPI package from react-aria to Base UI#4473
nolannbiron wants to merge 15 commits into
nolann/openapi-serializable-contextfrom
nolann/openapi-base-ui

Conversation

@nolannbiron

Copy link
Copy Markdown
Member

Stacked on #4472 — review that one first.

@gitbook/react-openapi was the only package still on react-aria, while packages/gitbook uses Radix. This starts consolidating both onto Base UI, beginning with the package that carries the weight: an API reference page instantiates around 800 react-aria hooks, and react-aria-components plus react-stately account for two chunks of 381 KB and 350 KB.

Base UI's dependency tree is five packages against react-aria's dozens of @react-aria/* and @react-stately/*.

Migrated

Component Now
OpenAPITooltip Tooltip.Root / Trigger / Portal / Positioner / Popup
OpenAPICopyButton plain <button> — it only used react-aria for onPress, which no caller passed
OpenAPISelect Select
OpenAPIDisclosure Collapsible
OpenAPITabs deleted — nothing imported it and it wasn't exported

InteractiveSection and OpenAPIDisclosureGroup still run on the low-level hooks (useButton, useDisclosure, useDisclosureState, plus react-stately's tree state). They come next, the group heading towards Accordion.

The select changes shape

react-aria cloned the selected item's children into the trigger — which is why the styles carry a span.react-aria-SelectValue:has(.openapi-statuscode) rule. Select.Value can't do that, so children becomes a render function used for both the list and the trigger, and all eight call sites move with it. The select also owns its tooltip now: Select.Root renders no element, so only the select holds the trigger a tooltip can anchor to.

Styling

react-aria shipped defaults that Base UI doesn't, so several rules were leaning on it without saying so. Each of these was a real visual defect, none of which the build or typecheck could catch:

  • The animation attributes split by purpose: data-starting-style / data-ending-style drive transitions, data-open / data-closed drive animations. These styles use @keyframes, so they belong on the second pair.
  • z-index only applies to positioned elements, so the overlay z-index has to sit on the positioner, not the popup. The tooltip was painting behind the operation heading.
  • The trigger had no outline reset, so the browser drew its own ring once Base UI returned focus to it on close.
  • .react-aria-SelectValue becomes .openapi-select-value, --trigger-width becomes --anchor-width.

Two fixes are unrelated to the migration and predate it: the styled select variant hovered to its own resting colour, and the popup carried a 24px backdrop blur behind a fully opaque background — invisible, but still forcing the browser to blur the region on every animation frame.

Positioning

A profile of opening a select put 76% of the click in Recalculate style inside floating-ui, a third of that in get scrollX. That's absolute positioning walking offset parents; the popups are portalled to the body, so fixed is safe and skips it. The trigger was also declared as a component inside the render, handing React a new type on every pass.

Verifying

Argos already covers OpenAPI pages — Snyk, the GitBook API reference, and several api-reference/pets pages. That's the check that matters here: every defect above was found by eye, and the screenshots are what will catch the next one.

Three of react-aria's heaviest surfaces, and the ones the rest depend on.

The select changes shape: react-aria cloned the selected item's children
into the trigger, which Base UI's `Select.Value` cannot do, so `children`
becomes a render function used for both the list and the trigger. It also
owns its tooltip now — only it holds the trigger the tooltip anchors to.

CSS follows the attributes Base UI actually emits: `data-entering`/`data-exiting`
become `data-starting-style`/`data-ending-style`, `.react-aria-SelectValue`
becomes `.openapi-select-value`, and `--trigger-width` becomes `--anchor-width`.
`Collapsible.Panel` unmounts when closed by default, matching the
conditional render it replaces, so collapsed content still stays out of
the DOM.

The focus ring moves from a react-aria inline style to `:focus-visible`,
and `[data-expanded="true"]` becomes `[data-open]`.
Base UI overlaps the trigger by default so the selected item lines up with
it, like a native select. react-aria placed the popover below with an
offset of 8, which is what the styling expects.
Base UI splits its animation attributes: `data-starting-style` and
`data-ending-style` drive CSS transitions, while `data-open` and
`data-closed` drive CSS animations. These styles use `@keyframes`, so the
first mapping attached them to attributes that only live for one frame.

The select popup also takes focus when it opens, which was drawing the
browser's own focus ring on top of it.
The trigger had no outline reset, so the browser drew its own ring — Base UI
returns focus to it when the popup closes, where react-aria moved focus into
the list instead. The styled ring is now scoped to keyboard focus.
Base UI scrolls the list and reveals the selected item through this part, so
the items sitting directly in the popup left it doing that work against a
container it doesn't own. The scroll styles move with them.
The popup's background is `rgb(var(--tint-1))`, fully opaque, so a 24px
backdrop blur produced nothing visible while still forcing the browser to
snapshot and blur the region behind it on every animation frame.
…header

Rewriting the copy button dropped `withTooltip`'s default of `true`, which
disabled every tooltip whose caller relied on it.

The tooltip also lingered after the pointer left, because Base UI keeps a
hoverable popup alive while the pointer travels towards it. These are plain
labels, so that grace period only reads as lag.

The disclosure group still runs on react-aria's `usePress`, which reacts to
pointer events, so the select inside its header needs to stop those rather
than just clicks.
A profile put 76% of the click in `Recalculate style` inside floating-ui,
with a third of that in `get scrollX`. Absolute positioning makes it walk
offset parents and read scroll offsets on every measure; the popups are
portalled to the body, so `fixed` is safe and skips that.

The trigger was also declared as a component inside the render, giving React
a fresh type on every pass and remounting the whole subtree.

The tooltip carried no z-index, where the app's own overlays all use z-50.
`z-index` only applies to positioned elements. The popup is static inside the
positioner, so the rule sat on an element that could never honour it, and the
tooltip kept painting behind the operation heading.
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: cdaed51

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@argos-ci

argos-ci Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
customers-v2-cloudflare (Inspect) ⚠️ Changes detected (Review) 6 changed, 8 ignored Aug 6, 2026, 10:30 AM
customers-v2-vercel (Inspect) ⚠️ Changes detected (Review) 11 changed, 3 ignored Aug 6, 2026, 10:28 AM
v2-cloudflare (Inspect) ✅ No changes detected 1 ignored Aug 6, 2026, 10:32 AM
v2-vercel (Inspect) ✅ No changes detected 3 ignored Aug 6, 2026, 10:30 AM

They ran bespoke keyframes where Popover, DropdownMenu and Tooltip all use
`scale-in` / `scale-out`. Same 200ms, but a different curve, so the OpenAPI
select never quite matched the popovers next to it.
It kept the document scrollable when react-aria's popover locked body
overflow. The select runs with `modal={false}` now, so nothing locks it —
and `:has()` on `body` makes the browser re-check the whole document on
every DOM mutation.
`InteractiveSection` and `OpenAPIDisclosureGroup` used the low-level hooks for
an open/closed boolean, two ARIA attributes and a focus ring. Plain state and
native elements cover that, so neither needs a headless library — swapping one
for another would have carried the cost without the benefit.

The group's trigger hosts the media-type select, so it stays a div with an
explicit button role. Now that it handles clicks rather than react-aria's
pointer events, the select inside only has to stop propagation once.

`react-aria`, `react-aria-components` and `react-stately` leave the package.
What the bundle still contains comes from `@scalar/api-client-react`, which
ships its own copy inside the lazily loaded Test-it chunk.
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.

1 participant