Beta, expect API change, and bugs. Hit one? Tell us.

Skip to content

Dialog

A focus-trapping overlay.

<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'
</script>

<template>
  <Dialog.Root>
    <Dialog.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Reminders
    </Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-20 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Dialog.Popup
        class="fixed top-1/2 left-1/2 -mt-8 w-96 max-w-[calc(100vw-3rem)] -translate-1/2 rounded-lg bg-gray-50 p-4 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[scale,opacity] duration-100 ease-out data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:scale-[0.98] data-starting-style:opacity-0"
      >
        <Dialog.Title class="mb-1 text-base font-semibold">Turn on reminders?</Dialog.Title>
        <Dialog.Description class="mb-4 text-sm text-gray-600">
          Get notified when something new is available.
        </Dialog.Description>
        <div class="flex justify-end gap-3">
          <Dialog.Close
            class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
          >
            Not now
          </Dialog.Close>
          <Dialog.Close
            class="flex h-8 items-center justify-center rounded-md border border-gray-900 bg-gray-900 px-3 text-sm font-normal text-gray-50 select-none hover:bg-gray-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-700"
          >
            Turn on
          </Dialog.Close>
        </div>
      </Dialog.Popup>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Anatomy

<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'
</script>

<template>
  <Dialog.Root>
    <Dialog.Trigger />
    <Dialog.Portal>
      <Dialog.Backdrop />
      <Dialog.Viewport>
        <Dialog.Popup>
          <Dialog.Title />
          <Dialog.Description />
          <Dialog.Close />
        </Dialog.Popup>
      </Dialog.Viewport>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Dialog.Viewport is optional. It provides a scrollable positioning container for the popup. When not needed, use Dialog.Popup directly with fixed positioning.

Usage guidelines

  • Dialog doesn't support gestures: if you need gestures or snap points, use Drawer. A panel that slides in from the screen edge without gestures is just a positioned Dialog.

Examples

State

By default, Dialog manages its own open state, and no props are required.

<template>
  <Dialog.Root>
    <Dialog.Trigger>Open</Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Popup>
        <Dialog.Title>Example dialog</Dialog.Title>
        <Dialog.Close>Close</Dialog.Close>
      </Dialog.Popup>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Drive open with :open / @update:open, or v-model:open.

<script setup>
import { shallowRef } from 'vue'

const open = shallowRef(false)
</script>

<template>
  <Dialog.Root :open="open" @update:open="(next) => (open = next)">
    <Dialog.Trigger>Open</Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Popup>
        <form
          @submit.prevent="
            async () => {
              // Close the dialog once the form data is submitted
              await submitData()
              open = false
            }
          "
        >
          ...
        </form>
      </Dialog.Popup>
    </Dialog.Portal>
  </Dialog.Root>
</template>

@update:open is also the place to run side effects when the dialog opens or closes. Prefer it over a watcher.

<template>
  <Dialog.Root
    :open="open"
    @update:open="
      (next) => {
        // Do stuff when the dialog is closed
        if (!next) {
          doStuff()
        }
        // Set the new state
        open = next
      }
    "
  >
    ...
  </Dialog.Root>
</template>

Open from a menu

To open a dialog from a menu, keep the dialog controlled and flip its state from the menu item's @click handler.

<script setup>
import { Dialog } from '@shardsui/vue/dialog'
import { Menu } from '@shardsui/vue/menu'
import { shallowRef } from 'vue'

const dialogOpen = shallowRef(false)
</script>

<template>
  <Menu.Root>
    <Menu.Trigger>Open menu</Menu.Trigger>
    <Menu.Portal>
      <Menu.Positioner>
        <Menu.Popup>
          <!-- Open the dialog when the menu item is clicked -->
          <Menu.Item @click="dialogOpen = true">Open dialog</Menu.Item>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>

  <!-- Control the dialog state -->
  <Dialog.Root v-model:open="dialogOpen">
    <Dialog.Portal>
      <Dialog.Backdrop />
      <Dialog.Popup>
        <!-- Rest of the dialog -->
      </Dialog.Popup>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Nested dialogs

Dialogs can be nested. Style the parent through the [data-nested-dialog-open] selector and the var(--nested-dialogs) CSS variable. Child dialogs render their own backdrop, marked with data-nested. Hide it with [data-nested] { opacity: 0 } to keep the parent visible behind the one on top.

<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'
</script>

