Skip to content
Seamless UI

Dialog

Save

A modal window built on Base UI Dialog, with a backdrop, focus trap, a footer action band, and a 220ms enter and faster 150ms exit.

Category
Core
Version
0.2.0
Frameworks
Next.js, Vite + React, Vite + Vue
Styling
Tailwind CSS v4

Preview

Dialog preview

Installation

npx @seamlessui/cli add dialog

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add dialog --dry-run

Usage

dialog-example.tsx
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/seamless/ui/dialog";

// Same recipe as the Button component, inlined so this example has no other dependency.
const button =
  "inline-flex h-9 items-center justify-center rounded-lg px-3.5 text-sm font-medium select-none " +
  "transition-[background-color,border-color,scale] duration-150 ease-out active:duration-100 motion-safe:active:scale-[0.97] motion-reduce:transition-none " +
  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500";
const primary =
  `${button} bg-zinc-950 text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.12),0_1px_2px_rgb(0_0_0/0.2)] hover:bg-zinc-800 ` +
  "dark:bg-white dark:text-black dark:shadow-[inset_0_-1px_0_rgb(0_0_0/0.12)] dark:hover:bg-zinc-200";
const secondary =
  `${button} border border-zinc-950/12 bg-white text-zinc-950 hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] ` +
  "dark:border-white/15 dark:bg-black dark:text-white dark:hover:border-white/25 dark:hover:bg-white/[0.06]";

