Modal
A focus-trapped dialog for critical actions, with scroll lock and full ARIA wiring.
"use client";
import { Modal, Button, Text } from "astralis-ui";
export function ModalDemo() {
return (
<Modal>
<Modal.Trigger>
<Button>Delete project</Button>
</Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Delete this project?</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Modal.Description>
This permanently removes the project and all of its data.
</Modal.Description>
<Text size="sm" color="muted">
Type the project name to confirm — just kidding, this is a demo.
</Text>
</Modal.Body>
<Modal.Footer>
<Modal.Close>
<Button variant="text" colorScheme="gray">Cancel</Button>
</Modal.Close>
<Modal.Close>
<Button colorScheme="red">Delete</Button>
</Modal.Close>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}Import#
import { Modal } from "astralis-ui";
// Modal.Trigger, .Content, .Header, .Body, .Footer,
// .Title, .Description, .Close, .CloseButtonAnatomy#
Trigger and Close wrap your own elements — usually Buttons — so the
modal composes with anything focusable:
<Modal>
<Modal.Trigger><Button>Open</Button></Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Title</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>…</Modal.Body>
<Modal.Footer>
<Modal.Close><Button variant="text">Cancel</Button></Modal.Close>
</Modal.Footer>
</Modal.Content>
</Modal>Sizes#
"use client";
import { Modal, Button, HStack } from "astralis-ui";
const sizes = ["sm", "md", "lg", "xl", "full"] as const;
export function ModalSizes() {
return (
<HStack gap="3" wrap="wrap" justifyContent="center">
{sizes.map((size) => (
<Modal key={size} size={size}>
<Modal.Trigger>
<Button variant="outline" colorScheme="gray" size="sm">
{size}
</Button>
</Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Size {size}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Modal.Description>
The panel width tracks the size prop; full covers the viewport.
</Modal.Description>
</Modal.Body>
</Modal.Content>
</Modal>
))}
</HStack>
);
}Controlled#
Own the state to keep the modal open during async work — pair with
closeOnOverlayClick={false} so a stray click can't discard a pending save.
"use client";
import { useState } from "react";
import { Modal, Button } from "astralis-ui";
export function ModalControlled() {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const save = () => {
setSaving(true);
setTimeout(() => {
setSaving(false);
setOpen(false); // close only after the work finishes
}, 1200);
};
return (
<Modal open={open} onOpenChange={setOpen} closeOnOverlayClick={false}>
<Modal.Trigger>
<Button variant="subtle">Edit profile</Button>
</Modal.Trigger>
<Modal.Content>
<Modal.Header>
<Modal.Title>Edit profile</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Modal.Description>
Controlled mode: the modal stays open until saving completes, and
overlay clicks are disabled.
</Modal.Description>
</Modal.Body>
<Modal.Footer>
<Modal.Close>
<Button variant="text" colorScheme="gray">Cancel</Button>
</Modal.Close>
<Button onClick={save} loading={saving} loadingText="Saving…">
Save changes
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}Props#
| Prop | Type | Default | Description |
|---|---|---|---|
open · defaultOpen · onOpenChange | boolean · boolean · (open: boolean) => void | — · false · — | Controlled / initial state, and the change callback. |
size | "sm" | "md" | "lg" | "xl" | "full" | "md" | Panel width; full covers the viewport. |
centered | boolean | true | Vertically center the panel. |
closeOnOverlayClick | boolean | true | Dismiss when the backdrop is clicked. |
closeOnEsc | boolean | true | Dismiss on Escape. |
Parts#
| Prop | Type | Default | Description |
|---|---|---|---|
Modal.Trigger / Modal.Close | children: ReactElement | — | Wrap a single element (e.g. a Button) to open / close the modal. |
Modal.Content | children · className | — | The portalled, focus-trapped dialog panel. |
Modal.Header / Body / Footer | children · className | — | Layout regions; Body scrolls when content overflows. |
Modal.Title / Modal.Description | children · className | — | Wired to aria-labelledby / aria-describedby automatically. |
Modal.CloseButton | className | — | The corner ✕ button, placed inside the Header. |
Keyboard#
| Key | Action |
|---|---|
Esc | Close (opt out with closeOnEsc={false}) |
Tab / Shift+Tab | Cycle focus inside the dialog — it can't escape |
On open, focus moves to the first focusable element inside; on close it returns to the trigger. With stacked overlays, Escape closes only the topmost layer at a time.
Accessibility#
- Renders
role="dialog"witharia-modal, portalled to the body; focus is trapped inside while open and page scroll is locked. Modal.TitleandModal.Descriptionwirearia-labelledby/aria-describedbyautomatically.- Escape and backdrop click dismiss by default; both are opt-out props.