Modal

A window above the page that holds the focus until it is answered or closed (also called a dialog). Reach for it for a short task that interrupts the page: create an item, edit its settings, read a policy.

import { Modal, ModalHeader, ModalBody, ModalFooter } from "@umriss-ui/core";

It is controlled: you hold open, and onClose reports Escape, the cross and a click on the backdrop. Whether it closes is yours, so a window with unsaved edits can ask first.

The title in ModalHeader names the dialog for a screen reader. The height follows the content; where it does not fit, only ModalBody scrolls.

It is the browser's modal dialog: focus stays inside, the page behind cannot be scrolled, and focus returns to the opener on closing.

Examples

A window that holds focus

You hold open; onClose reports Escape, the cross and a click on the backdrop. The header's title names the dialog.

import { useState } from "react";
import { Button, FormField, Grid, Input, Modal, ModalBody, ModalFooter, ModalHeader, Select } from "@umriss-ui/core";

export default function AWindow() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <Button onClick={() => setOpen(true)}>Open the modal</Button>
      <Modal open={open} onClose={() => setOpen(false)}>
        <ModalHeader title="Create a project" description="The name, client and lead can be changed later." />
        <ModalBody>
          <Grid minItemWidth="200px" gap={4}>
            <FormField label="Project name" required>
              <Input placeholder="e.g. Member portal" />
            </FormField>
            <FormField label="Client">
              <Input placeholder="e.g. Rowan Credit Union" />
            </FormField>
            <FormField label="Lead">
              <Select defaultValue="">
                <option value="" disabled>
                  Choose a person
                </option>
                <option value="maya">Maya Lindgren</option>
                <option value="luis">Luis Moreno</option>
              </Select>
            </FormField>
          </Grid>
        </ModalBody>
        <ModalFooter>
          <Button onClick={() => setOpen(false)}>Cancel</Button>
          <Button variant="primary" onClick={() => setOpen(false)}>
            Create
          </Button>
        </ModalFooter>
      </Modal>
    </>
  );
}

Sizes

size sets the width – sm for a short question, md for a form, lg for a table or long text; the height follows the content.

import { useState } from "react";
import { Button, Modal, ModalBody, ModalFooter, ModalHeader, Stack, Text } from "@umriss-ui/core";
import type { ModalSize } from "@umriss-ui/core";

export default function Sizes() {
  const [size, setSize] = useState<ModalSize | null>(null);

  return (
    <>
      <Stack direction="row" gap={3}>
        {(["sm", "md", "lg"] as const).map((one) => (
          <Button key={one} onClick={() => setSize(one)}>
            Open {one}
          </Button>
        ))}
      </Stack>
      <Modal open={size !== null} onClose={() => setSize(null)} size={size ?? "md"}>
        <ModalHeader title={`A ${size} window`} />
        <ModalBody>
          <Text>Sprint 15 starts on Monday 23 March with the shop's search and filters.</Text>
        </ModalBody>
        <ModalFooter>
          <Button onClick={() => setSize(null)}>Close</Button>
        </ModalFooter>
      </Modal>
    </>
  );
}

Scroll long content

When the content is taller than the window, only ModalBody scrolls; the header and the buttons stay in view.

import { useState } from "react";
import { Button, Modal, ModalBody, ModalFooter, ModalHeader, Stack, Text } from "@umriss-ui/core";

const RULES = [
  "Travel is booked through the travel desk, at least two weeks ahead.",
  "Rail is preferred to air for journeys under four hours.",
  "Hotels are reimbursed up to € 140 a night; more needs the cost-centre owner's approval.",
  "Meals are paid at the daily allowance, not against receipts.",
  "Client entertainment is booked to Sales (CC-1100), whoever pays.",
  "Receipts are uploaded within 30 days, or the claim lapses.",
  "Equipment over € 800 is bought through IT (CC-4300), not claimed.",
  "Training needs the approval of People (CC-4200) before booking.",
  "Mileage is paid at € 0.30 a kilometre for a private car.",
  "Claims in another currency use the rate on the day of payment.",
  "Tips are reimbursed up to 10 % of the bill.",
  "A claim is signed off by the cost-centre owner, then by Finance.",
];

export default function LongContent() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <Button onClick={() => setOpen(true)}>Read the expense policy</Button>
      <Modal open={open} onClose={() => setOpen(false)} size="lg">
        <ModalHeader title="Expense policy" description="Valid from 1 January 2026." />
        <ModalBody>
          <Stack gap={4}>
            {RULES.map((rule, index) => (
              <Stack key={rule} gap={1}>
                <Text weight="semibold">§ {index + 1}</Text>
                <Text tone="secondary">{rule}</Text>
              </Stack>
            ))}
          </Stack>
        </ModalBody>
        <ModalFooter>
          <Button onClick={() => setOpen(false)}>Close</Button>
          <Button variant="primary" onClick={() => setOpen(false)}>
            I have read it
          </Button>
        </ModalFooter>
      </Modal>
    </>
  );
}

Guard unsaved changes

Because onClose only reports the wish, a window with edits can ask first instead of throwing them away.

