Expressive

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 dialog

This 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

PropTypeDefaultDescription
open / defaultOpenboolean—Open state.
onOpenChange(open: boolean) => void—Called on open and close.
modalboolean | "trap-focus"trueWhether the rest of the page is inert.

DialogContent

PropTypeDefaultDescription
motion"expressive" | "standard" | "fluid" | "none"theme defaultMotion scheme for this component's transitions. none turns animation off.
classNamestring—Extra classes, merged with tailwind-merge.

DialogTitle, DialogDescription, DialogIcon, DialogActions, DialogTrigger, DialogClose

PropTypeDefaultDescription
…—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.

components/dialog.tsx
"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} />;
}