Skip to content

Rises from .97 on the expo curve, scrolls inside with edge hairlines, nudges when refused.

Dialogs & sheets@base-ui/reactmotion

01Preview

stealth-web

Production · main · 3 commits waiting

Ready

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react motion

03Usage

import {
  Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle,
  DialogDescription, DialogBody, DialogFooter, DialogClose,
} from "@/components/ui/dialog";

<Dialog dismissible={!dirty}>
  <DialogTrigger render={<Button />}>Rename</DialogTrigger>
  <DialogContent size="sm">
    <DialogHeader>
      <DialogTitle>Rename project</DialogTitle>
      <DialogDescription>The new name shows up in its URL.</DialogDescription>
    </DialogHeader>
    <DialogBody></DialogBody>
    <DialogFooter>
      <DialogClose>Cancel</DialogClose>
      <Button variant="primary" type="submit">Save changes</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

04Source

"use client";
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { useReducedMotion } from "motion/react";
import { createContext, useContext, useEffect, useRef } from "react";
import { cn } from "@/lib/cn";
import { X } from "@/lib/icons";

type DialogContextValue = { setPopup: (node: HTMLDivElement | null) => void; nudge: () => void };
const DialogContext = createContext<DialogContextValue | null>(null);

/**
 * A small "I'm still here" push: the popup swells 1.5% and settles. Used when a
 * click outside is refused, so the refusal reads as intentional, not as lag.
 */
function nudgeElement(el: HTMLElement | null, reduce: boolean) {
  if (!el || reduce || typeof el.animate !== "function") return;
  el.animate([{ transform: "scale(1)" }, { transform: "scale(1.015)" }, { transform: "scale(1)" }], {
    duration: 280,
    easing: "cubic-bezier(0.16, 1, 0.3, 1)",
  });
}

export type DialogProps = BaseDialog.Root.Props & {
  /**
   * Whether a click on the backdrop closes the dialog. When false the dialog
   * nudges instead, which is what you want once a form has unsaved input.
   * Escape and the close button still work.
   */
  dismissible?: boolean;
};

export function Dialog({ dismissible = true, onOpenChange, ...rest }: DialogProps) {
  const popupRef = useRef<HTMLDivElement>(null);
  const reduce = !!useReducedMotion();
  const nudge = () => nudgeElement(popupRef.current, reduce);
  const setPopup = (node: HTMLDivElement | null) => {
    popupRef.current = node;
  };
  return (
    <DialogContext.Provider value={{ setPopup, nudge }}>
      <BaseDialog.Root
        onOpenChange={(open, details) => {
          if (!open && !dismissible && details.reason === "outside-press") {
            details.cancel();
            nudge();
            return;
          }
          onOpenChange?.(open, details);
        }}
        {...rest}
      />
    </DialogContext.Provider>
  );
}

/** Nudge the open dialog from your own code, e.g. when you cancel a close in onOpenChange. */
export function useDialogNudge() {
  return useContext(DialogContext)?.nudge ?? (() => {});
}

export type DialogTriggerProps = BaseDialog.Trigger.Props;

/** Opens the dialog. Pass your own button with `render` to keep its look. */
export function DialogTrigger(props: DialogTriggerProps) {
  return <BaseDialog.Trigger {...props} />;
}

export type DialogSize = "sm" | "md" | "lg";

const widths: Record<DialogSize, string> = {
  sm: "max-w-[400px]",
  md: "max-w-[520px]",
  lg: "max-w-[720px]",
};

export type DialogContentProps = Omit<BaseDialog.Popup.Props, "className"> & {
  className?: string;
  /** 400px for a confirm, 520px for a form, 720px for content. */
  size?: DialogSize;
  /** The × in the top-right corner. */
  showCloseButton?: boolean;
  /** Accessible name of the close button. */
  closeLabel?: string;
  /** Render into this element instead of document.body. The backdrop then covers only the container. */
  container?: BaseDialog.Portal.Props["container"];
  backdropClassName?: string;
};

