ADR 0021. A modal surface owns its own open state
Sheet and Dialog declare open and onOpenChange, anything extending them inherits both, and a dialog never quietly becomes a sheet.
Status
Accepted. 2026-09-03. Implemented in registry/bases/base/sheet.tsx and
registry/bases/base/dialog.tsx, both of which render at /view and install through
shadcn add.
Context
Two specifications describe surfaces that cannot be opened, and a third describes one that changes its own contract at a breakpoint.
ConsentSheet and
LogSheet both say they "extend Sheet". Neither interface
declares open or onOpenChange. A component whose entire purpose is to appear over the
page, with no way for a caller to make it appear, is not an incomplete API. It is an API
that cannot be used at all, and the omission is identical in two documents written
separately, which usually means both authors assumed the base would carry it.
Separately, Dialog declares severity: "alert", which
promises a surface with no close control and a scrim that does not dismiss, and in the same
interface declares sheetBelow, which promises the dialog "renders as a Sheet instead"
below a width. A Sheet is draggable and dismissible by its own specification. Together
those two props make one component non-dismissible on a laptop and dismissible with a thumb
on a phone, silently, at whatever width the caller happened to pass. The reader who loses
the alert is the one holding the smaller screen.
There is a third thing, which nobody wrote down because it is not in any of our documents.
Base UI's dialog root passes escapeKey: isTopmost to its dismissal hook in every mode,
alert dialogs included (@base-ui/react/dialog/root/useDialogRoot.js). An untouched Base UI
alert dialog closes on Escape. A specification that says "on an alert dialog Escape does not
close it" is therefore a specification the primitive actively contradicts, and any
implementation that simply passes the prop through ships a non-dismissal contract that is
lost on the keyboard.
Decision
A modal surface declares open and onOpenChange, and anything that extends it inherits
both unchanged. Sheet and Dialog each own the pair. ConsentSheet and LogSheet do not
redeclare them, do not rename them, and do not wrap them in a differently-shaped
controller. They extend an interface that has them.
sheetBelow is not implemented and is not a prop. A component may not change which
dismissal contract it honours based on the width of the screen. severity decides whether
a surface can be dismissed and nothing else does; a product that wants a sheet on a phone
renders a Sheet on a phone, which it can do because both components exist and both are
installable.
Escape is intercepted rather than delegated. Dialog reads the dismissal reason on
onOpenChange and cancels the escape-key route when severity is alert, because the
primitive will otherwise close. The reason value is the kebab-case string escape-key, not
escapeKey; that is Base UI's own spelling and getting it wrong fails silently, which is
why it is written down here.
Sheet's onOpenChange carries the dismissal route as a second argument. Sheet's own
specification requires that a sheet holding unsaved input "asks first", and that it asks by
the same route as a background tap. A product cannot do that unless it is told the route.
The signature is widened rather than replaced: a one-argument handler is still assignable,
so onOpenChange={setOpen} typechecks unchanged.
Consequences
dismissible={false}does not turn a Sheet into a Dialog. Sheet's close control is the one route that is never cancelled, because its specification says the control is "always present, always keyboard-reachable". A surface that removes every exit is a Dialog withseverity: "alert", and that is a different component on purpose.- An alert dialog with no actions is a trap, and the component refuses it. With no close control, no dismissing scrim and no Escape, the actions are the way out. Shipping one without them raises a development warning naming the trap. This is a deliberate departure from Base UI's own guidance, which says to render a close control inside any modal popup so that touch screen readers can escape it; a close control on a surface whose contract is "there is no valid went-away outcome" would contradict the role it sits inside.
- The specifications for ConsentSheet and LogSheet are not amended to add the props. They say "extends Sheet" and that is now a statement with content. Batch E implements them against this record.
- A width-responsive presentation is still available, and it is the product's decision
rather than the component's: render
SheetorDialogfrom a media query the product owns. What is no longer available is a single component that silently swaps its safety contract underneath the caller.
Alternatives considered
Let ConsentSheet and LogSheet declare their own open/onOpenChange. Rejected.
Three components would then own three copies of the same pair, and the first time one of
them gained a dismissal reason the other two would not have it. "Extends" has to mean
something, and this is the smallest thing it can mean.
Implement sheetBelow and forbid it when severity is alert. Rejected, and it was
the tempting one because it keeps the prop. It makes the prop's legality depend on another
prop's value, which is a runtime error rather than a type error, and it leaves the
responsive-swap idea alive for the next person to extend to a case where it is unsafe
again. A prop that is only ever correct under a condition is better not offered.
Pass Escape through and document that Base UI wins. Rejected outright. The specification says an alert dialog does not close on Escape, and "the primitive does it anyway" is not a reason to publish a component that behaves differently from its own page. It is a reason to intercept, which costs nine lines.
Revisiting this
Revisit when Base UI gains a first-class non-dismissible mode that honours it on the keyboard as well as the pointer, at which point the interception here becomes duplication and should be deleted rather than kept "just in case".
Revisit sheetBelow if a real product shows a case where the same content genuinely needs
two dismissal contracts at two widths. That case would be evidence that severity is the
wrong axis, not that the prop was right.
Last read through against the system on 2026-09-03. Due for review every 12 months; expiry is reported by pnpm run check:freshness.
ADR 0017. precision means decimal places, everywhere
One name, one meaning, taken from the measurement rather than the unit. It is never significant figures, which would make one metric change shape as it moves.
ADR 0023. Three release phases, and only a component page carries one
The phase vocabulary becomes planned, shipped and deprecated, and status leaves every page that is not a component. Four of the six old words described nothing.