<template>
  <Dialog.Root>
    <Dialog.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Edit plan
    </Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-20 transition-opacity duration-150 ease-[cubic-bezier(0.25,0.1,0.25,1)] data-ending-style:opacity-0 data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Dialog.Popup
        class="fixed top-1/2 left-1/2 -mt-8 flex w-96 max-w-[calc(100vw-3rem)] [translate:-50%_calc(-50%+1.25rem*var(--nested-dialogs))] scale-[calc(1-0.1*var(--nested-dialogs))] flex-col gap-4 rounded-lg bg-gray-50 p-4 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[translate,scale,opacity] duration-100 ease-[cubic-bezier(0,0,0.58,1)] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-black/5 after:opacity-0 after:transition-opacity after:duration-100 after:ease-[cubic-bezier(0,0,0.58,1)] data-ending-style:[translate:-50%_calc(-50%+0.25rem+1.25rem*var(--nested-dialogs))] data-ending-style:scale-[0.96] data-ending-style:opacity-0 data-nested-dialog-open:after:opacity-100 data-starting-style:[translate:-50%_calc(-50%+0.25rem+1.25rem*var(--nested-dialogs))] data-starting-style:scale-[0.96] data-starting-style:opacity-0"
      >
        <div class="flex flex-col gap-1">
          <Dialog.Title class="text-base font-semibold">Edit plan</Dialog.Title>
          <Dialog.Description class="text-sm text-gray-600">
            Set your weekly goal and schedule.
          </Dialog.Description>
        </div>
        <div class="flex items-center justify-end gap-3">
          <Dialog.Root>
            <Dialog.Trigger
              class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
            >
              Manage reminders
            </Dialog.Trigger>
            <Dialog.Portal>
              <Dialog.Popup
                class="fixed top-1/2 left-1/2 -mt-8 flex w-96 max-w-[calc(100vw-3rem)] [translate:-50%_calc(-50%+1.25rem*var(--nested-dialogs))] scale-[calc(1-0.1*var(--nested-dialogs))] flex-col gap-4 rounded-lg bg-gray-50 p-4 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[translate,scale,opacity] duration-100 ease-[cubic-bezier(0,0,0.58,1)] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-black/5 after:opacity-0 after:transition-opacity after:duration-100 after:ease-[cubic-bezier(0,0,0.58,1)] data-ending-style:[translate:-50%_calc(-50%+0.25rem+1.25rem*var(--nested-dialogs))] data-ending-style:scale-[0.96] data-ending-style:opacity-0 data-nested-dialog-open:after:opacity-100 data-starting-style:[translate:-50%_calc(-50%+0.25rem+1.25rem*var(--nested-dialogs))] data-starting-style:scale-[0.96] data-starting-style:opacity-0"
              >
                <div class="flex flex-col gap-1">
                  <Dialog.Title class="text-base font-semibold">Reminders</Dialog.Title>
                  <Dialog.Description class="text-sm text-gray-600">
                    You have 2 daily reminders set.
                  </Dialog.Description>
                </div>
                <div class="flex items-center justify-end gap-3">
                  <Dialog.Close
                    class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
                  >
                    Close
                  </Dialog.Close>
                </div>
              </Dialog.Popup>
            </Dialog.Portal>
          </Dialog.Root>
        </div>
      </Dialog.Popup>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Close confirmation

A nested confirmation dialog guards against losing work: it opens when the text typed into the parent dialog is about to be discarded.

Veto the close by controlling open and deciding in the @update:open handler. When a close is requested the handler runs; if you don't commit the new value, the prop keeps the old one and the dialog stays open. Open the confirmation there instead, so the prompt appears whether the user clicks the backdrop, presses Esc, or hits a close button.

<template>
  <Dialog.Root
    :open="open"
    @update:open="
      (next) => {
        if (!next && hasUnsavedChanges) return // veto: don't commit, dialog stays open
        open = next
      }
    "
  >
    ...
  </Dialog.Root>
</template>
<script setup lang="ts">
import { AlertDialog } from '@shardsui/vue/alert-dialog'
import { Dialog } from '@shardsui/vue/dialog'
import { shallowRef, useId } from 'vue'

const dialogOpen = shallowRef(false)
const confirmationOpen = shallowRef(false)
const noteValue = shallowRef('')
const titleId = useId()

