Skip to content
cennge-ui
GitHubv0.3.1, changelog

Dialog

A window over the page that keeps focus until it closes: a header, a body that scrolls, a footer.

cennge-ui 0.3.2Draft

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

  1. Tab stays inside. Escape, the cross or a click on the backdrop closes it, and focus goes back to the button.
  2. Open Target branch and scroll the list: it moves, the page behind does not. Escape closes the list first, then the dialog.
  3. In the changelog the body scrolls; the header and the footer stay put.
Live: the component itself, not a picture of it.

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 isOpen and onOpenChange, 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

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.

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

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

ai-niche-scout
from-a-menu.tsx
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.

run-workflow.tsx
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-hidden and 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 from DialogTitle and its description from DialogDescription. A design without a visible title still needs one: give it className="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} on Dialog there 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 hideCloseButton only when the footer has a way out; Escape and the backdrop still close the dialog unless isKeyboardDismissDisabled or isDismissable={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#

PropTypeDefault
isOpen
boolean
–
defaultOpen
boolean
–
onOpenChange
(isOpen: boolean) => void
–
isModal
booleanBlocks interaction outside while open.
true
children
React.ReactNode
–

DialogContent#

PropTypeDefault
size
"sm""md""lg""xl"
"md"
radius
"none""sm""md""lg""full"
"lg"
hideCloseButton
boolean
false
isDismissable
booleanClose when clicking the backdrop.
true
isKeyboardDismissDisabled
booleanKeep the dialog open on Escape.
false
classNames
Partial<Record<DialogSlots, string>>
–
container
HTMLElement | nullMount the portal elsewhere (default: document.body).
–
onOpenAutoFocus
(event: Event) => voidRuns as the dialog opens and moves focus inside; event.preventDefault() to focus something else.
–
onCloseAutoFocus
(event: Event) => voidRuns as the dialog closes and returns focus; event.preventDefault() to send focus elsewhere.
–

Also takes every attribute of <div>.