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.
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>
</>
);
}
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>
</>
);
}
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>
</>
);
}
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>
);
}
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>
);
}
| 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. |
ModalProps| Name | Type | Default | Description |
|---|---|---|---|
openrequired | boolean | — | 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 |
size | ModalSize| "sm" | "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 |
closeOnBackdrop | boolean | true | Closes on a click on the backdrop. Shown in: A window that must be answered |
ref | Ref<HTMLDialogElement> | — | Receives the component's root element. from RefPropShown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more |
| Name | Type | Default | Description |
|---|---|---|---|
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| Name | Type | Default | Description |
|---|---|---|---|
titlerequired | 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". Shown in: A window that holds focus, Sizes, Scroll long content and 10 more |
description | ReactNode | — | 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 |
hideClose | boolean | false | Hides the close button. Shown in: A window that must be answered |
ref | Ref<HTMLDivElement> | — | Receives the component's root element. from RefPropShown 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.
ModalSizeThe width of a Modal.
type ModalSize = "sm" | "md" | "lg";What umriss deliberately does not build, and why: ADR-0032.