function requestOpenChange(open: boolean) {
  // Veto the close by not committing; prompt instead.
  if (!open && noteValue.value) {
    confirmationOpen.value = true
    return
  }
  noteValue.value = ''
  dialogOpen.value = open
}
</script>

<template>
  <Dialog.Root :open="dialogOpen" @update:open="requestOpenChange">
    <Dialog.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Add note
    </Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-20 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Dialog.Popup
        class="fixed top-[calc(50%+1.25rem*var(--nested-dialogs))] left-1/2 -mt-8 flex w-96 max-w-[calc(100vw-3rem)] -translate-1/2 scale-[calc(1-0.1*var(--nested-dialogs))] flex-col gap-1 rounded-lg bg-gray-50 p-4 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[top,scale,opacity] duration-100 ease-out after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-black/5 after:opacity-0 after:transition-opacity after:duration-100 after:ease-out data-ending-style:top-[calc(50%+0.25rem+1.25rem*var(--nested-dialogs))] data-ending-style:scale-[0.96] data-ending-style:opacity-0 data-nested-dialog-open:after:opacity-100 data-starting-style:top-[calc(50%+0.25rem+1.25rem*var(--nested-dialogs))] data-starting-style:scale-[0.96] data-starting-style:opacity-0"
      >
        <Dialog.Title :id="titleId" class="text-base font-semibold">Note</Dialog.Title>
        <form class="flex flex-col gap-4" @submit.prevent="dialogOpen = false">
          <textarea
            v-model="noteValue"
            :aria-labelledby="titleId"
            required
            class="min-h-32 w-full rounded-md border border-gray-200 p-2 text-sm font-normal text-gray-900 focus:outline-2 focus:-outline-offset-1 focus:outline-gray-950 any-pointer-coarse:text-base"
            placeholder="Capture a key takeaway…"
          ></textarea>
          <div class="flex justify-end gap-3">
            <Dialog.Close
              class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
            >
              Cancel
            </Dialog.Close>
            <button
              type="submit"
              class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
            >
              Save
            </button>
          </div>
        </form>
      </Dialog.Popup>
    </Dialog.Portal>

    <AlertDialog.Root v-model:open="confirmationOpen">
      <AlertDialog.Portal>
        <AlertDialog.Popup
          class="fixed top-[calc(50%+1.25rem*var(--nested-dialogs))] left-1/2 -mt-8 flex w-96 max-w-[calc(100vw-3rem)] -translate-1/2 scale-[calc(1-0.1*var(--nested-dialogs))] flex-col gap-1 rounded-lg bg-gray-50 p-4 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[top,scale,opacity] duration-100 ease-out after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-black/5 after:opacity-0 after:transition-opacity after:duration-100 after:ease-out data-ending-style:top-[calc(50%+0.25rem+1.25rem*var(--nested-dialogs))] data-ending-style:scale-[0.96] data-ending-style:opacity-0 data-nested-dialog-open:after:opacity-100 data-starting-style:top-[calc(50%+0.25rem+1.25rem*var(--nested-dialogs))] data-starting-style:scale-[0.96] data-starting-style:opacity-0"
        >
          <AlertDialog.Title class="mb-1 text-base font-semibold">Discard note?</AlertDialog.Title>
          <AlertDialog.Description class="mb-4 text-sm text-gray-600">
            Your draft will be lost.
          </AlertDialog.Description>
          <div class="flex items-center justify-end gap-3">
            <AlertDialog.Close
              class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
            >
              Go back
            </AlertDialog.Close>
            <button
              type="button"
              class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
              @click="
                () => {
                  confirmationOpen = false
                  dialogOpen = false
                }
              "
            >
              Discard
            </button>
          </div>
        </AlertDialog.Popup>
      </AlertDialog.Portal>
    </AlertDialog.Root>
  </Dialog.Root>
</template>

Custom focus management

Control where focus goes when the dialog opens and closes with the initialFocus and finalFocus props on <Dialog.Popup>.

<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'
import { shallowRef } from 'vue'

const initialFocus = shallowRef<HTMLElement | null>(null)
const finalFocus = shallowRef<HTMLElement | null>(null)
</script>

