@propellerads/dialog
v1.0.0
Published
`Dialog` is the blocking window: the page stays where it is, the dialog takes the focus and the keyboard, and nothing else answers until it is done. It is built the shadcn way — one stylesheet, variants picked by [class-variance-authority](https://cva.sty
Keywords
Readme
Dialog
Dialog is the blocking window: the page stays where it is, the dialog takes the
focus and the keyboard, and nothing else answers until it is done. It is built
the shadcn way — one stylesheet, variants picked by
class-variance-authority, the dialog itself from
Base UI — and every value in the
stylesheet comes from @propellerads/tokens.
It replaces @propellerads/popup, which is the same window
built on styled-components and a hand-rolled portal. See
From Popup.
Installation
bun add @propellerads/dialogThe dialog brings its own stylesheet: the built entry imports it, so importing the component is the whole of it. The tokens are the app's, not the dialog's — they carry the brand, and only the app knows which brand it is:
import '@propellerads/tokens/common.css';
import '@propellerads/tokens/propellerads.css'; // or monetag / propush / zeydooThe tokens are not optional: no var() in the stylesheet carries a fallback, so
without them the dialog has no colours, no spacing and no type. That is on
purpose — a page with no design system on it should look like one, not like a
dialog that almost works. The stylesheet is also exported as
@propellerads/dialog/index.css, for a consumer that extracts CSS by hand.
Use
import Dialog from '@propellerads/dialog';
import Button, {ButtonVariant} from '@propellerads/button';
<Dialog isOpen={isOpen} onClose={close} title="Title" footer={
<>
<Button variant={ButtonVariant.Secondary} onClick={close}>Cancel</Button>
<Button onClick={save}>OK</Button>
</>
}>
What the dialog is about.
</Dialog>Three parts, in the order the design file stacks them: a header with the title and the cross, the content, and a footer whose buttons end at the right edge. A part that is not passed is not rendered — a dialog with no footer has no empty row at the bottom, and no gap where one would have been.
The open state is the product's. Pass isOpen and close it yourself from
onClose; nothing closes behind your back. For a dialog whose state nobody else
needs, hand it a trigger instead and let it keep its own:
<Dialog trigger={<Button>Open</Button>} title="Title">…</Dialog>Blocking
Dismissable is the default: the cross, Escape and the backdrop all close the dialog. They are one decision rather than three — a cross that is drawn but does not close, or an Escape that works while the cross is gone, is a dialog lying about itself. Turn it off for a choice that has to be taken, and the cross goes with it:
<Dialog isOpen={isOpen} isDismissable={false} title="Delete the campaign?" footer={…}>The way out is then whatever the footer offers.
Width
| Width | Max | Where it comes from |
| --- | --- | --- |
| minimum | 296px | The design file's own size. The default. |
| small | 400px | Popup v4's small |
| medium | 600px | Popup v4's medium |
| big | 1080px | Popup v4's big |
A step is the width the box holds. It gives way to a narrower viewport, and below the mobile breakpoint every step is the width of the screen — but it does not shrink to its content: a ceiling alone would do nothing, because what is inside a dialog stretches to the box rather than the other way round.
When none of the four is the answer, set the custom property instead of reaching for a fifth name:
.onboarding .dialog { --dialog-width: 720px; }Long content
The box grows with what is inside it, as shadcn's does. A dialog taller than the screen scrolls with the page behind it, header and footer included. Nothing inside is clipped, so a select menu, a date picker or a hint opens past the edge of the box and stays clickable.
Content that is long by nature brings its own scroller, the way shadcn's "Scrollable content" example does. The heading and the buttons then hold their place:
<Dialog isOpen={isOpen} onClose={close} title="Preferred columns" footer={…}>
<div className="columns">…</div>
</Dialog>.columns { max-height: 50vh; overflow-y: auto; }Keep menus out of that scroller: a select inside it is clipped by it.
Props
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| isOpen | boolean | — | Whether the dialog is open. Leave it out for an uncontrolled dialog driven by trigger. |
| defaultOpen | boolean | false | The state an uncontrolled dialog starts in. |
| onOpenChange | (isOpen: boolean) => void | — | Called whenever the dialog opens or closes, whoever asked for it. |
| onClose | () => void | — | Called when the dialog asks to be closed. The shorthand for onOpenChange. |
| title | ReactNode | — | The heading, and the dialog's accessible name. |
| children | ReactNode | — | What the dialog is about. It does not scroll on its own: see Long content. |
| footer | ReactNode | — | The row at the bottom. Buttons end at the right edge. |
| width | DialogWidth | 'minimum' | How wide the box may grow. |
| isDismissable | boolean | true | Whether the dialog can be dismissed at all: the cross, Escape and the backdrop together. |
| closeLabel | string | 'Close' | The cross's accessible name. |
| closeIcon | ReactNode | the pack's Close | Replaces the glyph inside the close button. |
| trigger | ReactElement | — | What opens the dialog. The element itself becomes the trigger. |
| render | ReactElement \| fn | — | Replaces the box with another element. See As a form. |
| className | string | — | Goes on the box. |
| backdropClassName | string | — | Goes on the backdrop. |
| initialFocus | boolean \| RefObject \| fn | true | Where the focus lands when it opens. |
| finalFocus | boolean \| RefObject \| fn | true | Where the focus returns when it closes. |
| aria-label | string | — | Required when there is no title: a dialog has to say what it is. |
As a form
A submit button in the footer cannot reach a <form> that lives in the content —
they are siblings. Render the box as the form instead, and the header, the content
and the footer are all inside it:
<Dialog
isOpen={isOpen}
onClose={close}
title="New domain pool"
render={<form onSubmit={save} />}
footer={
<>
<Button variant={ButtonVariant.Secondary} onClick={close}>Cancel</Button>
<Button type="submit">Save</Button>
</>
}
>
<InputText name="name" />
</Dialog>The dialog keeps everything it had — role="dialog", the focus trap, the scroll
lock — and gains what a form gives: the submit button works, and Enter in a field
submits.
Styling
The custom properties at the top of .dialog are the override API. They need
no theme provider — any ancestor of the portal can set them, and so can the
dialog's own className:
| Property | Default | What it is |
| --- | --- | --- |
| --dialog-width | 296px | What the width variant sets. |
| --dialog-bg | --color-shade-white-inverter | The surface. Flips with the theme. |
| --dialog-fg | --color-shade-black-inverter | The text. |
| --dialog-padding | --space-24 | Inside the box. One step less below the mobile breakpoint. |
| --dialog-gap | --space-24 | Between header, content and footer. |
| --dialog-radius | --radius-24 | The corner. |
| --dialog-shadow | --shadow-big | The lift off the backdrop. |
| --dialog-backdrop | --over-fullscreen | The dimming behind it. |
| --dialog-backdrop-blur | 4px | How far out of focus the page behind goes. 0 takes the blur off. |
| --dialog-layer | 3000 | The stacking order, as Popup v4 had it. |
The cross is @propellerads/button — a ghost, icon-only button — repainted by
.dialog__close.button { --button-accent: var(--color-quiet) }. It is the
quietest thing in the dialog and the action colour belongs to the footer, and the
button has no neutral tone yet. Two classes, because the tone class sets that
variable on the element itself, so an ancestor cannot reach it.
dialogVariants is exported for a product that renders Base UI's parts itself and
only wants the look:
<Dialog.Popup className={dialogVariants({width: 'medium'})} />Screenshots
The Storybook story at 2×: the playground, then the five shapes — a question, something to agree to, a form, more than fits, and wide.
| Light | Dark | |---|---| | | |
Where this differs from the design file
Three values are deliberately not what the Figma frame draws. They are listed here so nobody "fixes" them back:
| | The file | Here | Why |
| --- | --- | --- | --- |
| big width | 800px (Popup old/Large) | 1080px | What Popup v4 shipped, and the screens are built against it. |
| Corner | 8px | --radius-24 | Decided after the frame was drawn. |
| Shadow | Shadow/Middle | --shadow-big | The token for large containers standing over an overlay. |
From Popup
@propellerads/popup is the same window with a different shape. The move is a
rename of most props:
| Popup v4 | Dialog | Note |
| --- | --- | --- |
| header | title | The heading is a heading now — <h2>, read out as the dialog's name. |
| children | children | Unchanged. |
| footer | footer | Unchanged. |
| size | width | Same three sizes under the same names, plus minimum. |
| closePopupHandler | onClose | Takes no argument: it is only ever called with false. |
| closePopupHandler={undefined} | isDismissable={false} | No cross meant no way to close it. Now that is one decision, and it takes Escape and the backdrop with it. |
| wrapperHandler | isDismissable / onClose | A click on the backdrop closes the dialog; isDismissable={false} makes it do nothing. |
| theme | — | Dropped. The backdrop follows the page's theme through the tokens. |
| popupCustomStyles | className | A class, or one of the custom properties above. |
| — | isOpen | Popup was mounted to be shown; the dialog is told. |
Popup rendered only when it was mounted, so the calling code carried an
{isVisible && <Popup />}. The dialog takes isOpen and handles its own mounting,
which is what gives it the closing animation, the focus return and the scroll
lock.
