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):
- 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).
- 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.
- 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.
- 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:
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:
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.tsx → cmdk
sonner.tsx → sonner
input-otp.tsx → input-otp
calendar.tsx → react-day-picker
chart.tsx → recharts
(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
- 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).
- Wrapper migration (bottom-up), per the order listed above. For each wrapper:
- Hand-transform the Radix import to its Base UI equivalent (part renames,
asChild → render, 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.
- 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).
- 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.
- 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.
- 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
References
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-appcurrently depends onradix-ui@^1.6.0for essentially every interactivecomponents/ui/*wrapper. This issue tracks migratingfdm-appoff 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 consistentrenderprop API replacingasChild, 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
renderprop is a more explicit, composable replacement for Radix'sasChild/Slotpattern, and several of our hand-rolled compositions (e.g.custom/expandable.tsx) already lean onSlotdirectly.shadcn add/difftooling less useful for us.Important complication: our shadcn style is legacy ("new-york") — resolved by adopting
vegafdm-app/components.jsoncurrently has"style": "new-york"(noradix-/base-prefix — this predates shadcn's dual-library registry). There is nobase-new-yorkregistry 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 aradix-<name>and a visually-identicalbase-<name>pair:vega,nova,maia,mira,luma,sera,rhea. Comparing theirbutton.tsxoutput against our current one:rounded-mdh-9shadow-xsaccents,text-sm— closest match to our current lookrounded-lgh-8rounded-4xlh-9rounded-mdh-7rounded-4xlh-9rounded-noneh-10rounded-2xlh-8vegais the closest visual match to our currentnew-yorklook (samerounded-mdcorners, sameh-9default control height, same subtleshadow-xstreatment, sametext-smbase). Decision: migratefdm-app's style fromnew-yorktoradix-vegafirst (cosmetically near-identical, Radix-based), then tobase-vega(same look, Base UI-based) as part of this migration, rather than hand-transforming the legacynew-yorkfiles 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):
components.json"style"toradix-vega. Re-runshadcn add <component> --overwritefor every wrapper in the list below to adopt thevegavisuals on top of Radix (a style/visual change, reviewed independently from the primitives swap — see Open questions on sequencing this as its own PR).new-yorkversions — do not silently drop them.radix-vega, flipcomponents.jsontobase-vegaand re-runshadcn add <component> --overwriteper wrapper to land the genuine Base UI primitives — this is now a true golden-pair CLI migration, not a hand transformation.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.tsxavatar.tsxprogress.tsxswitch.tsxcheckbox.tsxradio-group.tsxbutton.tsx— must migrate to the real@base-ui/react/buttonprimitive, not a hand-rolleduseRenderwrappercollapsible.tsxaccordion.tsxtabs.tsx— flag: Base UI Tabs uses manual activation by default, differs from Radix's automatic activation; verify keyboard behavior matches expectations or document the changebreadcrumb.tsxscroll-area.tsxtooltip.tsxpopover.tsx— flag: PopoverAnchorpart has no Base UI equivalent; needs an inert passthrough if useddialog.tsxalert-dialog.tsxsheet.tsx(built on the Dialog primitive)dropdown-menu.tsx— flag: verify menu-item close-on-click behavior matches Radixselect.tsxform.tsx(react-hook-form + RadixSlotcomposition — verifyrender-based replacement)item.tsxsidebar.tsx— largest/most complex wrapper (16 asChild/Slot usages internally); migrate last among wrappers, after Button/Tooltip/Sheet/Separator are done since it composes themScope: app-level code importing Radix directly (bypassing components/ui)
These import
radix-uiprimitives directly instead of going through acomponents/uiwrapper, and need their own migration pass:app/components/custom/expandable.tsx— importsSlot as SlotPrimitivefromradix-uidirectly; convert to Base UI'srenderpattern (see Base UI'suseRender/composition docs)app/components/blocks/helpdesk/ticket-viewer.tsx— decision: refactor to use the sharedcomponents/ui/dialog.tsxwrapper instead of its own bespokeDialogcomposition. Do this as part of thedialog.tsxmigration step: oncedialog.tsxis onbase-vega, rewriteticket-viewer.tsxto consume it (drop its directradix-uiimport entirely), then verify its dialog still matches current behavior/appearance (it may pick upvegastyling — call this out in manual QA)Scope: consumer call-site sweep (asChild → render)
asChildappears ~330 times across 138 files infdm-app/app(mostly incomponents/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'sasChildprop to Base UI'srenderprop. This is the largest part of the migration by file count and needs a dedicated sweep phase after allcomponents/uiwrappers are migrated — do not attempt this incrementally per-wrapper, since a single consumer file often uses several different wrapped primitives (e.g.sidebar/farm.tsxalone 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.tsx→cmdksonner.tsx→sonnerinput-otp.tsx→input-otpcalendar.tsx→react-day-pickerchart.tsx→recharts(No
drawer.tsx/vaulcomponent currently exists in this project, so there is nothing to skip there — noting it for completeness in case one is added later.)Plan / phases
chore/radix-to-base-ui).pnpm --filter fdm-app check-types(i.e.react-router typegen && tsc) andpnpm --filter fdm-app buildto record the pre-migration baseline (pre-existing failures must not be attributed to this work).@base-ui/reactalongsideradix-uiinfdm-app(they coexist fine during the migration).asChild→render,data-*/CSS variable renames).radix-ui/@radix-uiimports before considering it done — a clean diff/merge is not proof of a clean file.asChildcall-sites acrossapp/to therenderprop, file by file, typechecking incrementally (start with the high-traffic sidebar files, then routes, then remaining blocks).radix-ui/@radix-ui/react-*, removeradix-uifromfdm-app/package.json, update the lockfile, and do a final full build.ticket-viewer.tsxrefactor, and the consumer sweep are complete. Sincefdm-appis a design-sensitive product surface (seefdm-app/DESIGN.md/PRODUCT.md), this pass checks both thevegavisual refresh and the Base UI behavioral deltas (delays, focus return, activation modes) together before merging.pnpm changeset) forfdm-appdescribing the dependency swap, per this repo's release process.Behavior changes to flag (not silently patch)
Anchor: no direct Base UI equivalent — needs an inert passthrough if any of our popovers use a separate anchor element from the trigger.components/ui): no Base UI equivalent, same inert-passthrough treatment.Verification checklist
pnpm --filter fdm-app check-typespasses (react-router typegen && tsc)pnpm --filter fdm-app buildpassespnpm lintpasses (oxlint/oxfmt)radix-ui/@radix-ui/react-*imports anywhere infdm-app/app(grep -rn "radix-ui\|@radix-ui" fdm-app/app)radix-uiremoved fromfdm-app/package.json, lockfile updatedfdm-appReferences
fdm-app/components.json(current style:new-york)fdm-app/DESIGN.md,fdm-app/PRODUCT.md(design context for the sign-off phase)