<template>
  <div class="flex flex-wrap justify-center gap-3">
    <Dialog.Root>
      <Dialog.Trigger
        class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
      >
        Open feedback
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Backdrop
          class="fixed inset-0 min-h-dvh bg-black opacity-20 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0"
        />
        <Dialog.Popup
          :initial-focus="() => initialFocus"
          :final-focus="() => finalFocus"
          class="fixed top-1/2 left-1/2 -mt-8 flex w-96 max-w-[calc(100vw-3rem)] -translate-1/2 flex-col gap-4 rounded-lg bg-gray-50 p-4 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[scale,opacity] duration-100 ease-out data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:scale-[0.98] data-starting-style:opacity-0"
        >
          <div class="flex flex-col gap-1">
            <Dialog.Title class="text-base font-semibold">Feedback form</Dialog.Title>
            <Dialog.Description class="text-sm text-gray-600">
              Focus starts on Feedback, and returns to the button beside the trigger.
            </Dialog.Description>
          </div>
          <label class="flex flex-col items-start gap-1 text-sm">
            Full name
            <input
              class="h-8 w-full rounded-md border border-gray-200 bg-gray-50 px-2 text-sm text-gray-900 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-1 focus:outline-gray-950"
              placeholder="Enter your name"
            />
          </label>
          <label class="flex flex-col items-start gap-1 text-sm">
            Feedback
            <input
              ref="initialFocus"
              class="h-8 w-full rounded-md border border-gray-200 bg-gray-50 px-2 text-sm text-gray-900 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-1 focus:outline-gray-950"
              placeholder="Enter your feedback"
            />
          </label>
          <div class="flex justify-end">
            <Dialog.Close
              class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
            >
              Close
            </Dialog.Close>
          </div>
        </Dialog.Popup>
      </Dialog.Portal>
    </Dialog.Root>
    <button
      ref="finalFocus"
      type="button"
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Final focus
    </button>
  </div>
</template>

Outside scroll dialog

For long content, make <Dialog.Viewport> the outer scrollable container and let <Dialog.Popup> extend past the bottom edge. The scrollable area draws custom scrollbars with the Scroll Area component.

<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'
import { ScrollArea } from '@shardsui/vue/scroll-area'
import { shallowRef } from 'vue'

const SECTIONS = [
  {
    title: '1. Semantic HTML',
    body: 'Reach for the element that already does the job: a button gives you keyboard events, focus, and role for free, where a styled div makes you rebuild all three.'
  },
  {
    title: '2. CSS architecture',
    body: 'Organize styles with cascade layers and clear naming so a growing codebase stays predictable instead of turning into a pile of overrides.'
  },
  {
    title: '3. The box model',
    body: 'Master margin, border, padding, and box-sizing, then nest an inner border radius as outer minus padding so corners line up cleanly.'
  },
  {
    title: '4. Flexbox',
    body: 'Arrange elements in a row or column, controlling direction, alignment, gap, and how items grow or shrink to fill the space.'
  },
  {
    title: '5. CSS grid',
    body: 'Build two-dimensional layouts on twelve columns with named areas that reflow cleanly from a phone to a wide desktop.'
  },
  {
    title: '6. Design tokens',
    body: 'Store color, type, and spacing decisions as semantic tokens — --color-border-subtle, not #e0e0e0 — so a theme change is one edit, not a hundred.'
  },
  {
    title: '7. Type scale',
    body: 'Pick sizes from a type scale, set leading that lets text breathe, and hold line length near 65 characters for a comfortable read.'
  },
  {
    title: '8. Contrast ratio',
    body: 'Build a color ramp in OKLCH and check every foreground and background pair against WCAG: 4.5:1 for body text, 3:1 for large text and UI.'
  },
  {
    title: '9. Responsive design',
    body: 'Design fluid layouts with clamp() type and set breakpoints where the content actually breaks, not at assumed device widths.'
  },
  {
    title: '10. Easing',
    body: 'Use ease-out for elements entering the screen and ease-in for ones leaving it, and honor reduced motion for anyone who prefers less movement.'
  },
  {
    title: '11. Focus states',
    body: 'Add a visible focus state, a logical tab order, and an aria-label that names the action, so custom widgets behave like the native ones they replace.'
  },
  {
    title: '12. Design handoff',
    body: 'Package tokens, states, and specs so the jump from Figma to production loses nothing in translation.'
  }
]

const popup = shallowRef<InstanceType<typeof Dialog.Popup> | null>(null)
</script>

