A frosted dialog with a focus trap, scroll lock, inert page, scroll-aware edges and a bottom sheet on phones.
Modal portals a dialog over a deep scrim: focus moves in (to initialFocus, the first field, or the first button), Tab is trapped, Esc and a scrim click close it unless dismissible is false or busy is set, the page behind goes inert, and focus returns to whatever opened it.
Everything in the header sits on one left edge: the tone's icon tile and the close button on one row, then the title and description full width beneath; nothing is stacked over the title. tone tints the header wash and the tile (danger, success, info); progress draws a segmented step rule for multi-step flows.
The body scrolls on its own. Its scroll edges are the portable pattern from components/ui/Scroll: a hairline and soft shade under the header once content has scrolled (data-scrolled) and over the footer while there is more below (data-more), written straight to the DOM by useScrollEdges with a passive listener, plus thin tinted scrollbars and contained overscroll. Under 640px every size becomes a bottom sheet (or the full screen with mobile="fullscreen").
Widths are tokens: --modal-width-sm 480px, -md 640px, -lg 880px. meta puts a quiet line under the title (a reference, "Step 1 of 2"); nothing is stacked over the title. inline renders the open panel in place, with no portal, scrim, focus trap or scroll lock, for docs and design reviews.
Installation
npx shadcn@latest add KezLahd/rime/modalAlso installs: base, button, icon, icon-button, scroll-area.
Usage
import { Button, Modal } from "@/components/ui";
<Modal
open={open}
onClose={() => setOpen(false)}
title="Contact support"
description="Ask a question about invoice INV-1042."
footer={
<>
<Button variant="secondary" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={send}>Send question</Button>
</>
}
>
…form fields…
</Modal>Examples
Open
Contact support
Ask a question about this invoice. We reply within a day.
Form in a modal
Long content: scroll edges
Terms with a scroll-gated accept
ConfirmModal, danger, two steps
API
Generated from the component's TypeScript types and JSDoc (scripts/gen-ui-registry.mjs), so it always matches the source.
Modal
| Prop | Type | Default | Description |
|---|---|---|---|
| openRequired | boolean | ||
| onCloseRequired | () => void | Called on Esc, scrim click and the close button, unless dismissible is false. | |
| titleRequired | ReactNode | ||
| meta | ReactNode | A quiet line under the title: a reference, "Step 1 of 2". Never stacked over the title. | |
| description | ReactNode | ||
| children | ReactNode | ||
| inline | boolean | Render the open panel in place, in normal flow: no portal, no scrim, no inert page, no scroll lock, no focus trap and no exit animation. For docs, thumbnails and design reviews, where many can render at once. | |
| footer | ReactNode | Buttons, right-aligned. Primary last, and one gradient primary at most. | |
| footerStart | ReactNode | Left side of the footer: a line of secondary text, a link, a Back button. | |
| size | "sm" | "md" | "lg" | "md" | sm 480, md 640, lg 880 (--modal-width-*). Under 640px every size becomes a bottom sheet. |
| mobile | "sheet" | "fullscreen" | "sheet" | Under 640px: a bottom sheet (default) or the full screen, for long forms. |
| tone | ModalTone | "default" | Tints the header wash and the icon tile. danger, success and info bring their own icon; pass icon={false} to drop it. |
| icon | ReactNode | ||
| progress | { step: number; of: number } | A multi-step flow: draws the segmented step rule under the heading. | |
| onScrollEnd | () => void | Called when the body has been scrolled to its end (and at once if it does not overflow). Gate a terms "Accept" on it: see the scroll-gated accept example. | |
| dismissible | boolean | true | false while a request is in flight: Esc, scrim and close are ignored. |
| busy | boolean | false | A request is in flight: sets aria-busy and locks dismissal. |
| initialFocus | RefObject<HTMLElement | null> | Focused on open. Defaults to the first field or button in the body. | |
| closeLabel | string | "Close" | |
| className | string |
ConfirmModal
| Prop | Type | Default | Description |
|---|---|---|---|
| openRequired | boolean | ||
| onCloseRequired | () => void | ||
| titleRequired | ReactNode | ||
| meta | string | A quiet line under the title; two-step flows prefix it with "Step 1 of 2". | |
| description | ReactNode | ||
| inline | boolean | Render the open dialog in place (see Modal's inline). | |
| children | ReactNode | Step one: the details or form (e.g. withdrawal reason). | |
| review | ReactNode | Step two: what will happen, stated plainly. When given, the first button reads proceedLabel and leads here; the destructive button is only on this step. Omit for a single-step confirm. | |
| reviewTitle | ReactNode | ||
| reviewDescription | ReactNode | Replaces description on step two. Omitted: step two has none. | |
| proceedLabel | string | "Continue" | |
| confirmLabelRequired | string | ||
| cancelLabel | string | "Cancel" | |
| tone | ModalTone | "default" | |
| icon | ReactNode | Overrides the tone's icon tile; false drops it. | |
| onProceed | () => boolean | Return false to stay on step one (failed validation). | |
| onConfirmRequired | () => void | ||
| busy | boolean | The confirm request is in flight. Locks the dialog. | |
| busyLabel | string | ||
| size | ModalProps["size"] | "md" | |
| mobile | ModalProps["mobile"] |
Theme tokens
Per-component tokens are undeclared by default, so the default shown is the fallback the CSS uses. Set one at :root (or in theme.css) to restyle every instance. Live values are read from this page, so they follow the Rime Studio.
| Token | Default | Live value |
|---|---|---|
| --modal-inline-width | 520px | … |
| --modal-inline-width-lg | 640px | … |
| --modal-pad-x | calc(24px * var(--density)) | … |
| --modal-radius | var(--r-xl) | … |
| --modal-radius-lg | var(--r-2xl) | … |
| --modal-width-lg | 880px | … |
| --modal-width-md | 640px | … |
| --modal-width-sm | 480px | … |
System tokens it reads
| Token | Default | Live value |
|---|---|---|
| --brand-gradient | tokens.css | … |
| --danger-gradient | tokens.css | … |
| --danger-ink | tokens.css | … |
| --density | tokens.css | … |
| --dur-slow | tokens.css | … |
| --ease | tokens.css | … |
| --ease-out | tokens.css | … |
| --focus-color | tokens.css | … |
| --font-display | tokens.css | … |
| --info-ink | tokens.css | … |
| --ink-body | tokens.css | … |
| --ink-brand | tokens.css | … |
| --ink-heading | tokens.css | … |
| --ink-muted | tokens.css | … |
| --ink-secondary | tokens.css | … |
| --modal-blur | tokens.css | … |
| --modal-scrim | tokens.css | … |
| --modal-scrim-blur | tokens.css | … |
| --modal-shadow | tokens.css | … |
| --modal-surface | tokens.css | … |
| --modal-wash-opacity | tokens.css | … |
| --r-2xl | tokens.css | … |
| --r-full | tokens.css | … |
| --r-md | tokens.css | … |
| --r-xl | tokens.css | … |
| --rgb-brand | tokens.css | … |
| --rgb-brand-deep | tokens.css | … |
| --rgb-brand-soft | tokens.css | … |
| --rgb-danger | tokens.css | … |
| --scroll-edge-bottom | tokens.css | … |
| --scroll-edge-left | tokens.css | … |
| --scroll-edge-right | tokens.css | … |
| --scroll-edge-top | tokens.css | … |
| --scrollbar-thumb | tokens.css | … |
| --scrollbar-track | tokens.css | … |
| --scrollbar-width | tokens.css | … |
| --shadow-specular | tokens.css | … |
| --success-gradient | tokens.css | … |
| --success-ink | tokens.css | … |
| --success-solid | tokens.css | … |
| --text-body | tokens.css | … |
| --text-caption | tokens.css | … |
| --text-small | tokens.css | … |
| --tracking-display | tokens.css | … |
| --z-modal | tokens.css | … |
Accessibility
- role="dialog" ("alertdialog" for tone="danger") with aria-modal, aria-labelledby on the title and aria-describedby on the description.
- Focus trap with Tab and Shift+Tab, Esc to close, focus returned to the opener; the background is inert, except live regions (toasts) so failures are still announced.
- busy locks dismissal and sets aria-busy while a request is in flight.
- Reduced motion: everything fades in place; nothing travels or scales.
Do and don't
Do
- Keep one primary action, last in the footer.
- Use ConfirmModal for any irreversible action; TypeToConfirmModal when the blast radius is large.
- Use footerStart for a line of context or a Back button.
Don't
- Don't stack modals unless the second is a confirm of the first.
- Don't put a coloured side bar or a filled header on a modal; tone is a soft wash only.
- Don't open a modal on page load.
For AI agents
This page as markdown, rendered from the same registry entry. Point an agent at it (or add .md to this page's URL), or at /llms.txt for the index.
Open modal.mdRelated: button, scroll-area, type-to-confirm