Dialog
A window over the page that keeps focus until it closes: a header, a body that scrolls, a footer.
Dates in UTC
The playground reads the repositories' update dates in UTC, and the Next.js check looks from both sides of the server's time zone.
Targets main
- Tab stays inside. Escape, the cross or a click on the backdrop closes it, and focus goes back to the button.
- Open Target branch and scroll the list: it moves, the page behind does not. Escape closes the list first, then the dialog.
- In the changelog the body scrolls; the header and the footer stay put.
Field marks
- Import
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogBody, DialogFooter } from "cennge-ui";- Size
- 1.7 KB gzip for this import, its dependencies aside
- Pattern
- WAI-ARIA modal dialog:
role="dialog", named by its title - Keyboard
- Tab Shift Tab Esc
- State
- Controlled with
isOpenandonOpenChange, or uncontrolled - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Nesting
- Lists and menus opened inside scroll; Escape closes the topmost first
- Focus return
- To the trigger, or to what had focus when code opened it
- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: dialog-scroll, overlays
- Source
- src/components/dialog
When to use#
Use a Dialog when a task needs the reader's whole attention before the page goes on: a form to fill in, a setting to change, a text to read. While it is open the rest of the page takes no clicks, does not scroll and is out of reach of Tab and of screen readers.
Similar#
- AlertDialog asks for an answer: a click outside does not close it, there is no close button, and focus starts on Cancel.
- Drawer is the same dialog anchored to an edge of the screen, with the same focus trap, Escape and scroll lock.
- Popover sits next to its button and leaves the page usable.
- Command is a dialog of its own for searching actions and pages.
A form in a dialog#
Wrap the header, body and footer in a <form> and Enter in the field submits it. With className="contents" the form draws no box of its own, so the parts keep the dialog's layout. DialogClose makes any button close the dialog. The content unmounts when the dialog closes, so a field with defaultValue starts again from the saved name, and Cancel throws away what was typed.
import { useState, type FormEvent } from "react";
import {
Button,
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
toast,
} from "cennge-ui";
export default function RenameRepository() {
const [isOpen, setOpen] = useState(false);
const [name, setName] = useState("payload-cache");
function rename(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const next = String(new FormData(event.currentTarget).get("name")).trim();
if (!next) return;
setName(next);
setOpen(false);
toast.success("Repository renamed", { description: `Cennge/${next}` });
}
return (
<Dialog isOpen={isOpen} onOpenChange={setOpen}>
<DialogTrigger>
<Button variant="flat">Rename</Button>
</DialogTrigger>
<DialogContent size="sm">
{/* "contents" keeps header, body and footer in the dialog's own layout. */}
<form onSubmit={rename} className="contents">
<DialogHeader>
<DialogTitle>Rename repository</DialogTitle>
<DialogDescription>Links to the old name keep working.</DialogDescription>
</DialogHeader>
<DialogBody>
<Input label="Name" name="name" defaultValue={name} isRequired />
</DialogBody>
<DialogFooter>
<DialogClose>
<Button variant="flat">Cancel</Button>
</DialogClose>
<Button type="submit" color="primary">
Rename
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}Long content#
The dialog is never taller than the screen minus 2rem. Past that, DialogBody scrolls and the header and footer stay where they are. When nothing in the body takes focus, give it tabIndex={0}, so the arrow keys and Page Down can scroll it too. size sets the width: sm 24rem, md 28rem, lg 32rem, xl 42rem.
import {
Button,
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "cennge-ui";
const releases = [
{ version: "0.3.1", date: "1 November 2026", changes: ["The README's map examples are set in New York."] },
{
version: "0.3.0",
date: "31 October 2026",
changes: [
"Charts, in a new entry cennge-ui/charts, drawn in SVG with no chart library underneath: BarChart, LineChart, AreaChart, DonutChart and Sparkline.",
"Series take an eight-colour palette checked for colour blindness in both themes, whose first colour is the accent.",
"Theme switching without a flash and without a hydration error: ThemeScript, useTheme and ThemeSwitch.",
"Button shows the pointer cursor, and a loading one the wait cursor, without hover or press effects while it loads.",
"NavigationMenu triggers and Carousel dots show the pointer cursor.",
"A popup in a portal opened from a Dialog, Drawer, AlertDialog or CommandDialog scrolls again, also when it is another library's.",
"Kbd draws its key symbols at the size of the key cap, the same in every font.",
"A Select inside an open Dialog, Popover or Accordion item no longer shows its chevron turned up while its list is closed.",
],
},
{
version: "0.2.0",
date: "21 October 2026",
changes: [
"New entry cennge-ui/map, on MapLibre GL, fetched only when a map mounts.",
"Map, with a base map in the theme's colours, light and dark with the page.",
"MapMarker and MapPopup: accessible buttons and dialogs in the library's style.",
"MapControls: zoom, north, the user's location, full screen.",
"MapRoute and MapArc: lines and curves in a token's colour, under the place names.",
"MapGeoJSON and MapLegend: areas, lines and points, or a choropleth along a colour's scale.",
"MapClusterLayer: thousands of points, grouped; groups and points are buttons.",
],
},
{
version: "0.1.1",
date: "20 October 2026",
changes: ["minValue and maxValue of Calendar, RangeCalendar, DatePicker and DateRangePicker accept null, meaning no limit."],
},
{
version: "0.1.0",
date: "19 October 2026",
changes: ["First release: React components on Radix and Tailwind CSS v4, dark-first with a swappable accent."],
},
];
export default function ChangelogDialog() {
return (
<Dialog>
<DialogTrigger>
<Button variant="flat">Changelog</Button>
</DialogTrigger>
<DialogContent size="lg">
<DialogHeader>
<DialogTitle>Changelog</DialogTitle>
<DialogDescription>Every release of cennge-ui, newest first.</DialogDescription>
</DialogHeader>
{/* Nothing inside takes focus: tabIndex lets the keyboard scroll the body. */}
<DialogBody
tabIndex={0}
className="flex flex-col gap-5 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring"
>
{releases.map((release) => (
<section key={release.version}>
<h3 className="font-semibold">
{release.version} <span className="font-normal text-muted">· {release.date}</span>
</h3>
<ul className="mt-1.5 flex list-disc flex-col gap-1.5 pl-5">
{release.changes.map((change) => (
<li key={change}>{change}</li>
))}
</ul>
</section>
))}
</DialogBody>
<DialogFooter>
<DialogClose>
<Button variant="flat">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}Opened from a menu#
A dialog does not need a DialogTrigger: control it with isOpen and open it from a menu item, a shortcut or anything else. When it closes, focus goes back to the element that had it when the dialog opened, if that element is still on the page; Radix alone returns focus only to a DialogTrigger. A menu item is not: it leaves with its menu, so here onCloseAutoFocus sends focus to the menu's button.
import { useRef, useState } from "react";
import {
Button,
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
Textarea,
toast,
} from "cennge-ui";
function MoreIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.75" />
<circle cx="12" cy="12" r="1.75" />
<circle cx="19" cy="12" r="1.75" />
</svg>
);
}
export default function RepositoryMenu() {
const [isEditOpen, setEditOpen] = useState(false);
const menuButton = useRef<HTMLButtonElement>(null);
return (
<div className="flex w-full max-w-sm items-center justify-between gap-3 rounded-large bg-surface-3 py-2 pr-2 pl-4">
<span className="font-medium">ai-niche-scout</span>
<Dropdown>
<DropdownTrigger ref={menuButton}>
<Button isIconOnly variant="light" aria-label="ai-niche-scout actions">
<MoreIcon />
</Button>
</DropdownTrigger>
<DropdownContent align="end">
<DropdownItem onAction={() => toast("Starred ai-niche-scout")}>Star</DropdownItem>
<DropdownItem onAction={() => setEditOpen(true)}>Edit description…</DropdownItem>
</DropdownContent>
</Dropdown>
<Dialog isOpen={isEditOpen} onOpenChange={setEditOpen}>
<DialogContent
size="sm"
// The menu item that opened the dialog is gone by now: go back to the menu's button.
onCloseAutoFocus={(event) => {
event.preventDefault();
menuButton.current?.focus();
}}
>
<DialogHeader>
<DialogTitle>Edit description</DialogTitle>
<DialogDescription>Shown under the name of ai-niche-scout.</DialogDescription>
</DialogHeader>
<DialogBody>
<Textarea
label="Description"
minRows={2}
defaultValue="Validate an AI startup idea: competitors and 54 AI niches"
/>
</DialogBody>
<DialogFooter>
<DialogClose>
<Button variant="flat">Cancel</Button>
</DialogClose>
<DialogClose>
<Button color="primary" onClick={() => toast.success("Description saved")}>
Save
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}Lists and menus inside#
Put a Combobox, a Select, a menu or a Popover in a dialog as you would on the page. Their lists open in portals at the end of <body>, outside the dialog's box, and still scroll; Escape closes the list before the dialog.
import { useState } from "react";
import {
Button,
Combobox,
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Select,
SelectItem,
toast,
} from "cennge-ui";
const branches = [
"main",
"feat/charts",
"feat/sparkline",
"feat/theme-switch",
"feat/map",
"feat/map-clusters",
"fix/dialog-backdrop",
"fix/dialog-popup-scroll",
"fix/select-chevron",
"fix/button-cursor",
"fix/kbd-symbols",
"fix/carousel-controls",
"test/next-time-zones",
"ci/firefox-xvfb",
].map((branch) => ({ value: branch, label: branch }));
const runners = ["ubuntu-24.04", "windows-2025", "macos-15"];
export default function RunWorkflow() {
const [branch, setBranch] = useState<string | null>("main");
const [runner, setRunner] = useState<string | null>("ubuntu-24.04");
return (
<Dialog>
<DialogTrigger>
<Button color="primary">Run workflow</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Run workflow</DialogTitle>
<DialogDescription>ci.yml in Cennge/cennge-ui</DialogDescription>
</DialogHeader>
<DialogBody className="flex flex-col gap-4">
<Combobox label="Branch" items={branches} value={branch} onValueChange={setBranch} />
<Select label="Runner" value={runner} onValueChange={setRunner}>
{runners.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</Select>
</DialogBody>
<DialogFooter>
<DialogClose>
<Button variant="flat">Cancel</Button>
</DialogClose>
<DialogClose>
<Button
color="primary"
isDisabled={!branch || !runner}
onClick={() => toast.success("Run queued", { description: `${branch} on ${runner}` })}
>
Run
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}Radix keeps the page behind a modal dialog still by cancelling every scroll outside the backdrop's React tree. When the dialog is drawn beside its backdrop, as Radix draws it, the lists it opens fall outside that tree and cannot scroll (Radix #1159); Radix Dialog 1.3 spares only Radix's own layers. cennge-ui puts the dialog inside its backdrop, so anything opened from it is in the tree too, another library's popup included. The end-to-end tests check it in Chromium, Firefox and WebKit, on desktop and on phone profiles:
- A Combobox, a Select, a Popover and another library's popup, each opened from a Dialog, and a Combobox in a Drawer and in an AlertDialog: the list scrolls under the mouse wheel, and three more notches at its end leave the page where it was.
- On the phone profiles, where Playwright cannot swipe, the test sends the touch events of a swipe over the list (WebKit gets a wheel event): the dialog lets them through while the list can scroll and stops them at its end.
- A menu in a Dialog: the first Escape closes the menu and puts focus back on its button, the second closes the dialog.
- Scrolling or swiping over the backdrop does not move the page, and a tap on it closes the dialog.
- With a Popover's list open in a Dialog, axe finds no WCAG 2.2 AA violations in the dark or the light theme. The tests skip one rule while a modal is open, aria-hidden-focus: the page behind is hidden with
aria-hiddenand out of reach of focus, and axe still counts its buttons.
A popup of another library, in a portal of its own, needs pointer-events: auto: a modal dialog turns pointer events off for the rest of the page.
Accessibility#
- The dialog has
role="dialog", its name fromDialogTitleand its description fromDialogDescription. A design without a visible title still needs one: give itclassName="sr-only". - While it is open, Tab and Shift+Tab cycle inside it, and the page behind is hidden from screen readers, does not scroll and takes no clicks. All of that ends when the dialog closes. With
isModal={false}onDialogthere is no backdrop, and the page behind stays visible to screen readers, scrolls and takes clicks. - Focus starts on the first focusable element inside. On close it returns to the trigger, or, for a dialog opened from code, to the element that had it, if that element is still on the page. The end-to-end tests check both, the second with a button that opens the dialog through
isOpen. - The cross in the corner is a button named "Close". Hide it with
hideCloseButtononly when the footer has a way out; Escape and the backdrop still close the dialog unlessisKeyboardDismissDisabledorisDismissable={false}say otherwise.
Styling#
classNames reaches three parts: overlay, the layer over the whole screen; content, the dialog; closeButton. The dimmed backdrop is the overlay's ::before, so a backdrop of your own takes the before: variant, as in classNames={{ overlay: "before:bg-black/70" }}. className goes to the content, and radius rounds it.
Props#
Dialog#
DialogContent#
Also takes every attribute of <div>.