<template>
  <Dialog.Root>
    <Dialog.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      View details
    </Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Backdrop
        class="data-ending-style:backdrop-blur-0 data-starting-style:backdrop-blur-0 fixed inset-0 bg-black/10 opacity-100 backdrop-blur-xs transition-[backdrop-filter,opacity] duration-600 ease-(--ease-out-fast) data-ending-style:opacity-0 data-ending-style:duration-350 data-ending-style:ease-[cubic-bezier(0.375,0.015,0.545,0.455)] data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Dialog.Viewport class="group/dialog fixed inset-0">
        <ScrollArea.Root
          class="h-full overscroll-contain group-data-ending-style/dialog:pointer-events-none"
        >
          <ScrollArea.Viewport
            class="h-full overscroll-contain group-data-ending-style/dialog:pointer-events-none"
          >
            <ScrollArea.Content class="flex min-h-full items-center justify-center">
              <Dialog.Popup
                ref="popup"
                :initial-focus="() => popup?.$el ?? true"
                class="relative mx-auto my-18 w-[min(40rem,calc(100vw-2rem))] rounded-lg bg-gray-50 p-6 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-transform duration-700 ease-[cubic-bezier(0.45,1.005,0,1.005)] data-ending-style:translate-y-[max(100dvh,100%)] data-ending-style:duration-350 data-ending-style:ease-[cubic-bezier(0.375,0.015,0.545,0.455)] data-starting-style:translate-y-[100dvh] motion-reduce:transition-none"
              >
                <div class="mb-4 flex items-start justify-between gap-3">
                  <Dialog.Title class="m-0 text-base font-semibold">Details</Dialog.Title>
                  <Dialog.Close
                    aria-label="Close"
                    class="relative -top-2 -right-2 flex size-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
                  >
                    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
                      <path
                        d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75"
                        stroke="currentColor"
                        stroke-width="1.5"
                        stroke-linecap="round"
                      />
                    </svg>
                  </Dialog.Close>
                </div>

                <Dialog.Description class="m-0 mb-4 text-sm/[1.6rem] text-gray-600">
                  Everything covered in this overview.
                </Dialog.Description>

                <div class="flex flex-col gap-4">
                  <section v-for="item in SECTIONS" :key="item.title">
                    <h3 class="m-0 mb-1.5 text-sm/6 font-semibold">{{ item.title }}</h3>
                    <p class="m-0 text-sm/[1.55rem] text-gray-700">{{ item.body }}</p>
                  </section>
                </div>
              </Dialog.Popup>
            </ScrollArea.Content>
          </ScrollArea.Viewport>
          <ScrollArea.Scrollbar
            class="pointer-events-none absolute m-1.5 flex w-1 justify-center rounded-lg opacity-0 transition-opacity duration-250 group-data-ending-style/dialog:opacity-0 group-data-ending-style/dialog:duration-300 hover:pointer-events-auto hover:opacity-100 hover:delay-0 hover:duration-75 data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:delay-0 data-scrolling:duration-75 md:w-1.75"
          >
            <ScrollArea.Thumb
              class="w-full rounded-[inherit] bg-gray-500 before:absolute before:top-1/2 before:left-1/2 before:size-[calc(100%+1rem)] before:-translate-1/2 before:content-['']"
            />
          </ScrollArea.Scrollbar>
        </ScrollArea.Root>
      </Dialog.Viewport>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Inside scroll dialog

Here the popup stays fully on screen and an inner container scrolls instead. <Dialog.Viewport> positions <Dialog.Popup>, and the inner scrollable area is built with the Scroll Area component.

<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'
import { ScrollArea } from '@shardsui/vue/scroll-area'

