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.

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