For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Dialog

The Dialog component from this design system, built on Base UI.

Installation

$ pnpm dlx shadcn@latest add @cobalt/dialog

The dialog works with any theme and falls back to --background, --border and --muted for its surface, border and icon disc. Add theme-cobalt for the exact surface, border and shadow tokens.

$ pnpm dlx shadcn@latest add @cobalt/theme-cobalt

Parts

PartUse
DialogThe root. Controls open and onOpenChange.
DialogTriggerWhat opens the dialog. Use render to pass a Button.
DialogContentThe panel. Renders the backdrop, the portal and a close button.
DialogHeaderShrink-wrapped top block; holds the icon, title and description.
DialogIconA 40px disc above the title. variant="destructive" tints it red.
DialogTitleThe accessible name. Required.
DialogDescriptionSupporting copy, wired to the panel's aria-describedby.
DialogBodyScrolling region between the header and the footer.
DialogFooterActions. Stacks full-width on phones, a row from sm up.
DialogCloseCloses the dialog. Use render to pass a Button.

Sizes

DialogContent takes size: sm (400px), default (480px, the default), lg (640px) or xl (800px). On a phone the panel keeps 16px of page on each side.

placement="bottom" anchors the panel 40px above the bottom edge and slides it up from below, for compose-style dialogs that leave the page visible. backdrop="light" dims the page with black/20 instead of black/70. Both are opt-in; the default stays centred with the black/70 backdrop.

For a confirmation, pass role="alertdialog" and showCloseButton={false}, and add disablePointerDismissal to Dialog so a press on the backdrop leaves it open. Focus lands on the first tabbable element, which is the safe action when the footer lists it first.

Motion

The black/70 backdrop fades over 300ms. The panel fades and sharpens from scale(0.85) and a 4px blur over 300ms on cubic-bezier(0.32, 0.72, 0, 1), and plays the same transition in reverse before it unmounts. With prefers-reduced-motion: reduce the scale and blur are dropped and only the fade remains.

Source

"use client";

import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { RiCloseLine } from "@remixicon/react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "cn";
import * as React from "react";

import { useDialogScrollFade } from "@/components/hooks/use-dialog-scroll-fade";
import { Button } from "@/components/ui/button";

// A centred dialog over a dimmed page: focus is trapped inside and returns to
// the trigger on close, the page behind stops scrolling, and Escape or a press
// on the backdrop dismisses it.
//
// Surface and motion: the black/70 backdrop fades over 300ms; the panel fades
// and sharpens from scale 0.85 and a 4px blur over 300ms on the iOS sheet
// curve, and plays the same transition in reverse before it unmounts. Reduced
// motion keeps the fade and drops the scale and blur.
//
// Every colour references a token with a semantic fallback, so the dialog also
// works without theme-cobalt.
const dialogSizes = {
  sm: "max-w-[400px]",
  default: "max-w-[480px]",
  lg: "max-w-[640px]",
  xl: "max-w-[800px]",
} as const;

// A dialog opened from state (no DialogTrigger) has no trigger to give focus
// back to. Remember what had focus when `open` turned true: render runs before
// the dialog moves focus inside.
const DialogOpenerContext =
  React.createContext<React.RefObject<Element | null> | null>(null);

function Dialog({ ...props }: DialogPrimitive.Root.Props) {
  const opener = React.useRef<Element | null>(null);
  const wasOpen = React.useRef(false);
  if (props.open && !wasOpen.current && typeof document !== "undefined") {
    opener.current = document.activeElement;
  }
  wasOpen.current = !!props.open;
  return (
    <DialogOpenerContext.Provider value={opener}>
      <DialogPrimitive.Root data-slot="dialog" {...props} />
    </DialogOpenerContext.Provider>
  );
}

function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}

function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}

function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}