const SECTIONS = [
  {
    title: 'Kerning',
    body: 'The space between two specific characters, adjusted manually — different from tracking, which affects all characters equally.'
  },
  {
    title: 'Tracking',
    body: 'Letter-spacing applied uniformly across a word or block of text. Uppercase labels almost always need more of it.'
  },
  {
    title: 'Leading',
    body: 'The vertical space between lines of text. Too tight and text suffocates; too loose and it stops reading as a paragraph.'
  },
  {
    title: 'Type scale',
    body: 'A predetermined set of font sizes that work together, usually based on a ratio, so hierarchy stays consistent.'
  },
  {
    title: 'Hierarchy',
    body: 'The ranking of elements by importance. Without it, everything competes and nothing wins.'
  },
  {
    title: 'Negative space',
    body: 'The empty area around and between elements. It defines shape, creates breathing room, and guides the eye.'
  },
  {
    title: 'Gap',
    body: 'Space between flex or grid children, set on the parent. Unlike margin, it leaves no trailing space after the last item.'
  },
  {
    title: 'Baseline grid',
    body: 'A horizontal rhythm built from the line-height of body text, keeping editorial layouts consistent.'
  },
  {
    title: 'Border radius',
    body: 'The rounding on a corner. An inner element needs a smaller radius — outer radius minus padding — or a gap appears.'
  },
  {
    title: 'Contrast ratio',
    body: 'The luminance difference between foreground and background. WCAG asks for 4.5:1 on body text, 3:1 on large text and UI.'
  },
  {
    title: 'Easing',
    body: 'The rate an animation speeds up or slows down. Ease-out decelerates into place and feels natural.'
  },
  {
    title: 'Focus state',
    body: 'The visible indicator that an element is keyboard-focused. Removing it is an accessibility failure.'
  },
  {
    title: 'Affordance',
    body: 'The visual signal that tells you how something can be used. A button looks pressable; a link looks clickable.'
  },
  {
    title: 'Optical centre',
    body: 'Where something looks centred versus where it mathematically is — a play button centred by coordinates looks left-heavy.'
  }
]
</script>

<template>
  <Dialog.Root>
    <Dialog.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Open glossary
    </Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Backdrop
        class="fixed inset-0 bg-black opacity-20 transition-opacity duration-250 ease-[cubic-bezier(0.45,1.005,0,1.005)] data-ending-style:opacity-0 data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Dialog.Viewport
        class="fixed inset-0 flex items-center justify-center overflow-hidden py-6 [@media(min-height:600px)]:pt-8 [@media(min-height:600px)]:pb-12"
      >
        <Dialog.Popup
          class="relative flex max-h-full min-h-0 w-[min(40rem,calc(100vw-2rem))] max-w-full flex-col overflow-hidden rounded-lg bg-gray-50 p-8 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[scale,opacity] duration-100 ease-out data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:scale-[0.98] data-starting-style:opacity-0"
        >
          <Dialog.Title class="m-0 text-base font-semibold">Design glossary</Dialog.Title>
          <Dialog.Description class="m-0 mb-4 text-sm/[1.6rem] text-gray-600">
            Key terms, briefly defined.
          </Dialog.Description>
          <ScrollArea.Root
            class="relative flex min-h-0 flex-1 overflow-hidden before:absolute before:top-0 before:h-px before:w-full before:bg-gray-200 before:content-[''] after:absolute after:bottom-0 after:h-px after:w-full after:bg-gray-200 after:content-['']"
          >
            <ScrollArea.Viewport
              class="min-h-0 flex-1 overflow-y-auto overscroll-contain py-6 pr-6 pl-1 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-gray-950"
            >
              <ScrollArea.Content class="flex flex-col gap-4">
                <section v-for="item in SECTIONS" :key="item.title">
                  <h3 class="mb-1.5 text-sm/6 font-semibold">{{ item.title }}</h3>
                  <p class="m-0 text-sm/[1.55rem] text-gray-700">{{ item.body }}</p>
                </section>
              </ScrollArea.Content>
            </ScrollArea.Viewport>
            <ScrollArea.Scrollbar
              class="pointer-events-none absolute m-1 flex w-1 justify-center rounded-lg opacity-0 transition-opacity duration-250 data-hovering:pointer-events-auto data-hovering:opacity-100 data-hovering:duration-75 data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:duration-75 md:w-1.5"
            >
              <ScrollArea.Thumb
                class="w-full rounded-[inherit] bg-gray-500 before:absolute before:top-1/2 before:left-1/2 before:size-[calc(100%+1rem)] before:-translate-1/2 before:content-['']"
              />
            </ScrollArea.Scrollbar>
          </ScrollArea.Root>
          <div class="mt-4 flex justify-end">
            <Dialog.Close
              class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
            >
              Close
            </Dialog.Close>
          </div>
        </Dialog.Popup>
      </Dialog.Viewport>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Placing elements outside the popup

To place elements "outside" the colored popup area, still render them inside <Dialog.Popup> and move the popup styles onto a child element. This preserves tab order and correct screen-reader announcements.

<Dialog.Popup> uses pointer-events: none while its inner content — the colored popup and close button — uses pointer-events: auto, so backdrop clicks still register.

<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'
</script>

