Skip to content

Migrate fdm-app from Radix UI to Base UI #681

Description

@SvenVw

Summary

The shadcn/ui changelog has made Base UI (@base-ui/react) the default primitives library going forward, replacing Radix UI as the underlying headless engine for shadcn components. fdm-app currently depends on radix-ui@^1.6.0 for essentially every interactive components/ui/* wrapper. This issue tracks migrating fdm-app off Radix UI and onto Base UI, so the app stays aligned with the upstream shadcn registry and picks up Base UI's improvements (smaller bundle, more consistent render prop API replacing asChild, active development focus from the shadcn team).

This is a whole-project migration effort: all wrappers are migrated bottom-up (leaf/shared components first), followed by a full sweep of consumer call-sites, then the Radix dependency is removed and a full build/typecheck/test pass is run. Priority: medium (planned for an upcoming cycle, not blocking).

Why now

  • Radix UI is being de-emphasized upstream; new shadcn registry components and fixes will target Base UI first.
  • Base UI's render prop is a more explicit, composable replacement for Radix's asChild/Slot pattern, and several of our hand-rolled compositions (e.g. custom/expandable.tsx) already lean on Slot directly.
  • Staying on Radix long-term means diverging further from the shadcn registry, making future shadcn add/diff tooling less useful for us.

Important complication: our shadcn style is legacy ("new-york") — resolved by adopting vega

fdm-app/components.json currently has "style": "new-york" (no radix-/base- prefix — this predates shadcn's dual-library registry). There is no base-new-york registry counterpart to fetch and replay onto our wrappers, so the CLI golden-pair strategy (shadcn add <component> --overwrite) cannot be used directly against our current style.

The current shadcn registry (verified directly against ui.shadcn.com/r/styles/... in this investigation) offers seven modern style families, each shipped as a radix-<name> and a visually-identical base-<name> pair: vega, nova, maia, mira, luma, sera, rhea. Comparing their button.tsx output against our current one:

Style Corners Default height Notes
vega rounded-md h-9 shadow-xs accents, text-sm — closest match to our current look
nova rounded-lg h-8 softer/rounder, more compact
maia rounded-4xl h-9 pill-shaped
mira rounded-md h-7 matches corner radius but much more compact/dense sizing
luma rounded-4xl h-9 pill-shaped
sera rounded-none h-10 uppercase, letter-spaced, editorial/brutalist
rhea rounded-2xl h-8 rounder, compact

vega is the closest visual match to our current new-york look (same rounded-md corners, same h-9 default control height, same subtle shadow-xs treatment, same text-sm base). Decision: migrate fdm-app's style from new-york to radix-vega first (cosmetically near-identical, Radix-based), then to base-vega (same look, Base UI-based) as part of this migration, rather than hand-transforming the legacy new-york files directly. This unlocks the CLI golden-pair workflow for the whole project instead of the slower hand-transformation engine.

Revised sub-steps (replaces the earlier "hand-transform new-york" plan):

  1. Set components.json "style" to radix-vega. Re-run shadcn add <component> --overwrite for every wrapper in the list below to adopt the vega visuals on top of Radix (a style/visual change, reviewed independently from the primitives swap — see Open questions on sequencing this as its own PR).
  2. Diff the result against our current wrappers; port over any project-specific customizations (custom variants, extra props, className overrides) that existed on the new-york versions — do not silently drop them.
  3. Once on radix-vega, flip components.json to base-vega and re-run shadcn add <component> --overwrite per wrapper to land the genuine Base UI primitives — this is now a true golden-pair CLI migration, not a hand transformation.
  4. Continue with the consumer sweep (asChild → render) and dependency cleanup phases below as planned.

This adds a visual-refresh step up front but trades the error-prone hand-transformation engine for the CLI's tested golden-pair delivery across all 23 wrappers.

Scope: components/ui wrappers on Radix (23 files)

All of these import radix-ui (the umbrella package) or @radix-ui/react-* directly and need migrating. Suggested order is bottom-up so a component never depends on an un-migrated wrapper:

  • label.tsx — no Base UI counterpart; replace with a native <label> (keep existing className/behavior)
  • separator.tsx
  • avatar.tsx
  • progress.tsx
  • switch.tsx
  • checkbox.tsx
  • radio-group.tsx
  • button.tsx — must migrate to the real @base-ui/react/button primitive, not a hand-rolled useRender wrapper
  • collapsible.tsx
  • accordion.tsx
  • tabs.tsx — flag: Base UI Tabs uses manual activation by default, differs from Radix's automatic activation; verify keyboard behavior matches expectations or document the change
  • breadcrumb.tsx
  • scroll-area.tsx
  • tooltip.tsx
  • popover.tsx — flag: Popover Anchor part has no Base UI equivalent; needs an inert passthrough if used
  • dialog.tsx
  • alert-dialog.tsx
  • sheet.tsx (built on the Dialog primitive)
  • dropdown-menu.tsx — flag: verify menu-item close-on-click behavior matches Radix
  • select.tsx
  • form.tsx (react-hook-form + Radix Slot composition — verify render-based replacement)
  • item.tsx
  • sidebar.tsx — largest/most complex wrapper (16 asChild/Slot usages internally); migrate last among wrappers, after Button/Tooltip/Sheet/Separator are done since it composes them

Scope: app-level code importing Radix directly (bypassing components/ui)

These import radix-ui primitives directly instead of going through a components/ui wrapper, and need their own migration pass:

  • app/components/custom/expandable.tsx — imports Slot as SlotPrimitive from radix-ui directly; convert to Base UI's render pattern (see Base UI's useRender/composition docs)
  • app/components/blocks/helpdesk/ticket-viewer.tsxdecision: refactor to use the shared components/ui/dialog.tsx wrapper instead of its own bespoke Dialog composition. Do this as part of the dialog.tsx migration step: once dialog.tsx is on base-vega, rewrite ticket-viewer.tsx to consume it (drop its direct radix-ui import entirely), then verify its dialog still matches current behavior/appearance (it may pick up vega styling — call this out in manual QA)

Scope: consumer call-site sweep (asChild → render)

asChild appears ~330 times across 138 files in fdm-app/app (mostly in components/blocks/sidebar/*, app/routes/*, components/blocks/indicators/*, and various feature blocks). Every one of these is a potential compile break once the underlying wrapper switches from Radix's asChild prop to Base UI's render prop. This is the largest part of the migration by file count and needs a dedicated sweep phase after all components/ui wrappers are migrated — do not attempt this incrementally per-wrapper, since a single consumer file often uses several different wrapped primitives (e.g. sidebar/farm.tsx alone has 29 matches).

High-traffic files to expect the most churn in (for planning/reviewer allocation):

  • components/blocks/sidebar/farm.tsx (29), sidebar/organization.tsx (19), sidebar/apps.tsx (17)
  • components/ui/sidebar.tsx (16)
  • components/blocks/indicators/aggregation-tree.tsx (9)
  • components/blocks/sidebar/admin-helpdesk.tsx, sidebar/organization-apps.tsx, sidebar/user.tsx (7 each)
  • app/routes/farm.$b_id_farm._index.tsx (7)

Explicitly out of scope (not Radix — leave untouched)

These shadcn components are built on other headless libraries and must not be touched by this migration:

  • command.tsxcmdk
  • sonner.tsxsonner
  • input-otp.tsxinput-otp
  • calendar.tsxreact-day-picker
  • chart.tsxrecharts

(No drawer.tsx/vaul component currently exists in this project, so there is nothing to skip there — noting it for completeness in case one is added later.)

Plan / phases

  1. Preflight
    • Confirm clean git tree, work on a dedicated branch (e.g. chore/radix-to-base-ui).
    • Baseline run: pnpm --filter fdm-app check-types (i.e. react-router typegen && tsc) and pnpm --filter fdm-app build to record the pre-migration baseline (pre-existing failures must not be attributed to this work).
    • Install @base-ui/react alongside radix-ui in fdm-app (they coexist fine during the migration).
  2. Wrapper migration (bottom-up), per the order listed above. For each wrapper:
    • Hand-transform the Radix import to its Base UI equivalent (part renames, asChildrender, data-*/CSS variable renames).
    • Keep our exact Tailwind classes/customizations — this is a primitives swap, not a redesign.
    • Typecheck after each file.
    • Grep the file for leftover radix-ui/@radix-ui imports before considering it done — a clean diff/merge is not proof of a clean file.
  3. Consumer sweep: after all wrappers are migrated, sweep all asChild call-sites across app/ to the render prop, file by file, typechecking incrementally (start with the high-traffic sidebar files, then routes, then remaining blocks).
  4. Dependency cleanup: once no file imports radix-ui/@radix-ui/react-*, remove radix-ui from fdm-app/package.json, update the lockfile, and do a final full build.
  5. Design/UX sign-off: a single manual visual/behavioral pass across the whole migrated surface at the end (not incrementally per-component), done once all wrappers, the ticket-viewer.tsx refactor, and the consumer sweep are complete. Since fdm-app is a design-sensitive product surface (see fdm-app/DESIGN.md / PRODUCT.md), this pass checks both the vega visual refresh and the Base UI behavioral deltas (delays, focus return, activation modes) together before merging.
  6. Changeset: add a changeset (pnpm changeset) for fdm-app describing the dependency swap, per this repo's release process.