import { useState } from "react";
import {
  Button,
  ConfirmDialog,
  FormField,
  Modal,
  ModalBody,
  ModalFooter,
  ModalHeader,
  NumberInput,
  Stack,
  Text,
} from "@umriss-ui/core";

export default function UnsavedChanges() {
  const [open, setOpen] = useState(false);
  const [saved, setSaved] = useState(32);
  const [hours, setHours] = useState<number | null>(32);
  const [asking, setAsking] = useState(false);
  const dirty = hours !== saved;

  const close = () => {
    setOpen(false);
    setAsking(false);
    setHours(saved);
  };

  return (
    <Stack gap={3} align="flex-start">
      <Button
        onClick={() => {
          setHours(saved);
          setOpen(true);
        }}
      >
        Edit Maya's capacity
      </Button>
      <Text size="xs" tone="muted">
        Capacity: {saved} h a week
      </Text>
      <Modal open={open} onClose={() => (dirty ? setAsking(true) : close())} size="sm">
        <ModalHeader title="Maya Lindgren" description="Product manager · Web" />
        <ModalBody>
          <FormField label="Hours a week">
            <NumberInput value={hours} onChange={setHours} min={0} max={40} />
          </FormField>
        </ModalBody>
        <ModalFooter>
          <Button onClick={() => (dirty ? setAsking(true) : close())}>Cancel</Button>
          <Button
            variant="primary"
            onClick={() => {
              if (hours !== null) setSaved(hours);
              setOpen(false);
            }}
          >
            Save
          </Button>
        </ModalFooter>
      </Modal>
      <ConfirmDialog
        open={asking}
        onClose={() => setAsking(false)}
        onConfirm={close}
        tone="danger"
        title="Discard the change?"
        description={`The capacity stays at ${saved} h a week.`}
        confirmLabel="Discard"
        cancelLabel="Keep editing"
      />
    </Stack>
  );
}

A window that must be answered

closeOnBackdrop={false} lets a stray click beside the window pass, and hideClose takes the cross off the header: the footer's answers close it, and Escape still does. The same props hold for a Drawer.

import { useState } from "react";
import { Button, Modal, ModalBody, ModalFooter, ModalHeader, Stack, Text } from "@umriss-ui/core";

export default function MustBeAnswered() {
  const [open, setOpen] = useState(false);
  const [answer, setAnswer] = useState("-");

  const answerWith = (text: string) => {
    setAnswer(text);
    setOpen(false);
  };

  return (
    <Stack gap={3} align="flex-start">
      <Button onClick={() => setOpen(true)}>End my on-call week</Button>
      <Text size="xs" tone="muted">
        Answer: {answer}
      </Text>
      <Modal open={open} onClose={() => setOpen(false)} size="sm" closeOnBackdrop={false}>
        <ModalHeader title="Hand over two open incidents?" hideClose />
        <ModalBody>
          <Text size="sm">INC-1048 and INC-1051 are still open. Luis Moreno is on call next.</Text>
        </ModalBody>
        <ModalFooter>
          <Button onClick={() => answerWith("stayed on call")}>Stay on call</Button>
          <Button variant="primary" onClick={() => answerWith("handed over to Luis Moreno")}>
            Hand over to Luis
          </Button>
        </ModalFooter>
      </Modal>
    </Stack>
  );
}

When to use something else

Keyboard

Key Action
Tab / Shift+Tab Moves between the controls inside; focus does not leave the window.
Escape Asks to close: onClose is called, and the window goes when you close it.

API

ModalProps

NameTypeDefaultDescription
openrequiredboolean—Whether the window stands. Controlled: the opening is the caller's decision, and only the caller knows whether something may be open.

Shown in: A window that holds focus, Sizes, Scroll long content and 10 more

sizeModalSize
| "sm"
| "md"
| "lg"
"md"The width of the window. The height comes from the content; only the body scrolls, header and footer stand fast.

Shown in: Sizes, Scroll long content, Guard unsaved changes and 3 more

closeOnBackdropbooleantrueCloses on a click on the backdrop.

Shown in: A window that must be answered

refRef<HTMLDialogElement>—Receives the component's root element. from RefProp

Shown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more

Events

NameTypeDefaultDescription
onCloserequired() => void—Reports every wish to close – Escape, cross, backdrop click.

Shown in: A window that holds focus, Sizes, Scroll long content and 10 more

Also takes every attribute of <dialog> – without onClose.

ModalHeaderProps

NameTypeDefaultDescription
titlerequiredReactNode—The heading of the window. It names it for the screen reader at the same time – a dialog without a name says only "dialog".

Shown in: A window that holds focus, Sizes, Scroll long content and 10 more

descriptionReactNode—One sentence below the heading: what is to be decided here.

Shown in: A window that holds focus, Scroll long content, Guard unsaved changes and 8 more

hideClosebooleanfalseHides the close button.

Shown in: A window that must be answered

refRef<HTMLDivElement>—Receives the component's root element. from RefProp

Shown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more

Also takes every attribute of <div> – without title.

Types on this page

ModalSize

The width of a Modal.

type ModalSize = "sm" | "md" | "lg";

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index