export default function DialogExample() {
  return (
    <Dialog>
      <DialogTrigger className={secondary}>Rename workspace</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Rename workspace</DialogTitle>
          <DialogDescription>Everyone on your team will see the new name.</DialogDescription>
        </DialogHeader>
        <label className="grid gap-1.5 text-sm font-medium">
          Name
          <input
            defaultValue="Acme"
            className={
              "h-9 rounded-lg border border-zinc-950/15 bg-white px-3 text-sm font-normal text-zinc-950 " +
              "transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none " +
              "focus-visible:border-sky-500 focus-visible:ring-3 focus-visible:ring-sky-500/20 focus-visible:outline-none " +
              "dark:border-white/15 dark:bg-black dark:text-white"
            }
          />
        </label>
        <DialogFooter>
          <DialogClose className={secondary}>Cancel</DialogClose>
          <DialogClose className={primary}>Save</DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

API

PropTypeDefaultDescription
Dialog open / defaultOpen / onOpenChangeboolean / boolean / (open, details) => void—Root state. Leave open unset for an uncontrolled dialog.
Dialog modalboolean | "trap-focus"trueTraps focus, locks page scroll, and blocks outside pointer interaction.
Dialog disablePointerDismissalbooleanfalseKeeps the dialog open when the backdrop is clicked.
DialogTriggerBase UI Dialog.Trigger props—Unstyled button that opens the dialog. Pass className, or render={<Button />} to reuse your own button.
DialogContent size"sm" | "md" | "lg""md"Maximum width of the panel.
DialogContent showCloseButtonbooleantrueRenders a corner icon button that closes the dialog.
DialogContent closeLabelstring"Close"Accessible name of the corner close button.
DialogContent initialFocus / finalFocusboolean | RefObject | function—Overrides where focus goes on open and on close. Defaults to the first tabbable element and the trigger.
DialogContent backdropClassNamestring—Classes for the backdrop.
DialogTitle / DialogDescriptionBase UI Dialog.Title / Dialog.Description props—Heading and supporting text, wired to aria-labelledby and aria-describedby.
DialogHeader / DialogFooterComponentProps<"div">—Layout wrappers for the title block and the action row. DialogFooter sits in a band under a divider at the bottom of the panel and assumes the default p-6 padding.
DialogCloseBase UI Dialog.Close props—Unstyled button that closes the dialog, for Cancel or Done actions.

Accessibility

  • The panel has role="dialog" and aria-modal, and is named by DialogTitle and described by DialogDescription.
  • Opening moves focus into the dialog, Tab is trapped inside it, and closing returns focus to the trigger.
  • Escape and a click on the backdrop close the dialog; page scroll is locked while it is open.
  • The corner close button has an accessible name (closeLabel) and a 32px target; its icon is hidden from screen readers.
  • With reduced motion the fade, scale, and slide transitions are removed and the dialog appears and disappears immediately.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/ui/dialog.tsxdialog
  • npm packages: @base-ui/react@^1.9.0React and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/dialog/0.2.0.json.

ui/dialog.tsxShow
ui/dialog.tsx
"use client";

import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import type { ComponentProps } from "react";

type ClassName<State> = string | ((state: State) => string | undefined) | undefined;

function cx(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(" ");
}

/** Prepends base classes while keeping Base UI's state-function form of className working. */
function withBase<State>(base: string, className: ClassName<State>) {
  return typeof className === "function" ? (state: State) => cx(base, className(state)) : cx(base, className);
}

/** Groups the dialog parts and holds its open state. Pass `open`/`onOpenChange` to control it. */
export const Dialog = BaseDialog.Root;

/** A button that opens the dialog. Unstyled; pass `className`, or `render={<Button />}` to reuse your button. */
export const DialogTrigger = BaseDialog.Trigger;

/** A button that closes the dialog. Unstyled; pass `className` or `render`. */
export const DialogClose = BaseDialog.Close;

const sizes = {
  sm: "max-w-sm",
  md: "max-w-md",
  lg: "max-w-xl",
} as const;

export type DialogSize = keyof typeof sizes;

export interface DialogContentProps extends BaseDialog.Popup.Props {
  /** Maximum width of the panel. */
  size?: DialogSize;
  /** Renders an icon button in the corner that closes the dialog. */
  showCloseButton?: boolean;
  /** Accessible name of the corner close button. */
  closeLabel?: string;
  /** Classes for the backdrop behind the panel. */
  backdropClassName?: string;
  /** Element to portal into. Defaults to `document.body`. */
  container?: BaseDialog.Portal.Props["container"];
}

/** The dialog panel, rendered in a portal above a backdrop. */
export function DialogContent({
  size = "md",
  showCloseButton = true,
  closeLabel = "Close",
  backdropClassName,
  container,
  className,
  children,
  ...props
}: DialogContentProps) {
  return (
    <BaseDialog.Portal container={container}>
      <BaseDialog.Backdrop
        className={cx(
          "fixed inset-0 z-50 bg-black/40 dark:bg-black/70",
          "transition-opacity duration-220 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
          "data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[ending-style]:duration-150",
          backdropClassName,
        )}
      />
      <BaseDialog.Popup
        className={withBase(
          cx(
            // Centered with inset + auto margins so `translate` stays free for the motion.
            "fixed inset-0 z-50 m-auto grid h-fit max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] gap-5 overflow-y-auto overscroll-contain p-6",
            "rounded-2xl border border-zinc-950/10 bg-white text-zinc-950 shadow-[0_24px_64px_-16px_rgb(0_0_0/0.28),0_2px_6px_rgb(0_0_0/0.04)]",
            "dark:border-white/15 dark:bg-black dark:text-white dark:shadow-[0_24px_64px_-12px_rgb(0_0_0/1),inset_0_1px_0_rgb(255_255_255/0.08)]",
            "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
            // Enter: 220ms quint ease-out from 96% and 8px low. Exit: 150ms, a smaller move.
            "transition-[opacity,scale,translate] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
            "data-[starting-style]:opacity-0 motion-safe:data-[starting-style]:translate-y-2 motion-safe:data-[starting-style]:scale-96",
            "data-[ending-style]:opacity-0 data-[ending-style]:duration-150 motion-safe:data-[ending-style]:translate-y-1 motion-safe:data-[ending-style]:scale-98",
            // A dialog opened on top of this one pushes it back slightly.
            "motion-safe:data-[nested-dialog-open]:scale-[0.97] data-[nested-dialog-open]:opacity-60",
            sizes[size],
          ),
          className,
        )}
        {...props}
      >
        {children}
        {showCloseButton ? (
          <BaseDialog.Close
            aria-label={closeLabel}
            className={cx(
              "absolute top-4 right-4 inline-flex size-8 items-center justify-center rounded-lg text-zinc-500",
              "transition-[background-color,color,scale] duration-150 ease-out active:duration-100 motion-reduce:transition-none",
              "hover:bg-zinc-950/[0.05] hover:text-zinc-950 motion-safe:active:scale-[0.94]",
              "dark:hover:bg-white/[0.08] dark:hover:text-white",
              "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
            )}
          >
            <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" className="size-4">
              <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
            </svg>
          </BaseDialog.Close>
        ) : null}
      </BaseDialog.Popup>
    </BaseDialog.Portal>
  );
}

/** Wraps the title and description. Leaves room for the corner close button. */
export function DialogHeader({ className, ...props }: ComponentProps<"div">) {
  return <div className={cx("grid gap-1.5 pr-10", className)} {...props} />;
}

/**
 * Holds the actions, right-aligned on wider screens and stacked on narrow ones. Sits in a band
 * under a divider at the bottom of the panel; it assumes the panel's default `p-6` padding.
 */
export function DialogFooter({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      className={cx(
        "-mx-6 -mb-6 mt-1 flex flex-col-reverse gap-2 border-t border-zinc-950/[0.07] bg-zinc-950/[0.015] px-6 py-4 sm:flex-row sm:justify-end",
        "dark:border-white/[0.08] dark:bg-transparent",
        className,
      )}
      {...props}
    />
  );
}

/** The dialog's heading. Labels the dialog with aria-labelledby. */
export function DialogTitle({ className, ...props }: BaseDialog.Title.Props) {
  return <BaseDialog.Title className={withBase("text-base leading-6 font-semibold tracking-[-0.01em]", className)} {...props} />;
}

/** Supporting text. Describes the dialog with aria-describedby. */
export function DialogDescription({ className, ...props }: BaseDialog.Description.Props) {
  return (
    <BaseDialog.Description className={withBase("text-sm leading-relaxed text-zinc-600 dark:text-zinc-400", className)} {...props} />
  );
}

Vue

A native Vue 3 implementation ships in the Vue pilot. Run the same command in a Vite + Vue project; the CLI detects Vue and installs these files instead. The live preview above is the React version; the Vue example below is built in a clean Vite + Vue app by CI.

dialog-example.vue
<script setup lang="ts">
import { ref } from "vue";
import Dialog from "@/components/seamless/ui/Dialog.vue";

const open = ref(false);
const name = ref("Acme");
</script>

<template>
  <button
    type="button"
    class="inline-flex h-9 items-center justify-center rounded-lg border border-zinc-950/12 bg-white px-3.5 text-sm font-medium text-zinc-950 transition-colors duration-150 hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 motion-reduce:transition-none dark:border-white/15 dark:bg-black dark:text-white dark:hover:border-white/25 dark:hover:bg-white/[0.06]"
    @click="open = true"
  >
    Rename workspace
  </button>
  <Dialog v-model:open="open" title="Rename workspace" description="Everyone on your team will see the new name.">
    <label class="grid gap-1.5 text-sm font-medium">
      Name
      <input v-model="name" class="h-9 rounded-lg border border-zinc-300 bg-white px-3 font-normal focus-visible:border-sky-500 focus-visible:ring-3 focus-visible:ring-sky-500/20 focus-visible:outline-none dark:border-zinc-700 dark:bg-black" />
    </label>
    <template #footer="{ close }">
      <button type="button" class="inline-flex h-9 items-center justify-center rounded-lg border border-zinc-950/12 bg-white px-3.5 text-sm font-medium text-zinc-950 transition-colors duration-150 hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 motion-reduce:transition-none dark:border-white/15 dark:bg-black dark:text-white dark:hover:border-white/25 dark:hover:bg-white/[0.06]" @click="close">Cancel</button>
      <button type="button" class="inline-flex h-9 items-center justify-center rounded-lg bg-zinc-950 px-3.5 text-sm font-medium text-white transition-colors duration-150 hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 motion-reduce:transition-none dark:bg-white dark:text-black dark:hover:bg-zinc-200" @click="close">Save</button>
    </template>
  </Dialog>
</template>
  • src/components/seamless/ui/Dialog.vuevue/dialog@0.2.0
ui/Dialog.vueShow
ui/Dialog.vue
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, useId, watch } from "vue";

/**
 * A modal dialog built on the native <dialog> element: the page behind it is inert,
 * Escape closes it, and focus returns to the element that opened it.
 */
const props = withDefaults(
  defineProps<{
    title: string;
    description?: string;
    /** Accessible label for the close button. */
    closeLabel?: string;
    /** Closes when the backdrop is clicked. */
    closeOnBackdrop?: boolean;
  }>(),
  { closeLabel: "Close", closeOnBackdrop: true },
);

const open = defineModel<boolean>("open", { default: false });
const dialog = ref<HTMLDialogElement | null>(null);
const id = useId();
let returnFocus: HTMLElement | null = null;

watch(
  open,
  async (isOpen) => {
    await nextTick();
    const element = dialog.value;
    if (!element) return;
    if (isOpen && !element.open) {
      returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
      element.showModal();
    } else if (!isOpen && element.open) {
      element.close();
    }
  },
  { flush: "post" },
);

// Fires for Escape, the close button, and backdrop clicks.
function onClose() {
  open.value = false;
  returnFocus?.focus();
  returnFocus = null;
}

function onBackdropClick(event: MouseEvent) {
  if (props.closeOnBackdrop && event.target === dialog.value) dialog.value?.close();
}

onBeforeUnmount(() => dialog.value?.open && dialog.value.close());
</script>

<template>
  <dialog
    ref="dialog"
    :aria-labelledby="`${id}-title`"
    :aria-describedby="props.description ? `${id}-description` : undefined"
    :class="[
      'm-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-md overflow-y-auto overscroll-contain rounded-2xl p-0',
      'border border-zinc-950/10 bg-white text-zinc-950 shadow-[0_24px_64px_-16px_rgb(0_0_0/0.28),0_2px_6px_rgb(0_0_0/0.04)]',
      'dark:border-white/15 dark:bg-black dark:text-white dark:shadow-[0_24px_64px_-12px_rgb(0_0_0/1),inset_0_1px_0_rgb(255_255_255/0.08)]',
      'backdrop:bg-black/40 dark:backdrop:bg-black/70',
      // Enter: 220ms quint ease-out from 96% and 8px low. The native element closes instantly.
      'motion-safe:transition-[opacity,scale,translate] motion-safe:duration-220 motion-safe:ease-[cubic-bezier(0.22,1,0.36,1)]',
      'motion-safe:starting:open:translate-y-2 motion-safe:starting:open:scale-96 motion-safe:starting:open:opacity-0',
    ]"
    @close="onClose"
    @click="onBackdropClick"
  >
    <div class="p-6">
      <div class="flex items-start justify-between gap-4">
        <h2 :id="`${id}-title`" class="text-base leading-6 font-semibold tracking-[-0.01em]">{{ props.title }}</h2>
        <button
          type="button"
          :aria-label="props.closeLabel"
          class="-mt-1 -mr-2 inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-zinc-500 transition-[background-color,color,scale] duration-150 ease-out active:duration-100 hover:bg-zinc-950/[0.05] hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 motion-safe:active:scale-[0.94] motion-reduce:transition-none dark:hover:bg-white/[0.08] dark:hover:text-white"
          @click="dialog?.close()"
        >
          <svg aria-hidden="true" viewBox="0 0 16 16" class="size-4">
            <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
          </svg>
        </button>
      </div>
      <p v-if="props.description" :id="`${id}-description`" class="mt-1.5 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">{{ props.description }}</p>
      <div class="mt-5"><slot /></div>
    </div>
    <div
      v-if="$slots.footer"
      class="flex flex-col-reverse gap-2 border-t border-zinc-950/[0.07] bg-zinc-950/[0.015] px-6 py-4 sm:flex-row sm:justify-end dark:border-white/[0.08] dark:bg-transparent"
    >
      <slot name="footer" :close="() => dialog?.close()" />
    </div>
  </dialog>
</template>