<template>
  <Dialog.Root>
    <Dialog.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      View slides
    </Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-70 backdrop-blur-[2px] transition-[opacity,backdrop-filter] duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Dialog.Viewport class="fixed inset-0 grid place-items-center px-4 py-12 xl:py-6">
        <Dialog.Popup
          class="group/popup pointer-events-none flex size-full justify-center transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0"
        >
          <Dialog.Close
            class="pointer-events-auto absolute top-2 right-3 flex size-8 items-center justify-center rounded-md border-0 bg-transparent text-gray-50 hover:bg-gray-50/10 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-50 xl:top-3 xl:right-3"
            aria-label="Close"
          >
            <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
              <path
                d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75"
                stroke="currentColor"
                stroke-width="1.5"
                stroke-linecap="round"
              />
            </svg>
          </Dialog.Close>
          <div
            class="pointer-events-auto box-border flex size-full max-w-280 items-center justify-center rounded-lg bg-gray-50 p-4 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-transform duration-500 ease-out-quint group-data-starting-style/popup:scale-110"
          >
            <Dialog.Title class="sr-only">Slides</Dialog.Title>
          </div>
        </Dialog.Popup>
      </Dialog.Viewport>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Detached triggers

Keep <Dialog.Trigger> inside the root, as in the example at the top of this page. When the trigger and the dialog's content can't sit together in the markup, detach them: connect the trigger to a <Dialog.Root> with a shared handle from Dialog.createHandle(), with no shared open state needed.

<!-- [!code word::handle="myDialog"] -->
<script setup>
const myDialog = Dialog.createHandle()
</script>

<template>
  <Dialog.Trigger :handle="myDialog">Open</Dialog.Trigger>

  <Dialog.Root :handle="myDialog">...</Dialog.Root>
</template>
<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'

const activity = Dialog.createHandle()
</script>

<template>
  <Dialog.Trigger
    class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    :handle="activity"
  >
    Activity
  </Dialog.Trigger>

  <Dialog.Root :handle="activity">
    <Dialog.Portal>
      <Dialog.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-20 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Dialog.Popup
        class="fixed top-1/2 left-1/2 -mt-8 w-96 max-w-[calc(100vw-3rem)] -translate-1/2 rounded-lg bg-gray-50 p-4 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[scale,opacity] duration-100 ease-out data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:scale-[0.98] data-starting-style:opacity-0"
      >
        <Dialog.Title class="mb-1 text-base font-semibold">Activity</Dialog.Title>
        <Dialog.Description class="mb-4 text-sm text-gray-600">
          12 days active this month.
        </Dialog.Description>
        <div class="flex justify-end gap-3">
          <Dialog.Close
            class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
          >
            Close
          </Dialog.Close>
        </div>
      </Dialog.Popup>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Multiple triggers

Several triggers can open the same dialog. Share one handle across detached triggers, or drop multiple <Dialog.Trigger> components inside a single <Dialog.Root>.

<template>
  <Dialog.Root>
    <Dialog.Trigger>Trigger 1</Dialog.Trigger>
    <Dialog.Trigger>Trigger 2</Dialog.Trigger>
    ...
  </Dialog.Root>
</template>
<script setup>
const demoDialog = Dialog.createHandle()
</script>

<template>
  <Dialog.Trigger :handle="demoDialog">Trigger 1</Dialog.Trigger>
  <Dialog.Trigger :handle="demoDialog">Trigger 2</Dialog.Trigger>
  <Dialog.Root :handle="demoDialog">...</Dialog.Root>
</template>

To show different content depending on which trigger opened the dialog, pass a payload to each <Dialog.Trigger> and read it through the default slot on <Dialog.Root>. Give Dialog.createHandle() a type argument to type the payload:

<script setup lang="ts">
const demoDialog = Dialog.createHandle<{ text: string }>()
</script>

<template>
  <Dialog.Trigger :handle="demoDialog" :payload="{ text: 'Trigger 1' }">Trigger 1</Dialog.Trigger>

  <Dialog.Trigger :handle="demoDialog" :payload="{ text: 'Trigger 2' }">Trigger 2</Dialog.Trigger>

  <Dialog.Root v-slot="{ payload }" :handle="demoDialog">
    <Dialog.Portal>
      <Dialog.Popup>
        <Dialog.Title>Dialog</Dialog.Title>
        <Dialog.Description v-if="payload !== undefined">
          This has been opened by {{ payload.text }}
        </Dialog.Description>
      </Dialog.Popup>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Controlled mode with multiple triggers

