Modal
An accessible dialog built on the native <dialog> element with focus trap, Esc-to-close, scroll lock, and configurable sizes.
Import
import { Modal } from "@hyzer-labs/ui"Demo
No dialog is open on page load. Click a trigger to open one.
The open and close animation honors --hz-duration-* and --hz-ease-*. See Motion for the token values, and for the script-side @hyzer-labs/ui/motion helpers built on them.
<Button onclick={() => (open = true)}>Open modal</Button>
<Modal bind:open size="sm" title="Example modal" description="Optional description line.">
<p>Modal body content. Focus is trapped while open.</p>
{#snippet actions()}
<Button onclick={() => (open = false)}>Confirm</Button>
<Button variant="ghost" onclick={() => (open = false)}>Cancel</Button>
{/snippet}
</Modal><Button onclick={() => (open = true)}>Open modal</Button>
<Modal bind:open title="Example modal" description="Optional description line.">
<p>Modal body content. Focus is trapped while open.</p>
{#snippet actions()}
<Button onclick={() => (open = false)}>Confirm</Button>
<Button variant="ghost" onclick={() => (open = false)}>Cancel</Button>
{/snippet}
</Modal><Button onclick={() => (open = true)}>Open modal</Button>
<Modal bind:open size="lg" title="Example modal" description="Optional description line.">
<p>Modal body content. Focus is trapped while open.</p>
{#snippet actions()}
<Button onclick={() => (open = false)}>Confirm</Button>
<Button variant="ghost" onclick={() => (open = false)}>Cancel</Button>
{/snippet}
</Modal><Button onclick={() => (open = true)}>Open modal</Button>
<Modal bind:open size="full" title="Example modal" description="Optional description line.">
<p>Modal body content. Focus is trapped while open.</p>
{#snippet actions()}
<Button onclick={() => (open = false)}>Confirm</Button>
<Button variant="ghost" onclick={() => (open = false)}>Cancel</Button>
{/snippet}
</Modal>closeOnOverlay={false} ignores backdrop clicks, which helps when a stray
click would discard work. There is deliberately no Esc opt-out: the WAI-ARIA dialog pattern
requires Escape to dismiss.
<!-- Opting out of overlay-click dismissal; Esc always closes -->
<Modal bind:open closeOnOverlay={false} title="Confirm delete">
<p>Stray backdrop clicks are ignored; Esc still works.</p>
{#snippet actions()}
<Button intent="danger" onclick={confirm}>Delete</Button>
<Button variant="ghost" onclick={() => (open = false)}>Cancel</Button>
{/snippet}
</Modal><!-- Hide the built-in ✕; or relabel it via closeLabel -->
<Modal bind:open showClose={false} title="No close button">
<p>Dismissal happens through the footer action instead.</p>
{#snippet actions()}
<Button onclick={() => (open = false)}>Done</Button>
{/snippet}
</Modal><Modal bind:open onclose={() => (closedCount += 1)} title="Track dismissals">
<p>onclose fires once per dismissal, any path: ✕, Esc, overlay, or actions.</p>
</Modal>Props
| Name | Type | Default | Note |
|---|---|---|---|
title | string | — | Required for accessibility. |
open | boolean | false | $bindable. |
description | string | — | |
size | 'sm' | 'md' | 'lg' | 'full' | 'md' | |
closeOnOverlay | boolean | true | Esc always closes regardless — the dialog pattern requires it. |
showClose | boolean | true | |
preventScroll | boolean | true | |
closeLabel | string | 'Close dialog' | |
onclose | () => void | — | |
class | string | — | Merged after the hz-modal class. |
children | Snippet | — | |
actions | Snippet | — |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-modal
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-size | 'sm' | 'md' | 'lg' | 'full' | full goes edge-to-edge and drops the radius. Default md. |
data-state | 'open' | 'closed' | Mirrors the open prop. Visibility itself runs off the native [open] attribute that showModal() sets — this is the hook to animate against. |
data-modal-close | present on the close button | Load-bearing beyond styling: focus management queries it to keep the close button out of the first-focus candidates. Renaming it breaks focus order, not just looks. |
Custom properties
| Hook | Values | Styles |
|---|---|---|
--hz-modal-width | <length> — 30/40/52rem by size | One name across all three sizes: it overrides whichever size is active. data-size="full" ignores it. |
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-modal-header | child element | Always rendered. |
.hz-modal-title | child element | Always an h2 — Modal has no headingLevel prop, because the dialog is its own document section. |
.hz-modal-description | child element | The optional description. |
.hz-modal-body | child element | Always rendered, so the scroll region stays stable even when empty. |
.hz-modal-footer | child element | Present only with actions. |
::backdrop | pseudo-element | The native dialog backdrop. |
Accessibility
Modal uses showModal() for native top-layer focus trapping and backdrop. aria-modal, aria-labelledby, and optional aria-describedby are set automatically. Focus returns to the trigger on close. The built-in close button's accessible name comes from closeLabel.
References: APG Dialog (Modal) pattern · MDN: <dialog>