Getting started
npm i @apostel/bedrock
React 19 or later, as a peer. No runtime dependencies of its own.
import { Dialog } from '@apostel/bedrock'
export function DeleteProject({ onConfirm }: { onConfirm(): void }) {
return (
<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 type="button" onClick={onConfirm}>
Delete
</button>
</Dialog.Content>
</Dialog.Root>
)
}
That renders:
<button type="button" commandfor="«r0»" command="show-modal">Delete project</button>
<dialog id="«r0»" aria-labelledby="«r0»-title" aria-describedby="«r0»-description">…</dialog>
No effect opens it, no state hook holds it, no portal moves it. The button is
bound to the dialog by the parser, which is why the markup above works before
React hydrates and keeps working if the bundle never arrives. There is a test
for exactly that — tests/no-javascript.spec.ts renders the markup, disables
JavaScript, and clicks it.
The one rule
Triggers must be buttons. Invoker commands are defined on <button> and
nowhere else, so a trigger that renders anything else has no keyboard
activation and no implicit aria-expanded.
// fine
<Dialog.Trigger asChild>
<MyButton>Delete</MyButton>
</Dialog.Trigger>
// throws at mount in development, console.error in production
<Dialog.Trigger asChild>
<div onClick={…}>Delete</div>
</Dialog.Trigger>
There is no fallback click handler, deliberately — see
gaps. If you are wrapping a
third-party component that renders a div and you cannot change it, take the
props and own the accessibility:
const props = useDialogTrigger()
<ThirdPartyThing {...props} />
The same check catches a <button> inside a <form> without type="button",
which is the version of this mistake people actually make. Submitting a form
and invoking a dialog are conflicting behaviours, so the browser ignores the
invocation and nothing happens at all.
Styling
Nothing is styled. Every part takes className and renders exactly one
element. bedrock.css is a demonstration you can import or ignore:
import '@apostel/bedrock/bedrock.css'
See styling for how open and closed states are selected —
short version, :open and ::backdrop, not data-state.
Next
- Two roots — when you need
opento be a prop - Dialog API
- Browser support — read this before shipping to the public web