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
Loading demo…
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

  1. Select0.84 kB, was 31.537.5×
  2. Slider0.74 kB, was 9.813.2×
  3. Popover2.11 kB, was 24.211.5×
  4. DropdownMenu3.55 kB, was 31.68.9×
  5. Checkbox0.70 kB, was 5.98.4×
  6. Tooltip2.55 kB, was 19.37.6×
  7. Dialog1.95 kB, was 13.77.0×
  8. Accordion1.61 kB, was 8.85.5×
Measured by hand on 2026-02-14. Nothing in CI asserts these, which is a known gap.

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

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.

Contributing · Releasing · Changelog