function DialogOverlay({
  className,
  ...props
}: DialogPrimitive.Backdrop.Props) {
  return (
    <DialogPrimitive.Backdrop
      data-slot="dialog-overlay"
      className={cn(
        "fixed inset-0 isolate z-50 bg-black/70 transition-opacity duration-300 ease-out data-ending-style:opacity-0 data-starting-style:opacity-0",
        className
      )}
      {...props}
    />
  );
}

// Keep the panel within the visible area when a mobile keyboard resizes or
// pans the visual viewport. The backdrop still covers the layout viewport.
function DialogViewport({ children }: { children: React.ReactNode }) {
  const ref = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    const viewport = window.visualViewport;
    const element = ref.current;
    if (!viewport || !element) {
      return;
    }

    const update = () => {
      element.style.top = `${viewport.offsetTop}px`;
      element.style.height = `${viewport.height}px`;
    };
    update();
    viewport.addEventListener("resize", update);
    viewport.addEventListener("scroll", update);
    return () => {
      viewport.removeEventListener("resize", update);
      viewport.removeEventListener("scroll", update);
    };
  }, []);

  return (
    <div
      ref={ref}
      className="pointer-events-none fixed inset-x-0 top-0 z-50 h-dvh"
    >
      {children}
    </div>
  );
}

function DialogContent({
  className,
  children,
  size = "default",
  placement = "center",
  backdrop = "default",
  showCloseButton = true,
  finalFocus,
  ...props
}: DialogPrimitive.Popup.Props & {
  size?: keyof typeof dialogSizes;
  /**
   * `bottom` anchors the panel 40px above the bottom edge and slides it up
   * from below, for compose-style dialogs that leave the page visible.
   */
  placement?: "center" | "bottom";
  /** `light` dims the page with black/20 instead of black/70. */
  backdrop?: "default" | "light";
  showCloseButton?: boolean;
}) {
  const opener = React.useContext(DialogOpenerContext);
  return (
    <DialogPortal>
      <DialogOverlay
        className={backdrop === "light" ? "bg-black/20" : undefined}
      />
      <DialogViewport>
        <DialogPrimitive.Popup
          data-slot="dialog-content"
          data-size={size}
          className={cn(
            // A column, so DialogBody can scroll on its own while the header and
            // footer stay put. overflow-clip rather than hidden: a hidden box is
            // still programmatically scrollable, so focusing a field clipped by
            // the body's scroller could scroll the whole panel instead.
            "group/dialog pointer-events-auto absolute top-1/2 left-1/2 z-50 flex max-h-[calc(100%-2rem)] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 transform-gpu flex-col gap-4 overflow-clip rounded-[24px] border border-[color:var(--surface-strong,var(--border))] bg-[var(--surface-overlay,var(--background))] py-6 text-sm text-foreground shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] outline-none transition-[opacity,scale,filter] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:opacity-0 data-starting-style:opacity-0 motion-safe:data-ending-style:scale-[0.85] motion-safe:data-ending-style:blur-[4px] motion-safe:data-starting-style:scale-[0.85] motion-safe:data-starting-style:blur-[4px] sm:max-h-[calc(100%-3rem)]",
            dialogSizes[size],
            placement === "bottom" &&
              "top-auto bottom-10 translate-y-0 [transition-property:opacity,scale,filter,translate] motion-safe:data-ending-style:translate-y-6 motion-safe:data-starting-style:translate-y-6",
            className
          )}
          data-close-button={showCloseButton ? "" : undefined}
          finalFocus={
            finalFocus ??
            (() => {
              const element = opener?.current;
              return element instanceof HTMLElement &&
                element !== document.body &&
                element.isConnected
                ? element
                : true;
            })
          }
          {...props}
        >
          {children}
          {showCloseButton && (
            <DialogPrimitive.Close
              data-slot="dialog-close"
              render={
                <Button
                  variant="close"
                  size="icon-xs"
                  className="absolute top-6 right-5"
                />
              }
            >
              <RiCloseLine className="size-6" />
              <span className="sr-only">Close</span>
            </DialogPrimitive.Close>
          )}
        </DialogPrimitive.Popup>
      </DialogViewport>
    </DialogPortal>
  );
}

