We have two dialogs. One is the normal one: translucent scrim over the page, close button in the corner, Escape works, clicking outside works. The second one makes the opposite choice on every single point, and it is a separate component rather than a prop on the first.
/**
* Opaque, full-screen, non-dismissible dialog.
*
* Separate from components/ui/dialog.tsx because it makes the opposite choices on
* purpose:
*
* - The overlay is `bg-background`, not a translucent scrim. These flows ask the
* user for one thing, and leaving the dashboard visible behind a see-through
* panel invites them to try to interact with it.
* - There is no close button, and Escape / outside-click / pointer-down-outside
* are all suppressed. The only exits are the actions inside the content.
*/
The implementation is one component and three event handlers:
<DialogPrimitive.Overlay className="bg-background fixed inset-0 z-[100] ..." />
<DialogPrimitive.Content
// Suppressed rather than merely unhandled: without these, Escape and a
// click on the backdrop would both close the dialog and skip the question.
onEscapeKeyDown={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
className="bg-background fixed inset-0 z-[101] flex flex-col overflow-y-auto ..."
>
Suppressed rather than unhandled
That comment is the one I would keep if I could only keep one.
Radix's dismissal behaviour is not something you opt into. If you render a controlled dialog and simply do not pass onEscapeKeyDown, Escape still closes it, because the primitive fires its own close. "I did not write a handler" and "I prevented the default" look equally quiet in a diff and mean opposite things, which is exactly the sort of difference that gets silently reintroduced by somebody tidying up props they think are unused.
The second half of the comment says what the behaviour costs: Escape and an outside click do not merely dismiss the panel, they skip the question. For a one-time ask, dismissed and answered are the same state, and the user never sees it again.
Non-dismissible does not mean inaccessible
The thing I expected to be a tradeoff here is not one. Radix is still doing all the work that matters for assistive technology: focus trap, focus restore on close, aria-modal, scroll lock on the body. Suppressing three dismissal paths removes none of that.
What it does create is one hard obligation, and it is written into the component's own doc comment:
The content MUST therefore always render a way forward, or the user is stuck.
That is a real failure mode and it is not theoretical. A non-dismissible dialog whose only action is behind a failed network request is an app your user has to reload to escape. Any content that goes in here has to be able to render its exit without a successful fetch.
The place it earns its keep
The one flow that uses it is a one-time ask for a public review, shown to paying customers after enough completed practice to have an opinion, and asked exactly once whether or not it is acted on. It has exactly two outcomes, go and review or skip, and it stores neither an opinion nor a score, only the fact that it was answered.
Which makes the layout rule the interesting part of that component rather than the dialog:
The skip is the same size and full width as the review button, directly below it. Making the way out small is how a one-time ask turns into a resented one: anyone who does not want to review should be able to leave in one obvious click, on the first try, on a phone.
An opaque, un-closable, full-screen modal is about as aggressive as in-app UI gets. The thing that makes it acceptable is that the way out is as big as the way in. Suppressing Escape and then hiding the skip behind a 10 pixel grey link would be a dark pattern, and the component would be the same component.
There is a product decision underneath it that is worth stating plainly. This replaced an in-app star rating, which told us something we could not act on and told the user nothing they could use. The reviews it asks for are public ones, and they are the same reviews quoted on our home page, each card linking to its source so a visitor deciding whether the practice is worth paying for can check that a stranger wrote it. A private rating is a metric. A public review is something the next candidate can read.
When to write the second dialog
The honest test for whether you need two components rather than one with a dismissible prop: list the differences. Ours were the overlay colour, the presence of a close button, three event handlers, two z-index layers and a scroll container, which is most of the component. At that point a prop is not a variant, it is a second implementation wearing the first one's name.
There is now a third dialog in the codebase that is built on neither, a provider picker, and its header comment names both of the others to explain itself: not the default one, whose content hard-codes a centred max-width panel that would have to be undone class by class, and not this one either, because a picker has to be escapable. Three dialogs is more than I would have guessed we needed. Each one knowing why it is not the other two is what keeps that from being a mess.
Top comments (0)