export function DialogContent({
  size = "md",
  showCloseButton = true,
  closeLabel = "Close",
  container,
  backdropClassName,
  className,
  children,
  ref,
  ...rest
}: DialogContentProps) {
  const ctx = useContext(DialogContext);
  const contained = container != null;
  return (
    <BaseDialog.Portal container={container}>
      <BaseDialog.Backdrop
        className={cn(
          contained ? "absolute" : "fixed",
          "inset-0 z-(--z-overlay) bg-overlay",
          // In fast, out faster. Opacity only: a backdrop has nothing to say about direction.
          "transition-opacity duration-200 ease-out-quart data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-150",
          backdropClassName,
        )}
      />
      <BaseDialog.Viewport
        className={cn(
          contained ? "absolute" : "fixed",
          "inset-0 z-(--z-dialog) flex items-center justify-center p-4",
          // On a phone the dialog sits at the bottom, under the thumb.
          "max-sm:items-end max-sm:p-2 max-sm:pb-[max(8px,env(safe-area-inset-bottom))]",
        )}
      >
        <BaseDialog.Popup
          ref={(node: HTMLDivElement | null) => {
            ctx?.setPopup(node);
            if (typeof ref === "function") ref(node);
            else if (ref) ref.current = node;
          }}
          data-size={size}
          data-closable={showCloseButton ? "" : undefined}
          className={cn(
            "group/dialog relative flex max-h-full w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-line-2 bg-raised text-fg shadow-pop outline-none",
            widths[size],
            // Enter: fade, grow from .97 and rise 6px on the expo curve. Exit: faster and simpler, no travel.
            "origin-center transition-[opacity,scale,translate] duration-[260ms] ease-out-expo",
            "data-starting-style:translate-y-1.5 data-starting-style:scale-[0.97] data-starting-style:opacity-0",
            "data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-[160ms] data-ending-style:ease-out-quart",
            // Phones: rise from the bottom edge instead of growing in the middle.
            "max-sm:data-starting-style:translate-y-4 max-sm:data-starting-style:scale-100",
            // A dialog opened on top of this one pushes it back and dims it.
            "after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-overlay after:opacity-0 after:transition-opacity after:duration-200",
            "data-nested-dialog-open:scale-[0.96] data-nested-dialog-open:after:opacity-100",
            className,
          )}
          {...rest}
        >
          {children}
          {showCloseButton && (
            // Last in the DOM so initial focus lands on the content, first on screen.
            <BaseDialog.Close
              aria-label={closeLabel}
              className={cn(
                "absolute right-3 top-3 grid size-7 place-items-center rounded-md text-fg-3",
                "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
                "transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75",
                "before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
              )}
            >
              <X size={16} />
            </BaseDialog.Close>
          )}
        </BaseDialog.Popup>
      </BaseDialog.Viewport>
    </BaseDialog.Portal>
  );
}

export type DialogHeaderProps = React.ComponentProps<"div">;

export function DialogHeader({ className, ...rest }: DialogHeaderProps) {
  return (
    <div
      data-slot="dialog-header"
      className={cn("flex shrink-0 flex-col gap-1 px-5 pb-2 pt-5 group-data-closable/dialog:pr-12", className)}
      {...rest}
    />
  );
}

export type DialogTitleProps = Omit<BaseDialog.Title.Props, "className"> & { className?: string };

export function DialogTitle({ className, ...rest }: DialogTitleProps) {
  return (
    <BaseDialog.Title
      className={cn("text-[15px] font-medium leading-[1.3] tracking-[-0.015em] text-fg text-balance", className)}
      {...rest}
    />
  );
}

export type DialogDescriptionProps = Omit<BaseDialog.Description.Props, "className"> & { className?: string };

export function DialogDescription({ className, ...rest }: DialogDescriptionProps) {
  return <BaseDialog.Description className={cn("text-[13px] leading-[1.5] text-fg-2 text-pretty", className)} {...rest} />;
}

export type DialogBodyProps = React.ComponentProps<"div">;

/**
 * The part that scrolls. The header and footer stay put; a hairline appears on
 * each edge only while there is more content hidden past it.
 */
export function DialogBody({ className, children, ...rest }: DialogBodyProps) {
  const wrap = useRef<HTMLDivElement>(null);
  const scroller = useRef<HTMLDivElement>(null);
  const content = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const root = wrap.current;
    const el = scroller.current;
    if (!root || !el) return;
    const update = () => {
      const max = el.scrollHeight - el.clientHeight;
      root.toggleAttribute("data-overflow-top", el.scrollTop > 1);
      root.toggleAttribute("data-overflow-bottom", max - el.scrollTop > 1);
      // A region that scrolls must be reachable by keyboard when nothing inside it is.
      if (max > 1) el.setAttribute("tabindex", "0");
      else el.removeAttribute("tabindex");
    };
    update();
    el.addEventListener("scroll", update, { passive: true });
    const ro = new ResizeObserver(update);
    ro.observe(el);
    if (content.current) ro.observe(content.current);
    return () => {
      el.removeEventListener("scroll", update);
      ro.disconnect();
    };
  }, []);

  return (
    <div ref={wrap} data-slot="dialog-body" className="group/body relative flex min-h-0 flex-1 flex-col">
      <span
        aria-hidden
        className="pointer-events-none absolute inset-x-0 top-0 z-1 h-px bg-line-2 opacity-0 transition-opacity duration-150 group-data-overflow-top/body:opacity-100"
      />
      <div
        ref={scroller}
        className={cn(
          "min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-2 outline-none",
          "focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4",
          className,
        )}
        {...rest}
      >
        <div ref={content}>{children}</div>
      </div>
      <span
        aria-hidden
        className="pointer-events-none absolute inset-x-0 bottom-0 z-1 h-px bg-line-2 opacity-0 transition-opacity duration-150 group-data-overflow-bottom/body:opacity-100"
      />
    </div>
  );
}

export type DialogFooterProps = React.ComponentProps<"div">;

