oriUI

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.

ClassTypeDescription
ori-dialogBlockThe native panel: max-width 460 px, rounded, surface-coloured. Its ::backdrop pseudo-element is the dimmed overlay.
ori-dialog__contentLayoutPadding wrapper inside the dialog that holds the header and body.
ori-dialog__headerPartFlex row — title on the left, close button on the right.
ori-dialog__titleParth2 heading; its id is wired to aria-labelledby on the dialog.
ori-dialog__closePartBare close button (aria-label=Close); styled via opacity.
ori-dialog__bodyPartBody copy region below the header.

Anatomy

The component renders its parts in this order:

text
#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.

Weave a dialog

This modal runs on the native <dialog> element through oriUI's headless contract — focus is trapped, Esc closes it, the page behind is inert, and it's announced as aria-modal, all from the platform with no extra dependency. The swappable contract still lets an app drop in a different engine by changing one line at the app root.

html
<!-- 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>
vue
<OriDialog title="Hello, oriUI">
    <template #trigger="{ props }">
        <OriButton v-bind="props" text="Open dialog" />
    </template>
    <p>This modal traps focus, closes on Escape, and locks page scroll.</p>
</OriDialog>
svelte
<script>
    import { useDialog } from '@oriui/headless/svelte';

    // No styled Svelte component yet — drive a native <dialog> with @oriui/headless/svelte.
    let dialogEl;
    const { open, triggerProps, dialogProps, titleProps, closeTriggerProps } = useDialog({ id: 'greet' });

    // The platform gives the focus trap, Esc and ::backdrop; we only toggle showModal()/close() on `open`.
    $effect(() => {
        if (!dialogEl) return;
        if ($open) dialogEl.showModal();
        else if (dialogEl.open) dialogEl.close();
    });
</script>

<button {...$triggerProps} class="ori-button ori-variant_fill ori-color_primary">Open dialog</button>

<dialog {...$dialogProps} bind:this={dialogEl} class="ori-dialog">
    <div class="ori-dialog__content">
        <header class="ori-dialog__header">
            <h2 {...$titleProps} class="ori-dialog__title">Hello, oriUI</h2>
            <button {...$closeTriggerProps} class="ori-dialog__close" aria-label="Close">×</button>
        </header>
        <div class="ori-dialog__body">
            <p>This modal traps focus and closes on Escape or a backdrop click.</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).

Weave a dialog

This modal runs on the native <dialog> element through oriUI's headless contract — focus is trapped, Esc closes it, the page behind is inert, and it's announced as aria-modal, all from the platform with no extra dependency. The swappable contract still lets an app drop in a different engine by changing one line at the app root.

html
<!-- Structure only — open with dialog.show() for non-modal. -->
<dialog class="ori-dialog" aria-modal="false" aria-labelledby="dlg-title">
    <!-- content -->
</dialog>
vue
<OriDialog title="Non-modal hint" :modal="false">
    <template #trigger="{ props }">
        <OriButton v-bind="props" text="Open non-modal" variant="outline" />
    </template>
    <p>Page is still scrollable and click-outside does not close this dialog.</p>
</OriDialog>

Initially open

Pass defaultOpen to open the dialog on first mount — useful for confirmation prompts that appear on page load.

Weave a dialog

This modal runs on the native <dialog> element through oriUI's headless contract — focus is trapped, Esc closes it, the page behind is inert, and it's announced as aria-modal, all from the platform with no extra dependency. The swappable contract still lets an app drop in a different engine by changing one line at the app root.

html
<!-- Structure only — call dialog.showModal() on mount to open it. -->
<dialog class="ori-dialog" aria-labelledby="dlg-title" open>
    <!-- content -->
</dialog>
vue
<OriDialog title="Welcome back" :defaultOpen="true">
    <template #trigger="{ props }">
        <OriButton v-bind="props" text="Re-open" variant="tonal" />
    </template>
    <p>This dialog was open on first render.</p>
</OriDialog>

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.

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

Weave a dialog

This modal runs on the native <dialog> element through oriUI's headless contract — focus is trapped, Esc closes it, the page behind is inert, and it's announced as aria-modal, all from the platform with no extra dependency. The swappable contract still lets an app drop in a different engine by changing one line at the app root.

html
<!-- Structure only — not functional on its own. -->
<h2 id="dlg-title" class="ori-dialog__title">Delete <strong>project-x</strong>?</h2>
vue
<OriDialog>
    <template #trigger="{ props }">
        <OriButton v-bind="props" text="Rich title" variant="tonal" color="info" />
    </template>
    <template #title>
        <span>Delete <strong>project-x</strong>?</span>
    </template>
    <p>This action is permanent and cannot be undone.</p>
</OriDialog>

Common patterns

A confirm / cancel pair inside a dialog, wired to an async action.

Weave a dialog

This modal runs on the native <dialog> element through oriUI's headless contract — focus is trapped, Esc closes it, the page behind is inert, and it's announced as aria-modal, all from the platform with no extra dependency. The swappable contract still lets an app drop in a different engine by changing one line at the app root.

html
<!-- 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>
vue
<OriDialog title="Delete account?">
    <template #trigger="{ props }">
        <OriButton v-bind="props" text="Delete account" variant="tonal" color="danger" />
    </template>
    <p>This will permanently delete your account and all its data. This cannot be undone.</p>
    <div style="display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem">
        <!-- Close via the adapter's close mechanism or a custom event -->
        <OriButton text="Cancel" variant="text" />
        <OriButton text="Yes, delete" color="danger" variant="fill" />
    </div>
</OriDialog>

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> is role="dialog", aria-modal="true" (modal mode), and labelled by the title element via aria-labelledby.
  • The close button has aria-label="Close".
  • The #trigger slot's props object includes the aria-* attributes for the trigger element; bind them with v-bind="props".
  • showModal() provides the live behaviour — focus trap, scroll lock, ::backdrop, and returning focus to the trigger on close — straight from the platform.
KeyAction
EscapeCloses the dialog and returns focus to the trigger (native <dialog>).
TabCycles focus among interactive elements inside the dialog (focus trap).
Shift+TabCycles 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

PropTypeDefaultDescription
closeOnEscapebooleantrueWhether Esc closes the dialog.
closeOnInteractOutsidebooleantrueWhether a click on the ::backdrop closes the dialog.
defaultOpenbooleanfalseWhether the dialog is open on first mount.
modalbooleantrueModal mode: showModal() (trap + scroll lock + ::backdrop) vs show().
openbooleanControlled open state for v-model:open. Omit for an uncontrolled dialog.
titlestringHeading text. Overridden by the #title slot when provided.

Events & attributes

EventPayloadDescription
update:openbooleanFires on every open/close transition — enables v-model:open.
closeFires 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

SlotScopeDescription
trigger{ props: object, open: boolean }The control that opens the dialog. Bind :props on your trigger element.
titleReplaces the heading text; receives no scope. Falls back to the title prop when omitted.
defaultBody 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.

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