Containment
Dialog
Modal dialog with focus trapping and spring enter/exit transitions.
Dialogs interrupt to ask for a decision or show critical information. Keep them short: a title, one or two sentences, and at most two actions.
Installation
npx @grenishrai/expressive add dialogThis copies dialog.tsx into your components folder, along with what it needs from Expressive (utils, motion, styles), and installs @base-ui/react.
Usage
import {
Dialog, DialogActions, DialogClose, DialogContent,
DialogDescription, DialogTitle, DialogTrigger,
} from "@/components/expressive/dialog";
<Dialog>
<DialogTrigger render={<Button variant="tonal" />}>Reset settings</DialogTrigger>
<DialogContent>
<DialogTitle>Reset settings?</DialogTitle>
<DialogDescription>Your preferences go back to their defaults.</DialogDescription>
<DialogActions>
<DialogClose render={<Button variant="text" />}>Cancel</DialogClose>
<DialogClose render={<Button variant="text" />} onClick={reset}>Reset</DialogClose>
</DialogActions>
</DialogContent>
</Dialog>Props
Dialog
| Prop | Type | Default | Description |
|---|---|---|---|
| open / defaultOpen | boolean | — | Open state. |
| onOpenChange | (open: boolean) => void | — | Called on open and close. |
| modal | boolean | "trap-focus" | true | Whether the rest of the page is inert. |
DialogContent
| Prop | Type | Default | Description |
|---|---|---|---|
| motion | "expressive" | "standard" | "fluid" | "none" | theme default | Motion scheme for this component's transitions. none turns animation off. |
| className | string | — | Extra classes, merged with tailwind-merge. |
DialogTitle, DialogDescription, DialogIcon, DialogActions, DialogTrigger, DialogClose
| Prop | Type | Default | Description |
|---|---|---|---|
| … | — | Parts; Title and Description are wired to the dialog's aria-labelledby and aria-describedby. |
Accessibility
- Focus moves into the dialog on open, is trapped while it's open, and returns to the trigger on close.
- Escape and clicking the scrim close it.
- Always include a DialogTitle so the dialog has an accessible name.
Source
This is the file the CLI writes into your project, shown with the default import paths. It's yours to change.
"use client";
import * as React from "react";
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { cn } from "@/lib/expressive/utils";
import type { MotionProp } from "@/lib/expressive/use-motion";
import { useComposedRef, useFluidEnter } from "@/lib/expressive/use-fluid";
/*
* Basic dialog. Base UI handles focus trapping, ARIA wiring, Escape and
* outside-press dismissal; enter/exit transitions read the motion tokens.
*/
export const Dialog = BaseDialog.Root;
export const DialogTrigger = BaseDialog.Trigger;
export const DialogClose = BaseDialog.Close;
export interface DialogContentProps extends BaseDialog.Popup.Props {
motion?: MotionProp;
className?: string;
}
export function DialogContent({ className, motion, children, ref, ...props }: DialogContentProps) {
const enter = useFluidEnter({ from: 0.55, motion });
const popupRef = useComposedRef(enter, ref);
return (
<BaseDialog.Portal>
<BaseDialog.Backdrop
data-slot="dialog-backdrop"
data-motion={motion}
className={cn(
"fixed inset-0 z-50 bg-scrim/32",
"transition-opacity spring-effects-default",
"data-starting-style:opacity-0 data-ending-style:opacity-0",
)}
/>
<BaseDialog.Viewport className="fixed inset-0 z-50 grid place-items-center p-6">
<BaseDialog.Popup
ref={popupRef}
data-slot="dialog"
data-motion={motion}
className={cn(
"focus-ring flex w-full min-w-70 max-w-140 flex-col gap-4 rounded-shape-extra-large bg-surface-container-high p-6 text-on-surface shadow-elevation-3",
"origin-top [transition:transform_var(--ex-spring-spatial-default-duration)_var(--ex-spring-spatial-default-easing),opacity_var(--ex-spring-effects-default-duration)_var(--ex-spring-effects-default-easing)]",
"data-starting-style:-translate-y-4 data-starting-style:scale-y-75 data-starting-style:opacity-0",
"data-ending-style:scale-95 data-ending-style:opacity-0",
// Fluid: the entrance is script-driven; CSS only fades.
"fluid:[transition:opacity_var(--ex-spring-effects-default-duration)_var(--ex-spring-effects-default-easing)] fluid:data-ending-style:scale-100",
className,
)}
{...props}
>
{children}
</BaseDialog.Popup>
</BaseDialog.Viewport>
</BaseDialog.Portal>
);
}
/** Optional hero icon, centered above the title. */
export function DialogIcon({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-icon"
className={cn("flex justify-center text-secondary [&_svg]:size-6 [&+*]:text-center", className)}
{...props}
/>
);
}
export function DialogTitle({ className, ...props }: BaseDialog.Title.Props & { className?: string }) {
return <BaseDialog.Title data-slot="dialog-title" className={cn("text-headline-small text-on-surface", className)} {...props} />;
}
export function DialogDescription({ className, ...props }: BaseDialog.Description.Props & { className?: string }) {
return (
<BaseDialog.Description
data-slot="dialog-description"
className={cn("text-body-medium text-on-surface-variant", className)}
{...props}
/>
);
}
export function DialogActions({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="dialog-actions" className={cn("mt-2 flex justify-end gap-2", className)} {...props} />;
}