Migrating from Radix
Every Radix primitive has a bedrock equivalent, so a complete migration is possible. Whether it is a good idea is a separate question, and worth settling first: what changes is your test suite and your CSS conventions, not your component tree.
This guide is written against Dialog because every divergence shows up there.
The per-primitive notes at the end cover what is specific to the others.
The shape is the same
-import * as Dialog from '@radix-ui/react-dialog'
+import { Dialog } from '@apostel/bedrock'
<Dialog.Root>
<Dialog.Trigger asChild><button>Delete project</button></Dialog.Trigger>
- <Dialog.Portal>
- <Dialog.Overlay className="overlay" />
<Dialog.Content className="content">
<Dialog.Title>Delete project?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Close asChild><button>Cancel</button></Dialog.Close>
</Dialog.Content>
- </Dialog.Portal>
</Dialog.Root>
Compound components, part names, asChild, className passthrough: unchanged.
Mechanical changes
These are safe to codemod.
| Radix | bedrock | why |
|---|---|---|
import * as Dialog from '@radix-ui/react-dialog' |
import { Dialog } from '@apostel/bedrock' |
Namespace object rather than a module namespace. |
<Dialog.Portal> |
delete it | <dialog> is in the top layer; there is nothing to portal past. |
<Dialog.Overlay className="x" /> |
dialog::backdrop in CSS |
The backdrop is a pseudo-element. |
open + onOpenChange on Dialog.Root |
same props, import from @apostel/bedrock/controlled |
Same props; one import line. |
[data-state="open"] |
:open |
Native state, no JS mirror. |
[data-state="closed"] |
:not(:open) |
Same. |
forceMount |
delete it, and check what depended on it | Closed content unmounts, as in Radix, but there is no way to opt out. See should you switch?. |
<AlertDialog.Action> |
<Dialog.Close> plus your onClick |
No separate part; a close plus a handler. |
A rough sed for the state selectors, which is the bulk of a real diff:
rg -l 'data-state' src | xargs sed -i \
-e 's/\[data-state="open"\]/:open/g' \
-e 's/\[data-state="closed"\]/:not(:open)/g'
Check the results by hand where the selector targeted an ancestor, which the
:has() note in styling covers.
Changes that need a decision
Triggers must be buttons
Radix attaches a click handler to whatever you give it. bedrock does not, and
throws in development if the trigger is not a <button>.
-<Dialog.Trigger asChild><div role="button" tabIndex={0}>Open</div></Dialog.Trigger>
+<Dialog.Trigger asChild><button type="button">Open</button></Dialog.Trigger>
In a real Radix codebase the case that bites is not the div. It is a trigger
inside a <form>:
-<Dialog.Trigger asChild><button>Delete</button></Dialog.Trigger>
+<Dialog.Trigger asChild><button type="button">Delete</button></Dialog.Trigger>
A
<button>inside a form defaults totype="submit", and the browser ignorescommandforon a submit button. Radix papered over this; bedrock cannot, so it tells you at mount instead.
If you genuinely cannot change the element, say a third-party component that
renders a div, useDialogTrigger() hands you the props and the
responsibility.
No light dismiss
Radix closes on a backdrop click. A native modal <dialog> does not; Escape and
your Close button are the ways out. Nothing about focus or dismissal is
broken. It is one fewer way to close.
<dialog closedby="any"> restores it and will be an opt-in prop once the naming
question in should you switch? is settled. If
you need it today, put it on the element yourself:
<Dialog.Content {...{ closedby: 'any' }} />
The four smaller decisions
Each is a real difference, and none of them needs a page of its own.
| Change | Radix | bedrock | What to do |
|---|---|---|---|
onOpenChange is read-only on the default root |
How you take control. | A toggle listener: it reports, it cannot refuse. |
Nothing, if your handler resets a form or fires analytics. Grep for handlers that conditionally avoid calling setOpen: those roots move to @apostel/bedrock/controlled. |
| The controlled model is a veto, not ownership | Your state is the truth; Radix renders it. | The DOM acts, then React can refuse. | Usually nothing: for Dialog both directions are cancelable in Chrome, so a refusal is invisible. What changes is ordering: onOpenChange fires before your state updates. Code that assumed open and the DOM were in lockstep needs a second look. |
onEscapeKeyDown, onPointerDownOutside, onInteractOutside, onOpenAutoFocus |
Props on Dialog.Content. |
None exist. Escape is cancel, refusable through onOpenChange under the controlled root. There is no outside-pointer event because there is no light dismiss. |
For the common onOpenAutoFocus case, focusing a specific field rather than the first tabbable, put autofocus on that element and showModal() honours it. |
| Nested dialogs | Stacked portals and managed z-index. |
The top layer stacks by open order, so nesting needs no configuration, but a nested modal <dialog> makes the outer one inert, as the platform defines it. |
Usually nothing. If you relied on interacting with the outer dialog underneath, that is now impossible rather than discouraged. |
Does it behave the same?
Radix's own Dialog suite, all 42 cases, is ported in
tests/radix-parity.spec.ts and runs in CI. 30 pass, none fail, and 13 test
machinery this library exists to delete.
The scorecard names every one.
Doing it with an agent
The mechanical half of this page is exactly the work an agent should do, so it
ships as one: skills/migrate-to-bedrock/SKILL.md is a Claude Code
skill that walks an agent through migrating a
Radix codebase. It is written for the agent rather than for you, and it is still worth
reading: it is the shortest honest description of what a migration actually
involves.
Installing it
Into a project you are migrating:
mkdir -p .claude/skills/migrate-to-bedrock
curl -o .claude/skills/migrate-to-bedrock/SKILL.md \
./skills/migrate-to-bedrock/SKILL.md
Then: "migrate this app off Radix". The description is written so the skill
triggers on that, and on the questions people ask afterwards: why a trigger
throws, why the dialog no longer closes on a backdrop click, why data-state
selectors stopped matching.
It also ships inside the package, at
node_modules/@apostel/bedrock/skills/migrate-to-bedrock/SKILL.md, so an agent
already working in the project can be pointed at it with no download.
What it makes the agent do
The order is the point: it refuses to start a refactor that ends badly.
| Step | What happens |
|---|---|
| 1. Establish viability | Before any code, it checks four things and reports them: jsdom tests that click triggers, triggers that are not buttons, data-state in shared CSS, and overlays with content in them. A project with hundreds of jsdom component tests and no Playwright setup gets told so, and the skill stops. |
| 2. The mechanical work | Imports, deleting Portal and Overlay, data-state → :open, the animation classes. |
| 3. Four judgement calls, surfaced not resolved | The button rule, light dismiss, which roots need /controlled, and what depended on content staying mounted. Each changes behaviour a user notices, so the skill is explicit that the agent must not decide them quietly. |
| 4. Verification that fits the failure mode | A bad trigger only throws when its component mounts, so type-checking is not enough. The app has to be opened in a browser. |
Why a skill rather than a codemod
Most of the diff is mechanical and a codemod could do it. The parts that matter are not: whether a trigger can become a button depends on what that element is for, and whether light dismiss should be restored depends on whether the dialog is a confirmation or a form. A codemod would either refuse those or guess, and guessing quietly is the worst of the three.
Per-primitive notes
Only the differences worth knowing before you start. Everything not listed is a straight swap.
| primitive | what changes |
|---|---|
Accordion |
type="single" is <details name>, so an open item can always be closed. Radix's collapsible={false} has no native equivalent. Header renders the <summary> and Trigger sits inside it, because a button inside a summary would be two tab stops. |
Checkbox, Switch, RadioGroup |
real <input>s. Indicator and Thumb render nothing; draw the mark with ::before under :checked. onCheckedChange gives a boolean, never "indeterminate". |
Progress |
a real <progress>. Indicator renders nothing; style ::-webkit-progress-value. |
Slider |
<input type="range">, so one thumb. A two-thumb range has no native equivalent. Track/Range/Thumb render nothing. |
Select |
a real <select> under appearance: base-select. Options are <option>, so on a phone you get the OS picker. ItemIndicator is option::checkmark. |
ScrollArea |
native scrolling. Scrollbar/Thumb/Corner render nothing; use scrollbar-width and scrollbar-color. |
Tabs |
the unselected panel is unmounted rather than hidden, so switching away resets it. |
Toast |
one popover="manual" region for the stack. No swipe-to-dismiss. |
Tooltip, HoverCard |
delayDuration/openDelay unchanged. The trigger may be an <a>, which is what makes link previews work without a wrapper. A long press opens both on a touch screen, where Radix opens neither. See touch. |
DropdownMenu, ContextMenu, Menubar |
same anatomy. Submenus need no configuration: a nested popover keeps its parent open because the invoker is inside it. |
NavigationMenu |
Viewport renders nothing: each content is anchored to its own item and already in the top layer. |
AspectRatio |
one element with aspect-ratio, not a padding wrapper. |
AccessibleIcon |
role="img" and aria-label on the glyph, not a visually hidden text node beside it. |
Radix and bedrock coexist without conflict. Different packages, no shared globals, no CSS collisions, so migrating one primitive at a time is safe.