---
title: "ADR 0021. A modal surface owns its own open state"
description: "Sheet and Dialog declare open and onOpenChange, anything extending them inherits both, and a dialog never quietly becomes a sheet."
url: "https://opsinjs.pensievelabs.org/project/decisions/0021-modal-surfaces-own-their-open-state"
source: "https://opsinjs.pensievelabs.org/project/decisions/0021-modal-surfaces-own-their-open-state.md"
section: "Project"
kind: "project"
reviewed: "2026-09-03"
reviewer: "engineering"
aliases: ["open state", "onOpenChange", "sheetBelow", "extends sheet", "modal ownership"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="project" />

## Status [#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 [#context]

Two specifications describe surfaces that cannot be opened, and a third describes one that
changes its own contract at a breakpoint.

[ConsentSheet](../../components/consent-sheet.mdx) and
[LogSheet](../../components/log-sheet.mdx) 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](../../components/dialog.mdx) 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 [#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 [#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 with `severity: "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 `Sheet` or `Dialog` from 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 [#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 [#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.

<LastUpdated />

<Reviewed />
