Dialog
A window over the page that takes focus until it is dismissed. Modal by default: the rest of the page becomes inert, the body stops scrolling, and focus cannot leave.
Use something else if the content is not an interruption. A dialog costs the user their place on the page; a panel, a disclosure or a new route usually says the same thing without taking the page away.
<script setup lang="ts">import { Dialog } from '@caioalfonso/kanso-vue';</script>
<template> <Dialog.Root> <Dialog.Trigger>Delete project</Dialog.Trigger>
<Dialog.Positioner> <Dialog.Backdrop /> <Dialog.Content> <Dialog.Title>Delete this project?</Dialog.Title> <Dialog.Description> Everything in it goes with it. </Dialog.Description> <Dialog.Close>Keep it</Dialog.Close> </Dialog.Content> </Dialog.Positioner> </Dialog.Root></template>import { Dialog } from '@caioalfonso/kanso-react';
export function DialogBasicReact() { return ( <Dialog.Root> <Dialog.Trigger>Delete project</Dialog.Trigger>
<Dialog.Positioner> <Dialog.Backdrop /> <Dialog.Content> <Dialog.Title>Delete this project?</Dialog.Title> <Dialog.Description>Everything in it goes with it.</Dialog.Description> <Dialog.Close>Keep it</Dialog.Close> </Dialog.Content> </Dialog.Positioner> </Dialog.Root> );}The two panels above are different libraries rendering the same core. The focus
trap, the scroll lock, the dismissal rules and every ARIA attribute come from the
same @caioalfonso/kanso-core code; only the rendering differs.
Import
Section titled “Import”import { Dialog } from '@caioalfonso/kanso-react/dialog';
import '@caioalfonso/kanso-styles/tokens';import '@caioalfonso/kanso-styles/base';import '@caioalfonso/kanso-styles/dialog';The stylesheet is optional — without it you get a working, entirely unstyled
dialog, including the trap and the scroll lock, which are behaviour rather than
appearance. See Installation for package
setup, the Vue entry points, and why tokens and base come along.
Anatomy
Section titled “Anatomy”Dialog is a compound component: seven parts you assemble yourself.
| Part | Element | Notes |
|---|---|---|
trigger | <button> | Stays in the page. Carries aria-haspopup and aria-expanded. |
positioner | <div> | Portalled to <body>. Renders only while open. |
backdrop | <div> | The scrim. Optional, and rendered inside the positioner. |
content | <div> | role=“dialog”. The trap, the layer and focus all key off it. |
title | <h2> | Optional. Its presence is what emits aria-labelledby. |
description | <p> | Optional. Its presence is what emits aria-describedby. |
close | <button> | Any number of them, anywhere inside the content. |
The list above comes from dialogAnatomy, which the library exports:
{dialogAnatomy.join(', ')}.
Dialog.Root renders no element of its own — the trigger stays where you put
it and everything else is portalled, so there is no position in the tree a
wrapper could occupy without being wrong for one of them. That also means Dialog
has two marked scope roots rather than one: the trigger and the positioner both
carry data-kanso and data-scope="dialog".
All of these go on Dialog.Root.
| Prop | Type | Default | Notes |
|---|---|---|---|
open | boolean | — | Controlled. Present ⇒ you own the state. |
defaultOpen | boolean | false | Uncontrolled initial value. |
onOpenChange | (open: boolean) => void | — | Fires in both modes. Vue emits openChange. |
modal | boolean | true | Non-modal skips the trap and the scroll lock. |
role | 'dialog' | 'alertdialog' | 'dialog' | See below. |
closeOnEscape | boolean | true | |
closeOnInteractOutside | boolean | true | |
initialFocus | () => HTMLElement | null | — | Defaults to the first focusable element. |
finalFocus | () => HTMLElement | null | — | Defaults to whatever had focus at open. |
id | string | auto | Other ids derive from it. |
Vue supports v-model:open on Dialog.Root, which maps onto the same core
events.
Keyboard
Section titled “Keyboard”| Key | Action |
|---|---|
Escape | Close, and return focus to where it came from |
Tab | Move to the next focusable element inside the dialog; wrap at the end |
Shift+Tab | The same, in reverse |
Enter and Space need no handler: every trigger and close button is a real
<button>.
Focus, which is the part usually got wrong
Section titled “Focus, which is the part usually got wrong”- On open, focus moves inside —
initialFocusif given, else the first focusable element, else the content itself. Something inside has to receive focus or a screen reader never announces the dialog at all. - While open, focus cannot leave. The page behind is marked
inert, which also removes it from the accessibility tree and stops find-in-page reaching it; aTabcycle wraps at the edges as a fallback. - On close, focus returns to whatever had it when the dialog opened — usually the trigger, but not always, since a dialog can be opened by a keyboard shortcut or by a parent with no trigger at all. If that element is gone by then, focus falls back to the body rather than being dropped.
const cancelRef = useRef<HTMLButtonElement>(null);
<Dialog.Root role="alertdialog" initialFocus={() => cancelRef.current}>In Vue, a template ref on a component resolves to its instance rather than to
its element, so reach for the element through $el:
const cancel = ref();const initialFocus = () => cancel.value?.$el ?? null;modal versus non-modal
Section titled “modal versus non-modal”modal (the default) traps focus, locks body scrolling and inerts the page. A
non-modal dialog does none of those: the page behind stays usable, which is the
entire distinction. Dismissal and focus movement still apply either way.
The scroll lock compensates for the scrollbar it removes, so the page does not
shift sideways when a dialog opens, and it is refcounted — a dialog opened from
inside a dialog does not unlock the page when the inner one closes. Escape and
the Tab cycle are stack-aware too: with two dialogs open, only the topmost one
responds.
role="alertdialog"
Section titled “role="alertdialog"”For an interruption that needs a response: a confirmation, an error that blocks progress. Assistive technology announces the description along with the name, and the user is expected to deal with it before continuing. Everything else behaves identically, which is why this is a prop rather than a second component.
Accessibility
Section titled “Accessibility”Follows the APG modal dialog pattern.
role="dialog"witharia-modal="true"while modal.aria-modalis never emitted as"false"— its absence says the same thing.aria-labelledbyandaria-describedbyare emitted only when the part they point at is rendered. An idref to an element that does not exist leaves the dialog with no accessible name at all, and axe reports it as incomplete rather than as a violation — so nothing in CI would tell you.- A dialog with neither a
Dialog.Titlenor anaria-labellogs an error in development. Give it one or the other; a screen reader otherwise announces just “dialog”. - The trigger carries
aria-haspopup="dialog"andaria-expanded, and deliberately noaria-controls: the content is unmounted while closed, so that idref would dangle for most of the component’s life. - The content is
max-heightconstrained and scrolls internally, so a focused control inside a long dialog is never pushed under the edge of the viewport (WCAG 2.2 SC 2.4.11). - Triggers and close buttons are at least 44px tall (SC 2.5.8), and the focus
ring is
:focus-visibleonly.
Examples
Section titled “Examples”Controlled
Section titled “Controlled”const [open, setOpen] = useState(false);
<Dialog.Root open={open} onOpenChange={setOpen}> {/* … */}</Dialog.Root>Useful when something other than the trigger opens the dialog — a keyboard shortcut, a route, a failed request.
Dismissal you control
Section titled “Dismissal you control”<Dialog.Root closeOnEscape={false} closeOnInteractOutside={false}>Both off makes a dialog that can only be closed deliberately — appropriate for
a destructive confirmation, and hostile for anything else. closeOnEscape: false means only that Escape does not close this dialog; the press still
reaches your own handlers.
Server rendering
Section titled “Server rendering”The dialog is portalled after mount, so it is absent from the server-rendered
HTML — including one with defaultOpen. That is deliberate: React’s
createPortal is not supported by react-dom/server, Vue’s teleport output is
collected separately from the page HTML, and content that must appear in the
initial response does not want to be a dialog.
Unstyled
Section titled “Unstyled”The library ships no styles unless you import them. The behaviour is unaffected:
[data-scope='dialog'] [data-part='content'] { /* your dialog */}[data-scope='dialog'] [data-part='backdrop'] { /* your scrim */}Two rules are worth keeping if you write your own: the positioner needs
pointer-events: none with the content taking events back, or a non-modal
dialog makes the whole page unclickable; and the content needs a max-height
with overflow: auto, or a long dialog pushes its own buttons off-screen.