bedrock
Headless React primitives that let the browser do the layering, positioning and dismissal.
Radix-shaped anatomy: compound components, asChild, the part names you
already type. Underneath, the top layer, invoker commands and anchor positioning
do the work that Portal, Presence, DismissableLayer and Floating UI used to
do.
npm i @apostel/bedrock
Sourcedialog.tsx
import { useState } from 'react'
import { Dialog } from '../../src/index'
/**
* The trigger is an invoker: it carries `commandfor` and `command`, and the
* browser opens the dialog. Nothing here listens for a click.
*
* Type into the field, close, and reopen — it is empty again, because closed
* content unmounts. No effect resets it.
*/
export default function DialogDemo() {
const [saved, setSaved] = useState('')
return (
<>
<Dialog.Root>
<Dialog.Trigger>Rename project</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Rename project</Dialog.Title>
<Dialog.Description>This is shown to everyone on the team.</Dialog.Description>
<form
method="dialog"
onSubmit={(event) => setSaved(new FormData(event.currentTarget).get('name') as string)}
>
<div className="demo-row">
<input name="name" placeholder="New name" autoComplete="off" />
</div>
<Dialog.Close>Cancel</Dialog.Close> <button type="submit">Save</button>
</form>
</Dialog.Content>
</Dialog.Root>
{saved ? <output>saved: {saved}</output> : null}
</>
)
}The shape
import { Dialog } from '@apostel/bedrock'
<Dialog.Root>
<Dialog.Trigger>Delete project</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Delete project?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
<button onClick={destroy}>Delete</button>
</Dialog.Content>
</Dialog.Root>
renders roughly this:
<button commandfor="d1" command="show-modal">Delete project</button>
<dialog id="d1">…</dialog>
No effect opens it. No state hook holds it. No portal moves it. The parser wires the button to the dialog, so it works before hydration and keeps working if your bundle never arrives.
What it saves
Measured with esbuild, minified and gzipped, React external:
bedrock Radix
- Select0.84 kB, was 31.537.5×
- Slider0.74 kB, was 9.813.2×
- Popover2.11 kB, was 24.211.5×
- DropdownMenu3.55 kB, was 31.68.9×
- Checkbox0.70 kB, was 5.98.4×
- Tooltip2.55 kB, was 19.37.6×
- Dialog1.95 kB, was 13.77.0×
- Accordion1.61 kB, was 8.85.5×
The ratio is largest where the platform hands over a whole element and smallest where it hands over nothing but the layering. Menus are the case to budget from: they save more kilobytes than anything but Select, and they still leave the largest bedrock bundle in the library, because roving focus and typeahead have no native equivalent and ship either way. Should you switch? breaks that down by group. The larger win is behavioural anyway: no z-index fights, no portal clipping, no repositioning on every scroll frame.
What it costs
bedrock targets current browsers on purpose, and the price is a hard floor. Triggers are invoker commands with no JavaScript standing behind them, so:
| Chrome | Firefox | Safari | |
|---|---|---|---|
| Minimum | 135 | 144 | 26.2 |
Above that line, missing features degrade: placement falls back to the centre of
the viewport, transitions are skipped, a <select> renders as the OS dropdown.
The component still opens, closes, traps focus and is announced correctly. Below
it, nothing opens at all.
Browser support has every feature, its minimum version in each engine, and what its absence does to your UI, measured in the browser you open it in.
Where to start
- Getting started: install, a first component, and the one rule that applies to every trigger.
- Should you switch?: the case against adopting this. Read it first if you are evaluating.
- Migrating from Radix: what changes, what breaks, and what to do about it.
- shadcn registry: shadcn's own components with
Radix swapped for bedrock, installable with
npx shadcn add. - Agent skill: does the mechanical migration and brings you the four decisions it cannot make for you.
Status
0.1.1 on npm. All 29 primitives, 200 Playwright tests against real Chrome.
What is missing is soak time and other engines, not components.