# Modal

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/modal/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

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.

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

Demo page: https://romanhaendler.github.io/umriss-ui/core/modal/

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.

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

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

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

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

```tsx
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

- A detail beside the list the user came from → Drawer
- One yes-or-no before something that cannot be undone → ConfirmDialog
- A small panel that should not block the page → Popover

## 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`

| Prop | Type | Default | Description |
|---|---|---|---|
| `open` *required* | `boolean` | — | Whether the window stands. Controlled: the opening is the caller's decision, and only the caller knows whether something may be open.<br>Shown in: [A window that holds focus](https://romanhaendler.github.io/umriss-ui/core/modal/#a-window), [Sizes](https://romanhaendler.github.io/umriss-ui/core/modal/#sizes), [Scroll long content](https://romanhaendler.github.io/umriss-ui/core/modal/#long-content) and 10 more |
| `size` | [`ModalSize`](https://romanhaendler.github.io/umriss-ui/core/modal/#type-ModalSize)<br>`"sm" \| "md" \| "lg"` | `"md"` | The width of the window. The height comes from the content; only the body scrolls, header and footer stand fast.<br>Shown in: [Sizes](https://romanhaendler.github.io/umriss-ui/core/modal/#sizes), [Scroll long content](https://romanhaendler.github.io/umriss-ui/core/modal/#long-content), [Guard unsaved changes](https://romanhaendler.github.io/umriss-ui/core/modal/#unsaved-changes) and 3 more |
| `closeOnBackdrop` | `boolean` | `true` | Closes on a click on the backdrop.<br>Shown in: [A window that must be answered](https://romanhaendler.github.io/umriss-ui/core/modal/#must-be-answered) |
| `ref` | `Ref<HTMLDialogElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onClose` *required* | `() => void` | — | Reports every wish to close – Escape, cross, backdrop click.<br>Shown in: [A window that holds focus](https://romanhaendler.github.io/umriss-ui/core/modal/#a-window), [Sizes](https://romanhaendler.github.io/umriss-ui/core/modal/#sizes), [Scroll long content](https://romanhaendler.github.io/umriss-ui/core/modal/#long-content) and 10 more |

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

### `ModalHeaderProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `title` *required* | `ReactNode` | — | The heading of the window. It names it for the screen reader at the same time – a dialog without a name says only "dialog".<br>Shown in: [A window that holds focus](https://romanhaendler.github.io/umriss-ui/core/modal/#a-window), [Sizes](https://romanhaendler.github.io/umriss-ui/core/modal/#sizes), [Scroll long content](https://romanhaendler.github.io/umriss-ui/core/modal/#long-content) and 10 more |
| `description` | `ReactNode` | — | One sentence below the heading: what is to be decided here.<br>Shown in: [A window that holds focus](https://romanhaendler.github.io/umriss-ui/core/modal/#a-window), [Scroll long content](https://romanhaendler.github.io/umriss-ui/core/modal/#long-content), [Guard unsaved changes](https://romanhaendler.github.io/umriss-ui/core/modal/#unsaved-changes) and 8 more |
| `hideClose` | `boolean` | `false` | Hides the close button.<br>Shown in: [A window that must be answered](https://romanhaendler.github.io/umriss-ui/core/modal/#must-be-answered) |
| `ref` | `Ref<HTMLDivElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

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

### Types on this page

#### `ModalSize`

The width of a `Modal`.

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

## Known limits

- No non-modal window and no dragging or resizing: a panel that leaves the page usable is layout – a [Splitter](https://romanhaendler.github.io/umriss-ui/core/splitter/).

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