function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="dialog-header"
      className={cn(
        // Reserve the close button's column so a long title never runs under
        // it. A centred header (text-center) reserves it on both sides, so the
        // title stays on the dialog's centre line.
        "flex shrink-0 flex-col items-start px-6 group-data-close-button/dialog:pe-14 group-data-close-button/dialog:[&.text-center]:ps-14",
        className
      )}
      {...props}
    />
  );
}

const dialogIconVariants = cva(
  "mb-4 flex size-10 shrink-0 items-center justify-center rounded-full [&_svg:not([class*='size-'])]:size-5",
  {
    defaultVariants: {
      variant: "default",
    },
    variants: {
      variant: {
        default:
          "bg-[var(--border-subtle,var(--muted))] text-[color:var(--icon-foreground,var(--muted-foreground))]",
        destructive:
          "bg-[var(--notification-error-background,color-mix(in_oklch,var(--destructive)_15%,transparent))] text-[color:var(--notification-error-foreground,var(--destructive))]",
      },
    },
  }
);

function DialogIcon({
  className,
  variant,
  ...props
}: React.ComponentProps<"span"> & VariantProps<typeof dialogIconVariants>) {
  return (
    <span
      data-slot="dialog-icon"
      className={cn(dialogIconVariants({ variant }), className)}
      {...props}
    />
  );
}

function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
  return (
    <DialogPrimitive.Title
      data-slot="dialog-title"
      className={cn(
        "text-[18px] leading-[26px] font-medium tracking-normal text-foreground",
        className
      )}
      {...props}
    />
  );
}

function DialogDescription({
  className,
  ...props
}: DialogPrimitive.Description.Props) {
  return (
    <DialogPrimitive.Description
      data-slot="dialog-description"
      className={cn(
        "mt-1 text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
        className
      )}
      {...props}
    />
  );
}

function DialogScrollFade({ progress }: { progress: number }) {
  return (
    <div
      aria-hidden
      className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6"
    >
      <div
        className="absolute inset-0 backdrop-blur-[1px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
        style={{ opacity: progress }}
      />
      <div
        className="absolute inset-x-0 top-0 h-4 backdrop-blur-[4px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
        style={{ opacity: progress }}
      />
      <div
        className="absolute inset-0 bg-linear-to-b from-[var(--surface-overlay,var(--background))] to-transparent"
        style={{ opacity: progress }}
      />
    </div>
  );
}

// The content between the header and the footer. It scrolls on its own when
// the panel reaches the viewport's height, with the header and footer held in
// place and a progressive blur softening the top edge once scrolled.
function DialogBody({
  className,
  onScroll,
  children,
  ...props
}: React.ComponentProps<"div">) {
  const fade = useDialogScrollFade();

  return (
    <div className="relative flex min-h-0 flex-1 flex-col">
      <div
        data-slot="dialog-body"
        onScroll={(event) => {
          fade.onScroll(event);
          onScroll?.(event);
        }}
        className={cn(
          "min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-1 text-sm text-muted-foreground",
          className
        )}
        {...props}
      >
        {children}
      </div>
      <DialogScrollFade progress={fade.progress} />
    </div>
  );
}

function DialogFooter({
  className,
  showCloseButton = false,
  children,
  ...props
}: React.ComponentProps<"div"> & {
  showCloseButton?: boolean;
}) {
  return (
    <div
      data-slot="dialog-footer"
      className={cn(
        // Stacked full-width on phones (primary action on top), a trailing row
        // from sm up. 8px extra over the dialog's gap-4 sets the actions apart
        // from the content.
        "mt-2 flex shrink-0 flex-col-reverse gap-2 px-6 sm:flex-row sm:items-center sm:justify-end",
        className
      )}
      {...props}
    >
      {children}
      {showCloseButton && (
        <DialogPrimitive.Close render={<Button variant="outline" />}>
          Close
        </DialogPrimitive.Close>
      )}
    </div>
  );
}

export {
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogIcon,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
  dialogIconVariants,
};