Dialog
A modal dialog — the library's first genuinely interactive component, built on the native
<dialog> element. Unlike the styled-only components, a dialog needs behaviour: focus trap, scroll
lock, Esc-to-close, click-outside dismissal, and the full WAI-ARIA keyboard contract. All of it comes
from the platform via showModal() — no state-machine dependency and no adapter to wire. The styled
OriDialog supplies only the markup and tokens.
The examples are organised by layer: the class reference is the standalone
@oriui/css layer, and the Framework API is the @oriui/vue component. Every
example is live — flip its code between HTML (the standalone classes, also your htmx / Astro / Svelte /
plain-HTML usage), Vue, and Svelte (@oriui/headless/svelte); HTML is the default.
Classes
A dialog is a set of part classes hung on a native <dialog> element — there is no single block class
that drives variant tokens. Each part class maps to a structural element produced by the component.
| Class | Type | Description |
|---|---|---|
ori-dialog | Block | The native panel: max-width 460 px, rounded, surface-coloured. Its ::backdrop pseudo-element is the dimmed overlay. |
ori-dialog__content | Layout | Padding wrapper inside the dialog that holds the header and body. |
ori-dialog__header | Part | Flex row — title on the left, close button on the right. |
ori-dialog__title | Part | h2 heading; its id is wired to aria-labelledby on the dialog. |
ori-dialog__close | Part | Bare close button (aria-label=Close); styled via opacity. |
ori-dialog__body | Part | Body copy region below the header. |
Anatomy
The component renders its parts in this order:
#trigger slot (caller-supplied open control)
└─ <dialog class="ori-dialog"> (native element; ::backdrop is the overlay)
└─ .ori-dialog__content (padding wrapper)
├─ .ori-dialog__header
│ ├─ .ori-dialog__title (h2; #title slot or `title` prop)
│ └─ .ori-dialog__close (× button, aria-label="Close")
└─ .ori-dialog__body (default slot — body copy)
The <dialog> is rendered inline; showModal() promotes it to the browser's top layer, so it escapes
any stacking context without <Teleport>. A closed <dialog> is hidden, so SSR markup stays stable.
Basic
Open the dialog, tab through its controls, press Esc, and observe that focus returns to the
trigger. Page scroll is locked while it is open.
<!-- Structure only — drive it with dialog.showModal() to get the trap / ::backdrop. -->
<dialog class="ori-dialog" aria-labelledby="dlg-title">
<div class="ori-dialog__content">
<header class="ori-dialog__header">
<h2 id="dlg-title" class="ori-dialog__title">Hello, oriUI</h2>
<button class="ori-dialog__close" aria-label="Close">×</button>
</header>
<div class="ori-dialog__body">
<p>This modal traps focus, closes on Escape, and locks page scroll.</p>
</div>
</div>
</dialog>
Non-modal
Set modal to false for a non-modal dialog (opened with show() instead of showModal() — no scroll
lock, no focus trap, no ::backdrop, no click-outside dismiss).
<!-- Structure only — open with dialog.show() for non-modal. -->
<dialog class="ori-dialog" aria-modal="false" aria-labelledby="dlg-title">
<!-- content -->
</dialog>
Initially open
Pass defaultOpen to open the dialog on first mount — useful for confirmation prompts that appear on
page load.
<!-- Structure only — call dialog.showModal() on mount to open it. -->
<dialog class="ori-dialog" aria-labelledby="dlg-title" open>
<!-- content -->
</dialog>
Controlled open
Bind v-model:open to drive the dialog from your own state — a confirmation prompt opened by a host
action, with no #trigger slot. The dialog still emits update:open (keeping v-model in sync when
the user closes via Esc, the backdrop, or the × button) and close for one-shot reactions.
<script setup lang="ts">
import { ref } from 'vue';
import { OriButton, OriDialog } from '@oriui/vue';
const open = ref(false);
function onConfirm() {
// …run the destructive action…
open.value = false;
}
</script>
<template>
<OriButton text="Delete account" color="danger" variant="tonal" @click="open = true" />
<OriDialog v-model:open="open" title="Delete account?">
<p>This permanently deletes your account. This cannot be undone.</p>
<div style="display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem">
<OriButton text="Cancel" variant="text" @click="open = false" />
<OriButton text="Yes, delete" color="danger" @click="onConfirm" />
</div>
</OriDialog>
</template>
Reach for the uncontrolled form (defaultOpen + the #trigger slot, above) when the dialog owns a
single self-contained flow; reach for v-model:open when a host already holds the state — a store, a
route guard, or a shared "unsaved changes?" prompt.
Custom title via slot
Omit the title prop and supply markup in the #title slot when you need rich heading content.
<!-- Structure only — not functional on its own. -->
<h2 id="dlg-title" class="ori-dialog__title">Delete <strong>project-x</strong>?</h2>
Common patterns
A confirm / cancel pair inside a dialog, wired to an async action.
<!-- Structure only — not functional on its own. -->
<dialog class="ori-dialog" aria-labelledby="dlg-title">
<div class="ori-dialog__content">
<header class="ori-dialog__header">
<h2 id="dlg-title" class="ori-dialog__title">Delete account?</h2>
<button class="ori-dialog__close" aria-label="Close">×</button>
</header>
<div class="ori-dialog__body">
<p>This will permanently delete your account and all its data.</p>
<div style="display:flex; justify-content:flex-end; gap:.5rem; margin-top:1rem">
<button class="ori-button ori-variant_text">Cancel</button>
<button class="ori-button ori-variant_fill ori-color_danger">Yes, delete</button>
</div>
</div>
</div>
</dialog>
Accessibility
The accessibility contract holds across both layers — the classes and the Vue component render the
same attributes. The interactive behaviour, however, must be driven by JavaScript: a static HTML
<dialog> is markup only until showModal() opens it.
- The
<dialog>isrole="dialog",aria-modal="true"(modal mode), and labelled by the title element viaaria-labelledby. - The close button has
aria-label="Close". - The
#triggerslot'spropsobject includes thearia-*attributes for the trigger element; bind them withv-bind="props". showModal()provides the live behaviour — focus trap, scroll lock,::backdrop, and returning focus to the trigger on close — straight from the platform.
| Key | Action |
|---|---|
Escape | Closes the dialog and returns focus to the trigger (native <dialog>). |
Tab | Cycles focus among interactive elements inside the dialog (focus trap). |
Shift+Tab | Cycles focus backwards inside the dialog. |
Framework API
The props, events, slots, and headless wiring of the Vue component. The standalone CSS layer has no component API — its surface is the classes above, and the behaviour is yours to wire. (Svelte bindings are planned.)
Props
| Prop | Type | Default | Description |
|---|---|---|---|
closeOnEscape | boolean | true | Whether Esc closes the dialog. |
closeOnInteractOutside | boolean | true | Whether a click on the ::backdrop closes the dialog. |
defaultOpen | boolean | false | Whether the dialog is open on first mount. |
modal | boolean | true | Modal mode: showModal() (trap + scroll lock + ::backdrop) vs show(). |
open | boolean | — | Controlled open state for v-model:open. Omit for an uncontrolled dialog. |
title | string | — | Heading text. Overridden by the #title slot when provided. |
Events & attributes
| Event | Payload | Description |
|---|---|---|
update:open | boolean | Fires on every open/close transition — enables v-model:open. |
close | — | Fires whenever the dialog closes (trigger, Esc, backdrop, × button, or host). |
Bind v-model:open to control the dialog from a host ref (see Controlled open).
Both events fire in the uncontrolled (defaultOpen + #trigger) mode too, so a host can react to a
close without owning the state. The #trigger slot also exposes props and open — bind
v-bind="props" on the trigger element; open reflects the current state if you need it in the
template.
Controlled mode is optimistic: a user-initiated close (Esc, the backdrop, or the × button)
always closes the dialog and then emits — treat update:open(false) / close as authoritative and
mirror them into your state. The dialog can't be held open by ignoring the event (a native <dialog>
closes on Esc regardless — set :closeOnEscape="false" to disable that one path).
OriDialog renders a fragment (the #trigger slot plus the <dialog>), so stray attributes placed
directly on <OriDialog> are not auto-inherited onto a single root — put attributes meant for the
trigger on the trigger element via the #trigger slot's props instead.
Slots
| Slot | Scope | Description |
|---|---|---|
trigger | { props: object, open: boolean } | The control that opens the dialog. Bind :props on your trigger element. |
title | — | Replaces the heading text; receives no scope. Falls back to the title prop when omitted. |
default | — | Body content rendered inside .ori-dialog__body. |
Headless & adapter
OriDialog calls useDialog() from @oriui/headless/vue, which defaults to the native
<dialog> engine — the focus trap, scroll lock, Esc handling, ::backdrop and focus-return are the
platform's job, so no adapter and no extra dependency are required.
The OriHeadless contract is still available as a hedge: register a custom engine (e.g. a Zag-backed
adapter for a genuinely hard widget) only if a project needs one — the markup never changes.
// main.ts — optional; the native engine is used when nothing is registered.
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');
See useDialog for the full control contract.