Modal
Dialog overlay that temporarily blocks page interaction to present urgent content or complex sub-flows.
Modal
The Modal component creates a focused overlay container displayed above the main application interface. By dimming and blurring the background, it establishes an exclusive spatial threshold—demanding the user’s attention for critical decisions, forms, or detailed sub-flows before returning to the underlying page.
In Hashti’s spatial vocabulary, a Modal functions much like entering an interior pavilion (kushk): outside distractions fade into a softened backdrop, while internal content is rendered with utmost clarity and geometric balance.
Import
import { Modal, Dialog, DialogTrigger, Button, Heading } from "@hashti/react";
Examples
Standard Modal with DialogTrigger
Compose <DialogTrigger> with a trigger <Button>, a <Modal>, and a nested <Dialog>:
import { Modal, Dialog, DialogTrigger, Button, Heading } from "@hashti/react";
export function BasicModalDemo() {
return (
<DialogTrigger>
<Button variant="primary">New Restoration Project</Button>
<Modal>
<Dialog>
{({ close }) => (
<div className="flex flex-col gap-4 p-6">
<Heading slot="title" className="text-xl font-bold tracking-tight">
Create Restoration Workspace
</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure your structural conservation initiative. You can invite team members and
link CAD models immediately after setup.
</p>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-800">
<Button variant="ghost" onPress={close}>
Cancel
</Button>
<Button variant="primary" onPress={close}>
Create Workspace
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</DialogTrigger>
);
}
Controlled Modal State
Control modal visibility externally using isOpen and onOpenChange:
import { useState } from "react";
import { Modal, Dialog, Button, Heading, TextField } from "@hashti/react";
export function ControlledModalDemo() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button variant="secondary" onPress={() => setIsOpen(true)}>
Open Settings Modal
</Button>
<Modal isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog>
<div className="flex flex-col gap-4 p-6">
<Heading slot="title" className="text-lg font-bold">
Account Preferences
</Heading>
<TextField label="Display Name" defaultValue="Parisa Rahimi" />
<TextField label="Contact Email" defaultValue="parisa@heritage.org" />
<div className="flex justify-end gap-3 pt-2">
<Button variant="ghost" onPress={() => setIsOpen(false)}>
Cancel
</Button>
<Button variant="primary" onPress={() => setIsOpen(false)}>
Save Changes
</Button>
</div>
</div>
</Dialog>
</Modal>
</>
);
}
Modal Sizes
Modals are available in four proportional size tiers: xs (384px), sm (448px), md (576px, default), and lg (768px):
import { useState } from "react";
import { Modal, Dialog, Button, Heading } from "@hashti/react";
type ModalSize = "xs" | "sm" | "md" | "lg";
export function ModalSizesDemo() {
const [activeSize, setActiveSize] = useState<ModalSize | null>(null);
return (
<div className="flex flex-wrap gap-3">
{(["xs", "sm", "md", "lg"] as ModalSize[]).map((size) => (
<Button key={size} variant="secondary" onPress={() => setActiveSize(size)}>
Open Size: {size.toUpperCase()}
</Button>
))}
{activeSize && (
<Modal
size={activeSize}
isOpen={activeSize !== null}
onOpenChange={(open) => !open && setActiveSize(null)}
>
<Dialog>
{({ close }) => (
<div className="p-6 flex flex-col gap-3">
<Heading slot="title" className="text-lg font-bold">
Modal Size: {activeSize.toUpperCase()}
</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400">
This modal container uses the <code>{activeSize}</code> size constraint.
</p>
<div className="flex justify-end pt-4">
<Button variant="primary" onPress={close}>
Dismiss
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
)}
</div>
);
}
Non-Dismissable Modal
For mandatory, transactional flows (such as accepting terms or signing contracts), set isDismissable={false} to prevent dismissal when clicking the backdrop or pressing Esc:
import { useState } from "react";
import { Modal, Dialog, Button, Heading } from "@hashti/react";
export function NonDismissableModalDemo() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button variant="danger" onPress={() => setIsOpen(true)}>
Review Licensing Terms
</Button>
<Modal isOpen={isOpen} onOpenChange={setIsOpen} isDismissable={false}>
<Dialog>
{({ close }) => (
<div className="p-6 flex flex-col gap-4">
<Heading slot="title" className="text-lg font-bold">
Mandatory Agreement
</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400">
You must explicitly accept or decline these heritage preservation terms. Clicking
outside this window or pressing Escape will not dismiss it.
</p>
<div className="flex justify-end gap-3 pt-4">
<Button variant="ghost" onPress={close}>
Decline
</Button>
<Button variant="primary" onPress={close}>
I Agree & Continue
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</>
);
}
Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
isOpen | boolean | — | Controlled open visibility state. |
defaultOpen | boolean | false | Default open state for uncontrolled usage. |
onOpenChange | (isOpen: boolean) => void | — | Callback fired whenever the modal requests open/close. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Width and max-width dimension variant of the inner modal. |
isDismissable | boolean | true | Allows closing by clicking on the darkened backdrop or pressing Esc. |
isKeyboardDismissDisabled | boolean | false | Disables closing via the Esc key specifically. |
overlayClassName | string | — | Custom CSS classes applied to the backdrop overlay. |
modalStyle | CSSProperties | — | Inline style applied directly to the inner modal surface. |
children | ReactNode | — | Children to render, typically a <Dialog> element. |
Accessibility
Modal strictly implements the WAI-ARIA Dialog (Modal) Pattern:
- Focus Trap: Keyboard navigation (Tab / Shift + Tab) is strictly constrained within the modal while open. Focus cannot leak into the background page.
- Focus Restoration: When the modal closes, keyboard focus automatically returns to the triggering element that invoked it.
- Body Scroll Locking: Document background scrolling is locked while the modal is open, preventing disorienting background scroll behavior.
- Inert Background: Surrounding page content outside the modal container is automatically marked with the HTML
inertattribute andaria-hidden="true", ensuring assistive technologies can only inspect modal contents. - Accessible Labelling: When using
<Heading slot="title">, the modal dialog is automatically linked viaaria-labelledby.
Keyboard Interactions
| Key | Action |
|---|---|
| Tab | Cycles focus forward through tabbable elements trapped inside modal. |
| Shift+Tab | Cycles focus backward within modal dialog trap. |
| Esc | Dismisses modal dialog and restores focus to open trigger. |
WAI-ARIA & Data Attributes
| Attribute | Target | Description |
|---|---|---|
[data-entering] | Modal Overlay | Applied during entrance opacity transition. |
[data-exiting] | Modal Overlay | Applied during dismissal exit transition. |