Behavior changes to flag (not silently patch)

  • Tabs: Base UI defaults to manual activation (arrow keys move focus without switching tabs until Enter/Space); Radix defaults to automatic activation. Decide whether to configure Base UI for automatic activation to preserve current behavior, or accept/document the change.
  • Dropdown/context menus: verify menu items still close the menu on click by default; Base UI's default can differ from Radix's here.
  • Popover Anchor: no direct Base UI equivalent — needs an inert passthrough if any of our popovers use a separate anchor element from the trigger.
  • NavigationMenu indicator (if used anywhere outside components/ui): no Base UI equivalent, same inert-passthrough treatment.

Verification checklist

  • pnpm --filter fdm-app check-types passes (react-router typegen && tsc)
  • pnpm --filter fdm-app build passes
  • pnpm lint passes (oxlint/oxfmt)
  • Manual QA pass: dialogs/sheets return focus correctly on close, dropdown/select keyboard nav + typeahead work, tooltips show with expected delay, sidebar collapsible/tabs behave as before, accordion/collapsible animations still look right
  • No remaining radix-ui/@radix-ui/react-* imports anywhere in fdm-app/app (grep -rn "radix-ui\|@radix-ui" fdm-app/app)
  • radix-ui removed from fdm-app/package.json, lockfile updated
  • Changeset added for fdm-app

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions