PlModal
A sheet that takes the page away until it is answered. The header and the actions stay put while only the body scrolls.
import { PlButton, PlModal, PlModalClose } from 'plass-ui';
<PlModal
trigger={<PlButton color="danger">Delete project</PlButton>}
title="Delete “Aurora”?"
description="Everything in it goes with it."
actions={<PlModalClose render={<PlButton color="danger">Delete</PlButton>} />}
>
<PlTextField label="Type the project name to confirm" />
</PlModal>;import 'package:plass_ui/plass_ui.dart';
PlModal(
open: deleting,
onOpenChanged: (bool next) => setState(() => deleting = next),
title: const Text('Delete “Aurora”?'),
description: const Text('Everything in it goes with it.'),
actions: <Widget>[
PlButton(color: PlassColor.danger, onPressed: destroy, child: const Text('Delete')),
],
child: PlTextField(
controller: name,
label: const Text('Type the project name to confirm'),
),
);A modal lifts itself out of the tree, so it needs an Overlay above it, WidgetsApp with a navigator and MaterialApp both provide one. Where it is written does not matter, and it takes up no room there.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The sheet's max width and type scale. Its steps are wider than the control ladder because it answers a different question: not how big, but how long a line of text is comfortable inside |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | The family behind the focus ring and the hover tint. The sheet itself is never dyed |
| densityshared | 'default' | 'compact' | 'default' | The padding of each section |
| open | boolean | — | Whether it is shown. Use with onOpenChange for a controlled modal |
| defaultOpen | boolean | false | Whether it starts open |
| onOpenChange | (open: boolean) => void | — | Called when it opens or closes |
| trigger | ReactElement | — | The element that opens the modal, wired up by Base UI. Optional |
| title | ReactNode | — | The heading. Rendered as the <h2> that names the modal |
| description | ReactNode | — | A line under the title, and the modal's accessible description |
| actions | ReactNode | — | The bottom row, end-aligned so a pair of buttons needs no wrapper. PlModalClose is what makes one of them dismiss |
| dividers | boolean | false | Scores the sheet between the header, the body and the actions. Worth turning on the moment the body scrolls |
| showClose | boolean | true | The × in the corner. On by default — a modal takes the page away until it is answered, and the way out should be visible |
| closeLabel | string | 'Close' | Accessible name of the × button |
| width | number | string | — | A hard cap on the width, overriding the one size implies. Numbers are pixels |
| fullWidth | boolean | true | Takes the full width the size allows. On by default, the other way round from every other component: a modal that shrank to fit two words would be a tooltip |
| fullScreen | boolean | false | Fills the viewport edge to edge |
| modal | boolean | 'trap-focus' | true | Whether the page behind is taken away. 'trap-focus' keeps it scrollable and clickable while still holding focus inside |
| dismissible | boolean | true | Whether Escape or a click outside closes it. Turn it off for the modal that has to be answered — and then give it actions that answer it |
| children | ReactNode | — | The body — the only part that scrolls |
| classNames | { backdrop?: string } | — | Classes on the parts a className does not reach. backdrop is the scrim drawn behind the surface |
| Prop | Type | Default | Description |
|---|---|---|---|
| open * | bool | — | Whether it is shown. Use with onOpenChange for a controlled modal |
| onOpenChanged | ValueChanged<bool>? | — | Called with what the open state should become. The × and a press outside both report rather than act |
| title | Widget? | — | The heading. Rendered as the <h2> that names the modal |
| description | Widget? | — | A line under the title, and the modal's accessible description |
| actions | List<Widget>? | — | The bottom row, end-aligned and wrapping, so a pair of buttons needs no row of its own |
| child | Widget? | — | The body — the only part that scrolls |
| dividers | bool | true | Scores the sheet between the header, the body and the actions. Worth turning on the moment the body scrolls |
| showClose | bool | true | The × in the corner. On by default — a modal takes the page away until it is answered, and the way out should be visible |
| closeLabel | String | 'Close' | Accessible name of the × button |
| width | double? | — | A hard cap on the width, overriding the one size implies. In logical pixels |
| fullWidth | bool | true | Takes the full width the size allows. On by default, the other way round from every other component: a modal that shrank to fit two words would be a tooltip |
| fullScreen | bool | false | Fills the viewport edge to edge |
| modal | bool | true | Whether the page behind is taken away for the pointer as well as the keyboard. false leaves it clickable while still holding focus inside |
| dismissible | bool | true | Whether Escape or a click outside closes it. Turn it off for the modal that has to be answered — and then give it actions that answer it |
| sizeshared | PlassSize | PlassSize.md | The sheet's max width and type scale. Its steps are wider than the control ladder because it answers a different question: not how big, but how long a line of text is comfortable inside |
| colorshared | PlassColor | PlassColor.primary | The family behind the focus ring and the hover tint. The sheet itself is never dyed |
| densityshared | PlassDensity | PlassDensity.standard | The padding of each section |
Every native <div> attribute passes straight through to the sheet. color, title and children are excluded because all three are Plass props here.
A className therefore lands on the sheet. The scrim behind it is a second element in the same portal, and classNames.backdrop is the way to reach it.
Controlled, like every other stateful thing in the package: the × and a press outside both call onOpenChanged rather than closing the modal themselves. There is no trigger and no PlModalClose, with the open state already in the caller's hands, a button that closes the modal is a button that sets it to false.
actions is a List<Widget> rather than one widget, so a pair of buttons needs no row of its own.
There is no variant: the three materials answer "how much does this surface assert itself against the page around it", and a modal has already taken the page. There is no elevation either, a modal that could be told to sit flat on the page would be one that could be told to stop being a modal, so its shadow is fixed at the top of the ladder.
PlModalClose
PlModalClose closes the modal it is inside. It exists because an uncontrolled modal has no setOpen for its Cancel button to call, and the alternative (making every modal controlled) is a piece of state per modal that exists only to answer a button.
<PlModalClose render={<PlButton variant="ghost">Cancel</PlButton>} />What the shared axes (size color density) mean across the library is in prop conventions.
Examples
size
The width and the type scale move together, and their steps are further apart than the control ladder's because they answer a different question: not how big is this thing, but how long a line of text is comfortable inside it. width is the escape hatch for the modal whose content decides, a wide table, a narrow confirmation.
dividers
Off by default. Turn it on the moment the body scrolls: the hairlines are what say the header stayed put rather than scrolling away with the content.
Controlled
Pass `open` with `onOpenChange` when something other than the trigger has to open it, or when an action has work to do before it closes.This is the only mode: `open` with `onOpenChanged`, which is also what lets an action do its work before the sheet goes.dismissible
Off, Esc and a click outside both stop closing the modal. Pair it with showClose={false} only when the actions genuinely answer it. Otherwise there is no way out at all.
Accessibility
- Base UI owns everything hard about it: the focus trap, the scroll lock, restoring focus to the trigger when it closes, and marking the page behind inert.
titlebecomes the<h2>that names the dialog anddescriptionits accessible description. Both wired by Base UI, so noaria-labelledbyis needed.- Esc closes it unless
dismissibleis off;modal="trap-focus"keeps the page behind scrollable while still holding focus inside. - The × is on by default, unlike most booleans in the library. A modal takes the page away until it is answered, and the visible way out should not have to be remembered.
- The sheet caps its own height and scrolls its body rather than growing past the viewport, so a tall modal never has its top pushed off the top of the screen where nothing can reach it.
- Opening and closing animate opacity only. A modal that scaled or slid in would drag its own text across the screen, and unlike a control, this one is full of text.
- Focus goes in and stays in: the sheet is its own focus scope, and traversal is bounded by the nearest scope, so Tab cannot land on the page under it. When the modal closes, focus goes back to whatever had it, the button that opened it.
- The layer names a route, which is how a screen reader knows the screen changed, and
titleis announced as a heading rather than read as the first line of the body. - Escape closes it unless
dismissibleis off;modal: falsekeeps the page behind clickable while still holding focus inside. - The × is on by default, unlike most of the switches in the library. A modal takes the page away until it is answered, and the visible way out should not have to be remembered.
- Only the body scrolls, and it is the only section allowed to give way when the sheet runs out of screen, a header that scrolled away would take the modal's name with it.
- Opening and closing animate opacity only. A modal that scaled or slid in would drag its own text across the screen, and unlike a control, this one is full of text. With animations turned off at the OS it appears at once.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
open / defaultOpen / onOpenChange | open / onOpenChanged | Flutter's own controls are controlled, and its name for the callback. |
trigger | — | With the open state already in the caller's hands, the thing that opens a modal is an ordinary button that sets it to true. |
PlModalClose | — | It exists in React to give an uncontrolled modal a way to close. There is no uncontrolled modal here. |
actions, one node | actions, a List<Widget> | Dart has no fragment, and a list is the thing a fragment was standing in for. |
modal={true | 'trap-focus'} | modal: bool | The two values were "does the pointer get through". A boolean says that in Flutter's words. |
fullScreen | fullScreen | Same, except that "the viewport" is the Overlay the sheet is lifted into. |
width: number | string | width: double | Logical pixels. There is no CSS length to accept. |
title as an <h2>, aria-describedby | a heading, and a named route | Flutter names the state on the node itself; there is no id to point at. |
| the scroll lock, the inert page | the barrier | There is no document to lock, and a page behind an opaque barrier is not reachable by pointer. |
children | child | Flutter's name. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. |