PlOverlay
A sheet over the whole page that stops it being used. The scrim on its own, with whatever the caller puts on top of it. Most often a spinner and a line saying what is being waited for.
import { PlOverlay } from 'plass-ui';
<PlOverlay open={saving} label="Saving your changes">
<Spinner />
</PlOverlay>;import 'package:plass_ui/plass_ui.dart';
PlOverlay(
open: saving,
label: 'Saving your changes',
child: const Spinner(),
);An overlay 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 |
|---|---|---|---|
| open | boolean | — | The overlay is shown. Use with onOpenChange for a controlled overlay |
| defaultOpen | boolean | — | Whether it starts shown, for an uncontrolled one |
| onOpenChange | (open: boolean) => void | — | Called when the open state changes |
| tone | 'scrim' | 'glass' | 'solid' | 'clear' | 'scrim' | How much of the page is taken away. scrim is PlModal’s own backdrop, glass is a lighter dim over a real blur, solid is opaque, and clear draws nothing while still blocking the pointer |
| dismissible | boolean | false | Whether clicking the overlay or pressing Escape closes it. Off by default, the other way round from PlModal: a modal asks a question and Escape is the universal "no", while an overlay is saying *wait* |
| modal | boolean | 'trap-focus' | true | Whether the page behind is taken away for the keyboard too. trap-focus leaves it scrollable and clickable while still holding focus inside |
| alignshared | 'start' | 'center' | 'end' | 'center' | Where the content sits down the viewport |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Scale of the padding around the content |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Reaches the focus ring and whatever the content reads |
| label | string | 'Overlay' | The accessible name. Never drawn. An overlay that holds nothing readable still has to say what it is, which is why this has a default |
| children | ReactNode | — | What sits on top of the scrim — a spinner, a line of text, a small card |
| 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 | — | The overlay is shown. Use with onOpenChange for a controlled overlay |
| onOpenChanged | ValueChanged<bool>? | — | Called with false when the overlay asks to be closed — only ever when dismissible is on |
| child | Widget? | — | What sits on top of the scrim — a spinner, a line of text, a small card |
| tone | PlOverlayTone | PlOverlayTone.scrim | How much of the page is taken away. scrim is PlModal’s own backdrop, glass is a lighter dim over a real blur, solid is opaque, and clear draws nothing while still blocking the pointer |
| dismissible | bool | false | Whether clicking the overlay or pressing Escape closes it. Off by default, the other way round from PlModal: a modal asks a question and Escape is the universal "no", while an overlay is saying *wait* |
| 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 |
| alignshared | PlassAlign | PlassAlign.center | Where the content sits down the viewport |
| sizeshared | PlassSize | PlassSize.md | Scale of the padding around the content |
| label | String | 'Overlay' | The accessible name. Never drawn. An overlay that holds nothing readable still has to say what it is, which is why this has a default |
Every native <div> attribute passes straight through, onto the popup. color and children are excluded from the pass-through because both are Plass props here.
A className lands on the popup with them. The scrim underneath is what classNames.backdrop reaches.
Controlled: open and onOpenChanged are how an overlay is driven, and there is no uncontrolled mode. onOpenChanged is only ever called when dismissible is on, because nothing else can ask.
There is no color either. The one thing a colour family reached in the React build was the slots the content reads, and content in Flutter arrives with its own.
There is no variant. The three materials answer "how much does this surface assert itself against the page", and an overlay has already taken the page; tone is the question it actually has to answer. There is no elevation either: the overlay is the plane everything else floats above, and a scrim with a drop shadow is a scrim with an edge.
What the shared axes (size color align) mean across the library is in prop conventions.
Examples
tone
The four steps are one axis, how legible is what is behind, and they are tuned with the blur radius as much as with the alpha, because past about 16px a backdrop smears into flat colour and the scrim reads opaque no matter how low its alpha goes.
scrim matches PlModal's backdrop exactly. The two have to, or a modal opened over an overlay would show a seam.
clear draws nothing at all and still covers the viewport, which is the whole reason to use it: an invisible sheet that catches a click.
import { useState } from 'react';
import { PlButton, PlOverlay, PlTypography } from 'plass-ui';
const tones = ['scrim', 'glass', 'solid', 'clear'] as const;
export default function OverlayTones() {
const [tone, setTone] = useState<(typeof tones)[number] | null>(null);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
{tones.map((one) => (
<PlButton
key={one}
size="sm"
variant="glass"
color="secondary"
onClick={() => setTone(one)}
>
{one}
</PlButton>
))}
</div>
<PlTypography level="caption">Press one, then press the sheet to close it.</PlTypography>
<PlOverlay
dismissible
tone={tone ?? 'scrim'}
open={tone !== null}
onOpenChange={(next) => !next && setTone(null)}
label={`The ${tone} overlay`}
>
<PlTypography level="h4" color="primary">
{tone}
</PlTypography>
</PlOverlay>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class OverlayTones extends StatefulWidget {
const OverlayTones({super.key});
@override
State<OverlayTones> createState() => _OverlayTonesState();
}
class _OverlayTonesState extends State<OverlayTones> {
PlOverlayTone? _tone;
@override
Widget build(BuildContext context) {
// A preview is as tall as its content, and a sheet takes away whatever it is
// inside. This is the page for it to take.
return SizedBox(
height: 300,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
for (final tone in PlOverlayTone.values)
PlButton(
size: PlassSize.sm,
variant: PlassVariant.glass,
color: PlassColor.secondary,
onPressed: () => setState(() => _tone = tone),
child: Text(tone.name),
),
],
),
const PlTypography(
'Press one, then press the sheet to close it.',
level: PlTypographyLevel.caption,
),
PlOverlay(
open: _tone != null,
dismissible: true,
tone: _tone ?? PlOverlayTone.scrim,
label: 'The ${_tone?.name} overlay',
onOpenChanged: (bool next) => setState(() => _tone = null),
child: PlTypography(
_tone?.name ?? '',
level: PlTypographyLevel.h4,
color: PlassColor.primary,
),
),
],
),
);
}
}dismissible
Off by default, which is the other way round from PlModal and the one prop here worth reading twice. A modal asks a question and Escape is the universal "no"; an overlay says wait rather than asking anything, and a save that can be dismissed by a stray click is a save the user will think finished.
Turn it on for the overlay whose job is to catch a click outside something.
import { useState } from 'react';
import { PlButton, PlOverlay, PlTypography } from 'plass-ui';
export default function OverlayDismissible() {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-wrap gap-2">
<PlButton size="sm" onClick={() => setOpen(true)}>
Open a dismissible one
</PlButton>
<PlOverlay
dismissible
tone="glass"
open={open}
onOpenChange={setOpen}
label="Press anywhere to close"
>
<PlTypography level="lead" color="primary">
Press anywhere, or Escape.
</PlTypography>
</PlOverlay>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class OverlayDismissible extends StatefulWidget {
const OverlayDismissible({super.key});
@override
State<OverlayDismissible> createState() => _OverlayDismissibleState();
}
class _OverlayDismissibleState extends State<OverlayDismissible> {
bool _open = false;
@override
Widget build(BuildContext context) {
// A preview is as tall as its content, and a sheet takes away whatever it is
// inside. This is the page for it to take.
return SizedBox(
height: 300,
width: double.infinity,
child: Stack(
children: <Widget>[
PlButton(
size: PlassSize.sm,
onPressed: () => setState(() => _open = true),
child: const Text('Open a dismissible one'),
),
PlOverlay(
open: _open,
dismissible: true,
tone: PlOverlayTone.glass,
label: 'Press anywhere to close',
onOpenChanged: (bool next) => setState(() => _open = next),
child: const PlTypography(
'Press anywhere, or Escape.',
level: PlTypographyLevel.lead,
color: PlassColor.primary,
),
),
],
),
);
}
}align
import { useState } from 'react';
import { PlButton, PlOverlay, PlTypography } from 'plass-ui';
export default function OverlayAlign() {
const [align, setAlign] = useState<'start' | 'center' | 'end' | null>(null);
return (
<div className="flex flex-wrap gap-2">
{(['start', 'center', 'end'] as const).map((one) => (
<PlButton
key={one}
size="sm"
variant="glass"
color="secondary"
onClick={() => setAlign(one)}
>
{one}
</PlButton>
))}
<PlOverlay
dismissible
align={align ?? 'center'}
open={align !== null}
onOpenChange={(next) => !next && setAlign(null)}
label={`Aligned to ${align}`}
>
<PlTypography level="h4" color="primary">
{align}
</PlTypography>
</PlOverlay>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class OverlayAlign extends StatefulWidget {
const OverlayAlign({super.key});
@override
State<OverlayAlign> createState() => _OverlayAlignState();
}
class _OverlayAlignState extends State<OverlayAlign> {
PlassAlign? _align;
@override
Widget build(BuildContext context) {
// A preview is as tall as its content, and a sheet takes away whatever it is
// inside. This is the page for it to take.
return SizedBox(
height: 300,
width: double.infinity,
child: Stack(
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
for (final align in PlassAlign.values)
PlButton(
size: PlassSize.sm,
variant: PlassVariant.glass,
color: PlassColor.secondary,
onPressed: () => setState(() => _align = align),
child: Text(align.name),
),
],
),
PlOverlay(
open: _align != null,
dismissible: true,
align: _align ?? PlassAlign.center,
label: 'Aligned to ${_align?.name}',
onOpenChanged: (bool next) => setState(() => _align = null),
child: PlTypography(
_align?.name ?? '',
level: PlTypographyLevel.h4,
color: PlassColor.primary,
),
),
],
),
);
}
}Accessibility
- Base UI's Dialog owns the hard parts: the portal, the scroll lock, the focus held inside, the page behind going inert, and focus returning to wherever it came from when the overlay closes.
labelhas a default rather than being left empty, because an overlay that holds nothing readable (a bare spinner, aclearsheet) still has to say what it is.modal="trap-focus"keeps the page scrollable and clickable while still holding focus inside, which is what aclearoverlay usually wants.- The overlay animates opacity and nothing else. One that scaled or slid would drag whatever is written on it across the screen, and unlike a control this one is usually carrying a sentence.
- Use a
PlModalinstead when there is a question to answer. An overlay has no title, no description and no actions, so a screen reader has nothing to work with beyondlabel.
- Focus goes in and stays in: the layer is its own focus scope, and traversal is bounded by the nearest scope, so Tab inside the overlay cannot land on the page under it. When the overlay closes, focus goes back to whatever had it.
labelhas a default rather than being left empty, because an overlay that holds nothing readable (a bare spinner, aclearsheet) still has to say what it is. It names the layer as a route, which is how a screen reader knows the screen changed.modal: falseleaves the page clickable and scrollable while focus is still held inside, which is what aclearoverlay usually wants.- The overlay animates opacity and nothing else. One that scaled or slid would drag whatever is written on it across the screen, and unlike a control this one is usually carrying a sentence. With animations turned off at the OS it appears at once.
- Use a
PlModalinstead when there is a question to answer. An overlay has no title, no description and no actions, so a screen reader has nothing to work with beyondlabel.
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. |
modal={true | 'trap-focus'} | modal: bool | The two values were "does the pointer get through". A boolean says that in Flutter's words. |
children | child | Flutter's name. |
color | — | The only thing it reached was the slots the content read, and content here arrives with its own colours. |
a portal to document.body | an Overlay ancestor | Flutter's portal goes to the nearest Overlay, which WidgetsApp with a navigator and MaterialApp both provide. |
| the scroll lock | — | There is no document to lock. The barrier already takes the pointer, and a scrollable behind it is not reachable. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. |