useDialog
A headless modal dialog primitive built on the native <dialog> element — focus trap, scroll lock,
Escape to close, focus return, and aria-modal semantics all come from the platform via
showModal(). It owns the open state and the ARIA wiring as ready-to-bind prop bags; you own the
markup and styles (you render the <dialog> and drive showModal() / close() from open).
This is the Vue binding; the engine-agnostic contract lives in @oriui/headless, and the
styled OriDialog is built on it. Like useDisclosure,
a dialog now has a zero-dependency native default — the browser's <dialog> supplies the focus trap,
::backdrop, top-layer and focus-return that used to require a heavyweight engine. No adapter is required.
Import
import { useDialog } from '@oriui/headless/vue';
No adapter needs to be registered — useDialog resolves the native engine by default. Swapping a custom
engine is optional; see Adapter.
Options
Pass an options object (or a getter returning one, to stay reactive):
| Option | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | Initial open state (uncontrolled). |
modal | boolean | true | showModal() (trap + inert) vs show(). |
closeOnEscape | boolean | true | Escape closes the dialog. |
closeOnInteractOutside | boolean | true | Click on the ::backdrop closes it. |
onOpenChange | (open: boolean) => void | — | Fires whenever the open state changes. |
id | string | auto | Base id for the part ARIA wiring. |
Returns
A DialogControl — the open state plus the prop bags you bind to each part. Every *Props value is a
ComputedRef; bind it with v-bind.
| Property | Type | Description |
|---|---|---|
open | ComputedRef<boolean> | Current open state. |
setOpen(open) | (open: boolean) => void | Open / close imperatively. |
toggle() | () => void | Flip the open state. |
triggerProps | ComputedRef<object> | The control that opens the dialog. |
dialogProps | ComputedRef<object> | The <dialog> element: role, aria-modal, labelling, and the close / cancel / backdrop-click handlers that keep open in sync. |
titleProps | ComputedRef<object> | Wires the accessible name (aria-labelledby). |
descriptionProps | ComputedRef<object> | Wires the accessible description (aria-describedby). |
closeTriggerProps | ComputedRef<object> | The close control. |
Usage
Render a real <dialog>, bind dialogProps, and drive showModal() / close() from open:
<script setup lang="ts">
import { useTemplateRef, watchPostEffect } from 'vue';
import { useDialog } from '@oriui/headless/vue';
const dlg = useDialog(() => ({ modal: true }));
const dialogEl = useTemplateRef<HTMLDialogElement>('dialog');
watchPostEffect(() => {
const el = dialogEl.value;
if (!el) return;
if (dlg.open.value && !el.open) el.showModal();
else if (!dlg.open.value && el.open) el.close();
});
</script>
<template>
<button v-bind="dlg.triggerProps.value">Open</button>
<dialog ref="dialog" v-bind="dlg.dialogProps.value">
<h2 v-bind="dlg.titleProps.value">Title</h2>
<p v-bind="dlg.descriptionProps.value">Body copy.</p>
<button v-bind="dlg.closeTriggerProps.value">Close</button>
</dialog>
</template>
No <Teleport> and no mounted-ref gating are needed: a modal <dialog> renders in the browser's top
layer regardless of where it sits in the DOM, and a closed <dialog> is hidden, so the SSR markup stays
stable. The styled OriDialog wraps exactly this pattern.
The Svelte binding is the same — drive showModal() / close() from $open in an $effect:
<script>
import { useDialog } from '@oriui/headless/svelte';
let dialogEl;
const { open, triggerProps, dialogProps, titleProps, descriptionProps, closeTriggerProps } = useDialog({
modal: true
});
$effect(() => {
if (!dialogEl) return;
if ($open && !dialogEl.open) dialogEl.showModal();
else if (!$open && dialogEl.open) dialogEl.close();
});
</script>
<button {...$triggerProps}>Open</button>
<dialog bind:this={dialogEl} {...$dialogProps}>
<h2 {...$titleProps}>Title</h2>
<p {...$descriptionProps}>Body copy.</p>
<button {...$closeTriggerProps}>Close</button>
</dialog>
Adapter
useDialog defaults to the native <dialog> engine — the focus trap, scroll lock, Esc, ::backdrop
and focus-return are the platform's job now, so no dependency is required. The OriHeadless contract is
still there as a hedge: register a custom engine per primitive only if a project needs one (for
example a Zag-backed adapter for a genuinely hard widget), without touching your markup.
// main.ts
import { createApp } from 'vue';
import { OriHeadless } from '@oriui/headless/vue';
import { myDialog } from './headless/my-dialog'; // optional custom adapter
const app = createApp(App);
app.use(OriHeadless, { dialog: myDialog });
app.mount('#app');
useDialog resolves that adapter (or one from provideHeadless inside a subtree), falling back to the
native engine when none is wired. Any adapter produces the same DialogControl shape, so your markup
never changes.
Accessibility
The native <dialog> carries the WAI-ARIA dialog contract; the prop bags complete the wiring.
dialogPropssetrole="dialog",aria-modal(per themodaloption),aria-labelledbypointing at the element you bindtitlePropsto, and theclose/cancelhandlers that keepopenin sync when the browser closes the dialog (Esc, backdrop click).triggerPropscarry thearia-*attributes the open control needs.closeTriggerPropscarry the close control's wiring; give it an accessible name (e.g.aria-label="Close").- The live behaviour — focus trap, scroll lock, returning focus to the trigger on close — comes from
showModal(). A non-modalshow()(modal: false) does not trap focus or block the page.
| Key | Action |
|---|---|
Escape | Closes the dialog and returns focus to the trigger (when closeOnEscape). |
Tab | Cycles focus among interactive elements inside the dialog (focus trap). |
Shift+Tab | Cycles focus backwards inside the dialog. |
See also
- @oriui/headless — the framework-agnostic contract and the native engine.
- useDisclosure — the sibling show / hide primitive (also native by default).
- OriDialog — the styled component built on this primitive.
- CSS layer — the standalone
.ori-*classes for the dialog parts.