oriUI

useDialog

A headless modal dialog primitive built on the native <dialog> element — focus trap, 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) => void—Fires 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.
contentPropsComputedRef<object>The <dialog> element: role, aria-modal, labeling, 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 contentProps, 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.contentProps.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.

Adapter

useDialog defaults to the native <dialog> engine — the focus trap, 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.

  • contentProps 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 behavior — focus trap, returning focus to the trigger on close — comes from showModal(). A non-modal show() (modal: false) does not trap focus or block the page.
  • showModal() does not stop the page from scrolling under the dialog. The .ori-dialog class does it for you; with a class of your own, add :root:has(dialog:modal) { overflow: hidden; }.
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.