Dialog
SaveA 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 dialogPre-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-runUsage
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
| Prop | Type | Default | Description |
|---|---|---|---|
| Dialog open / defaultOpen / onOpenChange | boolean / boolean / (open, details) => void | — | Root state. Leave open unset for an uncontrolled dialog. |
| Dialog modal | boolean | "trap-focus" | true | Traps focus, locks page scroll, and blocks outside pointer interaction. |
| Dialog disablePointerDismissal | boolean | false | Keeps the dialog open when the backdrop is clicked. |
| DialogTrigger | Base 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 showCloseButton | boolean | true | Renders a corner icon button that closes the dialog. |
| DialogContent closeLabel | string | "Close" | Accessible name of the corner close button. |
| DialogContent initialFocus / finalFocus | boolean | RefObject | function | — | Overrides where focus goes on open and on close. Defaults to the first tabbable element and the trigger. |
| DialogContent backdropClassName | string | — | Classes for the backdrop. |
| DialogTitle / DialogDescription | Base UI Dialog.Title / Dialog.Description props | — | Heading and supporting text, wired to aria-labelledby and aria-describedby. |
| DialogHeader / DialogFooter | ComponentProps<"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. |
| DialogClose | Base 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.tsxShowHide
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.vueShowHide
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>