Styling
Every part renders exactly one element and forwards className and style, so
any styling approach works. bedrock.css is optional and is a demonstration,
not a dependency.
State selectors
State is native. This is a deliberate divergence from Radix, which uses
data-state="open" | "closed".
| you want | bedrock | Radix |
|---|---|---|
| dialog is open | dialog:open |
[data-state="open"] |
| popover is open | :popover-open |
[data-state="open"] |
| checkbox is checked | :checked |
[data-state="checked"] |
| the overlay | ::backdrop |
a separate Overlay element |
Why: data-state has to be written by JavaScript, which means it is a mirror
that can lag or desync, and it cannot be set at all before hydration. A closed
<dialog> matches :not(:open) from the moment the parser sees it.
The cost is real and worth naming: you cannot select an ancestor on a
descendant's open state, and :has() is the only way back, which is why
data-* attributes are still emitted for part identification
(data-bedrock-dialog, data-bedrock-dialog-trigger, …) even though state is
not.
Enter and exit transitions
No forceMount, no presence wrapper, no unmount timing in JavaScript. Three
things do the work:
[data-bedrock-dialog] {
opacity: 0;
scale: 0.96;
transition:
opacity 180ms,
scale 180ms,
display 180ms allow-discrete, /* keeps it displayed while exiting */
overlay 180ms allow-discrete; /* keeps it in the top layer while exiting */
}
[data-bedrock-dialog]:open {
opacity: 1;
scale: 1;
}
/* the state it animates *from* on entry */
@starting-style {
[data-bedrock-dialog]:open {
opacity: 0;
scale: 0.96;
}
}
overlay is the one people miss, and it is the one worth seeing rather than
reading. Both panels below are the same markup with the same 900ms fade. The
left one keeps overlay in its transition list; the right one does not, and
spends its fade behind a card it was above a moment ago.
Sourcetop-layer-exit.tsx
import { Popover } from '../../src/index'
/**
* What `transition: overlay` is for, in the only form that settles it.
*
* Both panels are the same markup with the same 900ms exit. The left one keeps
* `overlay ... allow-discrete` in its transition list and the right one does
* not, which is the whole difference between them.
*
* The grey band is an ordinary element with a `z-index`. While a panel is open
* it is in the top layer, so the band cannot touch it. On close, the panel that
* kept `overlay` stays up there for the whole fade; the other one is returned
* to normal flow on the first frame and spends its fade cut in half by a card
* it was above a moment ago.
*
* This is the bug that gets reported as "z-index is broken in my dialog".
*/
function Lane({ keeps }: { keeps: boolean }) {
return (
<div className="tle-lane" data-keeps={keeps || undefined}>
<p className="tle-label">
{keeps ? (
<code>transition: overlay 900ms allow-discrete</code>
) : (
<code>the same, without overlay</code>
)}
</p>
<Popover.Root>
<Popover.Trigger className="tle-trigger">Open, then close</Popover.Trigger>
<Popover.Content className="tle-panel" side="bottom" align="start" sideOffset={4}>
<span>Watch this fade.</span>
</Popover.Content>
</Popover.Root>
<div className="tle-card">a card, z-index 2</div>
</div>
)
}
export default function TopLayerExitDemo() {
return (
<div className="tle">
<Lane keeps />
<Lane keeps={false} />
</div>
)
}At the 180ms bedrock.css actually ships, that is a flicker on close, which is
why it gets reported as a z-index bug rather than a missing transition
property.
The backdrop
[data-bedrock-dialog]::backdrop {
background: rgb(0 0 0 / 0.4);
}
It is a pseudo-element, so it cannot be a React node, cannot take a className,
and cannot have children. If you need a clickable or animated overlay with
content in it, that is a real limitation. See
should you switch?.
Backdrop transitions need their own @starting-style block; a pseudo-element
does not inherit the host's.
Tailwind
Tailwind 4 handles all of this with variants and arbitrary selectors:
<Dialog.Content
className="
opacity-0 scale-96 transition-all duration-180
transition-discrete
open:opacity-100 open:scale-100
starting:open:opacity-0 starting:open:scale-96
backdrop:bg-black/40 backdrop:transition-opacity
"
/>
open:, starting:, backdrop: and transition-discrete are all built in.
Tailwind 3 needs arbitrary variants: [&:open]:, [&::backdrop]:.
Reduced motion
bedrock.css collapses its durations under prefers-reduced-motion: reduce
rather than removing the transitions, so allow-discrete still sequences the
display change correctly.