oriUI

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

ts
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):

OptionTypeDefaultDescription
defaultOpenbooleanfalseInitial open state (uncontrolled).
modalbooleantrueshowModal() (trap + inert) vs show().
closeOnEscapebooleantrueEscape closes the dialog.
closeOnInteractOutsidebooleantrueClick on the ::backdrop closes it.
onOpenChange(open: boolean) => voidFires whenever the open state changes.
idstringautoBase 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.

PropertyTypeDescription
openComputedRef<boolean>Current open state.
setOpen(open)(open: boolean) => voidOpen / close imperatively.
toggle()() => voidFlip the open state.
triggerPropsComputedRef<object>The control that opens the dialog.
dialogPropsComputedRef<object>The <dialog> element: role, aria-modal, labelling, and the close / cancel / backdrop-click handlers that keep open in sync.
titlePropsComputedRef<object>Wires the accessible name (aria-labelledby).
descriptionPropsComputedRef<object>Wires the accessible description (aria-describedby).
closeTriggerPropsComputedRef<object>The close control.

Usage

Render a real <dialog>, bind dialogProps, and drive showModal() / close() from open:

vue
<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:

svelte
<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.

ts
// 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.

  • dialogProps set role="dialog", aria-modal (per the modal option), aria-labelledby pointing at the element you bind titleProps to, and the close / cancel handlers that keep open in sync when the browser closes the dialog (Esc, backdrop click).
  • triggerProps carry the aria-* attributes the open control needs.
  • closeTriggerProps carry 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-modal show() (modal: false) does not trap focus or block the page.
KeyAction
EscapeCloses the dialog and returns focus to the trigger (when closeOnEscape).
TabCycles focus among interactive elements inside the dialog (focus trap).
Shift+TabCycles 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.