With multiple triggers, track the active one with v-model:trigger-id on <Dialog.Root> and the id prop on each <Dialog.Trigger>. The dialog writes back the id of the trigger that opened it.

<script setup lang="ts">
import { Dialog } from '@shardsui/vue/dialog'
import { shallowRef } from 'vue'

const itemDialog = Dialog.createHandle<string>()

const open = shallowRef(false)
const triggerId = shallowRef<string | null>(null)

const items = [
  { id: 'item-design-systems', payload: 'Design Systems' },
  { id: 'item-motion', payload: 'Motion' }
]
</script>

<template>
  <div class="flex flex-wrap justify-center gap-2">
    <Dialog.Trigger
      v-for="item in items"
      :key="item.id"
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
      :handle="itemDialog"
      :id="item.id"
      :payload="item.payload"
    >
      {{ item.payload }}
    </Dialog.Trigger>

    <button
      type="button"
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
      @click="
        () => {
          triggerId = 'item-motion'
          open = true
        }
      "
    >
      Open Motion
    </button>
  </div>

  <Dialog.Root
    v-slot="{ payload }"
    v-model:open="open"
    v-model:trigger-id="triggerId"
    :handle="itemDialog"
    @update:open="(isOpen) => !isOpen && (triggerId = null)"
  >
    <Dialog.Portal>
      <Dialog.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-20 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Dialog.Popup
        class="fixed top-1/2 left-1/2 -mt-8 w-96 max-w-[calc(100vw-3rem)] -translate-1/2 rounded-lg bg-gray-50 p-4 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[scale,opacity] duration-100 ease-out data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:scale-[0.98] data-starting-style:opacity-0"
      >
        <Dialog.Title class="mb-1 text-base font-semibold">{{ payload }}</Dialog.Title>
        <Dialog.Description class="mb-4 text-sm text-gray-600">
          Opened from the {{ payload }} trigger.
        </Dialog.Description>
        <div class="flex justify-end gap-3">
          <Dialog.Close
            class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
          >
            Close
          </Dialog.Close>
        </div>
      </Dialog.Popup>
    </Dialog.Portal>
  </Dialog.Root>
</template>

API reference

Root

Groups all parts of the dialog. Doesn't render its own HTML element.

PropTypeDefault

Trigger

A button that opens the dialog. Renders a <button> element.

PropTypeDefault
AttributeDescription
data-popup-openPresent while the dialog is open from this trigger.
data-disabledPresent when the trigger is disabled.

Portal

A portal that moves the popup out to <body>, clear of ancestor clipping and stacking. Renders a <div> element.

PropTypeDefault

Backdrop

An overlay displayed beneath the popup. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the dialog is open.
data-closedPresent when the dialog is closed.
data-nestedPresent when the dialog is nested within another dialog.
data-nested-dialog-openPresent when the dialog has other open dialogs nested within it.
data-starting-stylePresent when the backdrop is animating in.
data-ending-stylePresent when the backdrop is animating out.

Viewport

A positioning container for the dialog popup that can be made scrollable. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the dialog is open.
data-closedPresent when the dialog is closed.
data-nestedPresent when the dialog is nested within another dialog.
data-nested-dialog-openPresent when the dialog has other open dialogs nested within it.
data-starting-stylePresent when the dialog is animating in.
data-ending-stylePresent when the dialog is animating out.

Popup

A container for the dialog contents. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the dialog is open.
data-closedPresent when the dialog is closed.
data-nestedPresent when the dialog is nested within another dialog.
data-nested-dialog-openPresent when the dialog has other open dialogs nested within it.
data-starting-stylePresent when the dialog is animating in.
data-ending-stylePresent when the dialog is animating out.
CSS VariableDescription
--nested-dialogsIndicates how many dialogs are nested within.

Title

A heading that labels the dialog. Renders an <h2> element.

PropTypeDefault

Description

A paragraph with additional information about the dialog. Renders a <p> element.

PropTypeDefault

Close

A button that closes the dialog. Renders a <button> element.

PropTypeDefault
AttributeDescription
data-disabledPresent when the button is disabled.

Handle

Connects a <Dialog.Root> with detached <Dialog.Trigger> components, and controls the dialog imperatively. Pass a type argument to type the payload.

const dialog = Dialog.createHandle<Payload>()
MemberType