Overlay
A sheet over the whole page that stops it being used. The scrim on its own, with whatever you put on top of it.
import { Overlay, ProgressCircular } from 'neba';
<Overlay open={saving} tone="blur" label="Saving">
<ProgressCircular size="lg" />
</Overlay>;Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | The overlay is shown. Use with onOpenChange for a controlled overlay |
| defaultOpen | boolean | false | Whether the overlay starts shown |
| onOpenChange | (open: boolean) => void | — | Called when the overlay opens or closes |
| tone | 'scrim' | 'blur' | 'solid' | 'clear' | 'scrim' | How much of the page is taken away. Four steps on one axis, tuned with the blur radius as much as with the alpha |
| dismissible | boolean | false | Whether a click or Escape closes it. Off, the other way round from Dialog: an overlay is not asking anything, and a save dismissed by a stray click is a save the user will believe finished |
| 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. It has a default rather than being optional: an overlay that holds nothing readable still has to say what it is |
| children | ReactNode | — | What sits on top of the scrim — a spinner, a line of text, a small card |
Overlay or Dialog?
The difference is what is not here. An Overlay has no surface, no border, no title and no actions — a Dialog is a sheet that asks a question, and an Overlay is the plane that sheet would have floated above.
Reach for an Overlay when the page has to stop and there is nothing to answer: something is saving, something is loading, something is being replaced. Reach for a Dialog the moment there is a decision to make.
It is not dismissible by default
This is the one prop worth reading twice, and it is the other way round from Dialog.
A dialog asks a question and Escape is the universal no. An overlay is not asking anything — it is saying wait — and a save that can be dismissed by a stray click is a save the user will believe finished. So dismissible starts off, and Escape and a click on the scrim are both refused until you turn it on.
Turn it on for the overlay whose whole job is to catch a click outside something.
import { useState } from 'react';
import { Button, Card, Overlay } from 'neba';
export default function OverlayDismissible() {
const [held, setHeld] = useState(false);
const [loose, setLoose] = useState(false);
return (
<>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setHeld(true)}>
Cannot be dismissed
</Button>
<Button variant="outline" onClick={() => setLoose(true)}>
Click anywhere to close
</Button>
</div>
<Overlay open={held} label="Working">
<Card
title="No way past this"
subtitle="Escape and a click outside are both refused."
footer={<Button onClick={() => setHeld(false)}>Let me out</Button>}
/>
</Overlay>
<Overlay
open={loose}
onOpenChange={setLoose}
dismissible
tone="scrim"
label="Dismissible overlay"
>
<p className="m-0 text-(--neba-fg)">Click the scrim, or press Escape.</p>
</Overlay>
</>
);
}Examples
Tones
Four steps on one axis: how legible is what is behind.
Each is 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.
| Tone | What the page behind does |
|---|---|
scrim | Stays readable. It has only stopped being reachable. This is Dialog's own backdrop, so the two never show a seam. |
blur | Present as shape and colour, gone as words. For "this is being replaced". |
solid | Gone. The page surface, opaque. |
clear | Nothing is drawn. Still blocks the pointer — an invisible sheet that catches a click. |
import { useState } from 'react';
import { Button, Overlay, Typography } from 'neba';
import type { OverlayTone } from 'neba';
const TONES: OverlayTone[] = ['scrim', 'blur', 'solid', 'clear'];
export default function OverlayTones() {
const [tone, setTone] = useState<OverlayTone | null>(null);
return (
<>
<div className="flex flex-wrap gap-2">
{TONES.map((name) => (
<Button key={name} variant="outline" onClick={() => setTone(name)}>
{name}
</Button>
))}
</div>
<Overlay
open={tone !== null}
onOpenChange={(next) => !next && setTone(null)}
tone={tone ?? 'scrim'}
dismissible
label={`${tone} overlay`}
>
<div className="flex flex-col items-center gap-3">
<Typography level="h4">tone=“{tone}”</Typography>
<Button onClick={() => setTone(null)}>Close</Button>
</div>
</Overlay>
</>
);
}Accessibility
- Base UI owns 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.
- The overlay is a
dialog, andlabelis its accessible name. It has a default rather than being optional: an overlay that holds nothing readable — a bare spinner, aclearsheet — still has to say what it is. modal="trap-focus"leaves the page scrollable and clickable while still holding the focus inside, which is usually what aclearoverlay wants.- The fade is opacity only. An overlay that scales or slides drags whatever is written on it across the screen, which is the one thing the design language is against.