Skip to content

PlModal

A sheet that takes the page away until it is answered. The header and the actions stay put while only the body scrolls.

React
tsx
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>;
dart
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

PropTypeDefaultDescription
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
openbooleanWhether it is shown. Use with onOpenChange for a controlled modal
defaultOpenbooleanfalseWhether it starts open
onOpenChange(open: boolean) => voidCalled when it opens or closes
triggerReactElementThe element that opens the modal, wired up by Base UI. Optional
titleReactNodeThe heading. Rendered as the <h2> that names the modal
descriptionReactNodeA line under the title, and the modal's accessible description
actionsReactNodeThe bottom row, end-aligned so a pair of buttons needs no wrapper. PlModalClose is what makes one of them dismiss
dividersbooleanfalseScores the sheet between the header, the body and the actions. Worth turning on the moment the body scrolls
showClosebooleantrueThe × in the corner. On by default — a modal takes the page away until it is answered, and the way out should be visible
closeLabelstring'Close'Accessible name of the × button
widthnumber | stringA hard cap on the width, overriding the one size implies. Numbers are pixels
fullWidthbooleantrueTakes 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
fullScreenbooleanfalseFills the viewport edge to edge
modalboolean | 'trap-focus'trueWhether the page behind is taken away. 'trap-focus' keeps it scrollable and clickable while still holding focus inside
dismissiblebooleantrueWhether 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
childrenReactNodeThe 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
PropTypeDefaultDescription
open * boolWhether it is shown. Use with onOpenChange for a controlled modal
onOpenChangedValueChanged<bool>?Called with what the open state should become. The × and a press outside both report rather than act
titleWidget?The heading. Rendered as the <h2> that names the modal
descriptionWidget?A line under the title, and the modal's accessible description
actionsList<Widget>?The bottom row, end-aligned and wrapping, so a pair of buttons needs no row of its own
childWidget?The body — the only part that scrolls
dividersbooltrueScores the sheet between the header, the body and the actions. Worth turning on the moment the body scrolls
showClosebooltrueThe × in the corner. On by default — a modal takes the page away until it is answered, and the way out should be visible
closeLabelString'Close'Accessible name of the × button
widthdouble?A hard cap on the width, overriding the one size implies. In logical pixels
fullWidthbooltrueTakes 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
fullScreenboolfalseFills the viewport edge to edge
modalbooltrueWhether the page behind is taken away for the pointer as well as the keyboard. false leaves it clickable while still holding focus inside
dismissiblebooltrueWhether 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
sizesharedPlassSizePlassSize.mdThe 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
colorsharedPlassColorPlassColor.primaryThe family behind the focus ring and the hover tint. The sheet itself is never dyed
densitysharedPlassDensityPlassDensity.standardThe 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.

tsx
<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.

React

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.

React

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.
React

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.

React

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.
  • title becomes the <h2> that names the dialog and description its accessible description. Both wired by Base UI, so no aria-labelledby is needed.
  • Esc closes it unless dismissible is 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 title is announced as a heading rather than read as the first line of the body.
  • Escape closes it unless dismissible is off; modal: false keeps 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

ReactFlutterWhy
open / defaultOpen / onOpenChangeopen / onOpenChangedFlutter's own controls are controlled, and its name for the callback.
triggerWith the open state already in the caller's hands, the thing that opens a modal is an ordinary button that sets it to true.
PlModalCloseIt exists in React to give an uncontrolled modal a way to close. There is no uncontrolled modal here.
actions, one nodeactions, a List<Widget>Dart has no fragment, and a list is the thing a fragment was standing in for.
modal={true | 'trap-focus'}modal: boolThe two values were "does the pointer get through". A boolean says that in Flutter's words.
fullScreenfullScreenSame, except that "the viewport" is the Overlay the sheet is lifted into.
width: number | stringwidth: doubleLogical pixels. There is no CSS length to accept.
title as an <h2>, aria-describedbya heading, and a named routeFlutter names the state on the node itself; there is no id to point at.
the scroll lock, the inert pagethe barrierThere is no document to lock, and a page behind an opaque barrier is not reachable by pointer.
childrenchildFlutter's name.
className, style, native attributesThere is no class list and no style attribute to pass through.

Released under the MIT License