export function DialogFooter({ className, ...rest }: DialogFooterProps) {
  return (
    <div
      data-slot="dialog-footer"
      className={cn(
        "flex shrink-0 items-center justify-end gap-2 px-5 pb-5 pt-4",
        // Phones: buttons share the row equally and grow to a thumb-sized 40px.
        "max-sm:grid max-sm:auto-cols-fr max-sm:grid-flow-col max-sm:*:h-10",
        className,
      )}
      {...rest}
    />
  );
}

export type DialogCloseProps = Omit<BaseDialog.Close.Props, "className"> & {
  className?: string;
  variant?: "primary" | "secondary" | "ghost";
};

/** A button that closes the dialog, styled as a button unless you `render` your own. */
export function DialogClose({ variant = "secondary", className, ...rest }: DialogCloseProps) {
  return (
    <BaseDialog.Close
      data-variant={variant}
      className={cn(
        "relative inline-flex h-8 shrink-0 select-none items-center justify-center whitespace-nowrap rounded-lg px-2.5 text-[12.5px] font-medium tracking-[-0.005em]",
        "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
        "transition-[background-color,border-color,color,scale] duration-150 ease-out-quart active:scale-[0.97] active:duration-75",
        "data-disabled:pointer-events-none data-disabled:opacity-50",
        variant === "primary" && "bg-fg text-frame shadow-[var(--shadow)] hover:bg-fg/90",
        variant === "secondary" && "border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover",
        variant === "ghost" && "text-fg-2 hover:bg-hover hover:text-fg",
        className,
      )}
      {...rest}
    />
  );
}

05Props

Dialog

PropTypeDefaultDescription
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial state when uncontrolled.
onOpenChange(open: boolean, details) => voidCalled with the reason (escape-key, outside-press, close-press…). Call details.cancel() to keep it open.
dismissiblebooleantrueWhether a backdrop click closes it. When false the dialog nudges instead; Escape and Close still work.
modalboolean | "trap-focus"trueTrap focus and lock page scroll, trap focus only, or neither.

DialogContent

PropTypeDefaultDescription
size"sm" | "md" | "lg""md"400px for a confirm, 520px for a form, 720px for content.
showCloseButtonbooleantrueThe × in the top-right corner.
closeLabelstring"Close"Accessible name of the × button.
containerHTMLElement | RefObject<HTMLElement>Portal target. The backdrop and viewport become absolute to it.
initialFocusboolean | RefObject | (type) => HTMLElementWhere focus goes on open. Defaults to the first tabbable element.
finalFocusboolean | RefObject | (type) => HTMLElementWhere focus goes on close. Defaults to the trigger.
backdropClassNamestringClasses for the backdrop.

DialogHeader

PropTypeDefaultDescription
classNamestringTitle and description stack. Leaves room for the × when it is shown.

DialogTitle

PropTypeDefaultDescription
classNamestringAn h2 that names the dialog.

DialogDescription

PropTypeDefaultDescription
classNamestringA paragraph linked as the dialog's description.

DialogBody

PropTypeDefaultDescription
classNamestringThe scrolling region. Edge hairlines appear only while content is hidden past them.

DialogFooter

PropTypeDefaultDescription
classNamestringActions, right-aligned. On phones they share the row and grow to 40px.

DialogClose

PropTypeDefaultDescription
variant"primary" | "secondary" | "ghost""secondary"Button look, unless you pass your own with render.
renderReactElementPut the close behavior on your own button.

useDialogNudge

PropTypeDefaultDescription
()() => voidReturns a function that nudges the open dialog, for when you cancel a close yourself.

06Notes

Behavior

  • Header and footer never scroll; the body does, with overscroll contained. A hairline fades in at each edge only while there is more content past it, so a short dialog stays clean.
  • With dismissible false a stray backdrop click nudges the dialog instead of discarding input. Use it once a form is dirty; Escape and the close button still work.
  • A dialog opened from this one pushes it back to 96% and dims it, and only the front one has a backdrop.
  • On phones it anchors to the bottom edge above the safe area, and footer buttons split the row at 40px tall.
  • When the body overflows and has nothing focusable, it joins the tab order so the keyboard can scroll it.

Motion

  • Enter: opacity, scale .97 → 1 and a 6px rise, 260ms on the expo ease-out. Exit: opacity and scale .98, 160ms, no travel.
  • Backdrop fades 200ms in, 150ms out. On phones the popup rises 16px from the bottom instead of growing.
  • The refusal nudge swells 1.5% and settles in 280ms. The × presses to .92.
  • Reduced motion: transitions collapse to instant and the nudge is skipped; nothing is hidden.

Accessibility

  • role=dialog with aria-labelledby and aria-describedby from Title and Description; focus is trapped and the page behind is inert.
  • Focus lands on the first field (the × is last in the DOM), Escape closes the top dialog only, and focus returns to the trigger.
  • The × has an accessible name and a 44px touch target; the trigger shows its pressed look while its dialog is open.