Vela UI Docsvenizia.ai

Dialog

A modal overlay for confirmations, decisions, and short flows.

npx shadcn@latest add @velaui/dialog
npx shadcn@latest add @velaui/dialog

Examples

  • Default
  • Warning decision
  • Photo left
  • Photo right small
  • Error stacked
  • Info large

View code

Dialog · Default

Installation

pnpm dlx velaui add @velaui/dialog
pnpm dlx velaui add @velaui/dialog

Code

dialog-default.tsx
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogHeader,
  DialogIcon,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Check01 } from "@/components/icons/check-01";

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="primary">Delete account</Button>
      </DialogTrigger>
      <DialogContent size="md">
        <DialogHeader>
          <DialogIcon variant="success">
            <Check01 />
          </DialogIcon>
          <DialogTitle>Delete your account?</DialogTitle>
          <DialogDescription>
            This action is permanent and cannot be undone. All your data will be
            removed.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter direction="right">
          <DialogClose asChild>
            <Button variant="secondary">Cancel</Button>
          </DialogClose>
          <DialogClose asChild>
            <Button variant="primary">Confirm</Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogHeader,
  DialogIcon,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Check01 } from "@/components/icons/check-01";

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="primary" />}>
        Delete account
      </DialogTrigger>
      <DialogContent size="md">
        <DialogHeader>
          <DialogIcon variant="success">
            <Check01 />
          </DialogIcon>
          <DialogTitle>Delete your account?</DialogTitle>
          <DialogDescription>
            This action is permanent and cannot be undone. All your data will be
            removed.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter direction="right">
          <DialogClose render={<Button variant="secondary" />}>
            Cancel
          </DialogClose>
          <DialogClose render={<Button variant="primary" />}>
            Confirm
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

View code

Dialog · Warning decision

Installation

pnpm dlx velaui add @velaui/dialog
pnpm dlx velaui add @velaui/dialog

Code

dialog-warning-decision.tsx
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogHeader,
  DialogIcon,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from "@/components/icons/alert-triangle";

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="secondary">Leave without saving</Button>
      </DialogTrigger>
      <DialogContent size="sm">
        <DialogHeader align="center">
          <DialogIcon variant="warning">
            <AlertTriangle />
          </DialogIcon>
          <DialogTitle>Discard changes?</DialogTitle>
          <DialogDescription>
            You have unsaved changes that will be lost.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter direction="full-width">
          <DialogClose asChild>
            <Button variant="secondary">Keep editing</Button>
          </DialogClose>
          <DialogClose asChild>
            <Button variant="primary">Discard</Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogHeader,
  DialogIcon,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from "@/components/icons/alert-triangle";

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="secondary" />}>
        Leave without saving
      </DialogTrigger>
      <DialogContent size="sm">
        <DialogHeader align="center">
          <DialogIcon variant="warning">
            <AlertTriangle />
          </DialogIcon>
          <DialogTitle>Discard changes?</DialogTitle>
          <DialogDescription>
            You have unsaved changes that will be lost.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter direction="full-width">
          <DialogClose render={<Button variant="secondary" />}>
            Keep editing
          </DialogClose>
          <DialogClose render={<Button variant="primary" />}>
            Discard
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

View code

Dialog · Photo left

Installation

pnpm dlx velaui add @velaui/dialog
pnpm dlx velaui add @velaui/dialog

Code

dialog-photo-left.tsx
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogPhoto,
  DialogPanel,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="primary">Upgrade plan</Button>
      </DialogTrigger>
      <DialogContent photo="left">
        <DialogPhoto>
          <img src="https://picsum.photos/seed/dialog-left/300/400" alt="" />
        </DialogPhoto>
        <DialogPanel>
          <DialogHeader>
            <DialogTitle>Upgrade to Pro</DialogTitle>
            <DialogDescription>
              Unlock unlimited projects, priority support, and advanced
              analytics.
            </DialogDescription>
          </DialogHeader>
          <DialogFooter direction="right">
            <DialogClose asChild>
              <Button variant="secondary">Not now</Button>
            </DialogClose>
            <DialogClose asChild>
              <Button variant="primary">Upgrade</Button>
            </DialogClose>
          </DialogFooter>
        </DialogPanel>
      </DialogContent>
    </Dialog>
  )
}
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogPhoto,
  DialogPanel,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="primary" />}>
        Upgrade plan
      </DialogTrigger>
      <DialogContent photo="left">
        <DialogPhoto>
          <img src="https://picsum.photos/seed/dialog-left/300/400" alt="" />
        </DialogPhoto>
        <DialogPanel>
          <DialogHeader>
            <DialogTitle>Upgrade to Pro</DialogTitle>
            <DialogDescription>
              Unlock unlimited projects, priority support, and advanced
              analytics.
            </DialogDescription>
          </DialogHeader>
          <DialogFooter direction="right">
            <DialogClose render={<Button variant="secondary" />}>
              Not now
            </DialogClose>
            <DialogClose render={<Button variant="primary" />}>
              Upgrade
            </DialogClose>
          </DialogFooter>
        </DialogPanel>
      </DialogContent>
    </Dialog>
  )
}

View code

Dialog · Photo right small

Installation

pnpm dlx velaui add @velaui/dialog
pnpm dlx velaui add @velaui/dialog

Code

dialog-photo-right-small.tsx
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogPhoto,
  DialogPanel,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">Invite teammate</Button>
      </DialogTrigger>
      <DialogContent size="sm" photo="right">
        <DialogPanel>
          <DialogHeader>
            <DialogTitle>Invite a teammate</DialogTitle>
            <DialogDescription>
              They'll get an email with a link to join your workspace.
            </DialogDescription>
          </DialogHeader>
          <DialogFooter direction="right">
            <DialogClose asChild>
              <Button variant="secondary">Cancel</Button>
            </DialogClose>
            <DialogClose asChild>
              <Button variant="primary">Send invite</Button>
            </DialogClose>
          </DialogFooter>
        </DialogPanel>
        <DialogPhoto>
          <img src="https://picsum.photos/seed/dialog-right/300/400" alt="" />
        </DialogPhoto>
      </DialogContent>
    </Dialog>
  )
}
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogPhoto,
  DialogPanel,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>
        Invite teammate
      </DialogTrigger>
      <DialogContent size="sm" photo="right">
        <DialogPanel>
          <DialogHeader>
            <DialogTitle>Invite a teammate</DialogTitle>
            <DialogDescription>
              They'll get an email with a link to join your workspace.
            </DialogDescription>
          </DialogHeader>
          <DialogFooter direction="right">
            <DialogClose render={<Button variant="secondary" />}>
              Cancel
            </DialogClose>
            <DialogClose render={<Button variant="primary" />}>
              Send invite
            </DialogClose>
          </DialogFooter>
        </DialogPanel>
        <DialogPhoto>
          <img src="https://picsum.photos/seed/dialog-right/300/400" alt="" />
        </DialogPhoto>
      </DialogContent>
    </Dialog>
  )
}

View code

Dialog · Error stacked

Installation

pnpm dlx velaui add @velaui/dialog
pnpm dlx velaui add @velaui/dialog

Code

dialog-error-stacked.tsx
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogHeader,
  DialogIcon,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { CloseCircleContained } from "@/components/icons/close-circle-contained";

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="destructive">Payment failed</Button>
      </DialogTrigger>
      <DialogContent size="sm">
        <DialogHeader align="center">
          <DialogIcon variant="error">
            <CloseCircleContained />
          </DialogIcon>
          <DialogTitle>Payment failed</DialogTitle>
          <DialogDescription>
            We couldn't process your card. Please check your details and try
            again.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter direction="stacked">
          <DialogClose asChild>
            <Button variant="primary">Try again</Button>
          </DialogClose>
          <DialogClose asChild>
            <Button variant="secondary">Update payment method</Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogHeader,
  DialogIcon,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { CloseCircleContained } from "@/components/icons/close-circle-contained";

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="destructive" />}>
        Payment failed
      </DialogTrigger>
      <DialogContent size="sm">
        <DialogHeader align="center">
          <DialogIcon variant="error">
            <CloseCircleContained />
          </DialogIcon>
          <DialogTitle>Payment failed</DialogTitle>
          <DialogDescription>
            We couldn't process your card. Please check your details and try
            again.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter direction="stacked">
          <DialogClose render={<Button variant="primary" />}>
            Try again
          </DialogClose>
          <DialogClose render={<Button variant="secondary" />}>
            Update payment method
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

View code

Dialog · Info large

Installation

pnpm dlx velaui add @velaui/dialog
pnpm dlx velaui add @velaui/dialog

Code

dialog-info-large.tsx
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogHeader,
  DialogIcon,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { InformationCircleContained } from "@/components/icons/information-circle-contained";

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="ghost">What's new</Button>
      </DialogTrigger>
      <DialogContent size="lg">
        <DialogHeader>
          <DialogIcon variant="info">
            <InformationCircleContained />
          </DialogIcon>
          <DialogTitle>New in this release</DialogTitle>
          <DialogDescription>
            Faster search, dark mode across all screens, and a redesigned
            notifications center.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter direction="left">
          <DialogClose asChild>
            <Button variant="primary">Got it</Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogHeader,
  DialogIcon,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { InformationCircleContained } from "@/components/icons/information-circle-contained";

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="ghost" />}>
        What's new
      </DialogTrigger>
      <DialogContent size="lg">
        <DialogHeader>
          <DialogIcon variant="info">
            <InformationCircleContained />
          </DialogIcon>
          <DialogTitle>New in this release</DialogTitle>
          <DialogDescription>
            Faster search, dark mode across all screens, and a redesigned
            notifications center.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter direction="left">
          <DialogClose render={<Button variant="primary" />}>
            Got it
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Usage

import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogHeader,
  DialogIcon,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Check01 } from "@/components/icons/check-01";

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="primary">Delete account</Button>
      </DialogTrigger>
      <DialogContent size="md">
        <DialogHeader>
          <DialogIcon variant="success">
            <Check01 />
          </DialogIcon>
          <DialogTitle>Delete your account?</DialogTitle>
          <DialogDescription>
            This action is permanent and cannot be undone. All your data will be
            removed.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter direction="right">
          <DialogClose asChild>
            <Button variant="secondary">Cancel</Button>
          </DialogClose>
          <DialogClose asChild>
            <Button variant="primary">Confirm</Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  DialogHeader,
  DialogIcon,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Check01 } from "@/components/icons/check-01";

export default function Example() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="primary" />}>
        Delete account
      </DialogTrigger>
      <DialogContent size="md">
        <DialogHeader>
          <DialogIcon variant="success">
            <Check01 />
          </DialogIcon>
          <DialogTitle>Delete your account?</DialogTitle>
          <DialogDescription>
            This action is permanent and cannot be undone. All your data will be
            removed.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter direction="right">
          <DialogClose render={<Button variant="secondary" />}>
            Cancel
          </DialogClose>
          <DialogClose render={<Button variant="primary" />}>
            Confirm
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Props

Dialog

Takes every prop of DialogProps.

DialogClose

Takes every prop of DialogCloseProps.

DialogCloseButton

Takes every prop of DialogCloseButtonProps.

DialogContent

PropTypeDefault
photo"none" | "left" | "right"—
showCloseButtonbooleantrue
size"sm" | "md" | "lg"—

Takes these and every prop of DialogContentProps.

DialogDescription

Takes every prop of DialogDescriptionProps.

DialogFooter

PropTypeDefault
direction"left" | "right" | "full-width" | "stacked"—

Takes these and every prop of DialogFooterProps.

DialogHeader

PropTypeDefault
align"center" | "left"—

Takes these and every prop of DialogHeaderProps.

DialogIcon

PropTypeDefault
variant"info" | "success" | "warning" | "error"—

Takes these and every prop of DialogIconProps.

DialogOverlay

Takes every prop of DialogOverlayProps.

DialogPanel

PropTypeDefault
showCloseButtonbooleantrue

Takes these and every prop of DialogPanelProps.

DialogPhoto

Takes every prop of DialogPhotoProps.

DialogPortal

Takes every prop of DialogPortalProps.

DialogTitle

Takes every prop of DialogTitleProps.

DialogTrigger

Takes every prop of DialogTriggerProps.

Dependencies

npm
class-variance-authority@0.7.1radix-ui@1.6.0
Installs with it
1 icon
npm
@base-ui/react@1.6.0class-variance-authority@0.7.1
Installs with it
1 icon

Source

components/ui/dialog.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { Close02 } from "@/components/icons/close-02";

import { cn } from "@/lib/utils";

const dialogContentVariants = cva(
  [
    "fixed top-1/2 left-1/2 z-50 flex w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2",
    "overflow-hidden rounded-xl border border-border-default bg-bg-main text-text-strong shadow-md",
    "duration-100 outline-none",
    "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
    "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
  ].join(" "),
  {
    variants: {
      size: {
        sm: "sm:max-w-[400px]",
        md: "sm:max-w-[480px]",
        lg: "sm:max-w-[560px]",
      },
      // The photo panel turns the card into a row; without one the card IS the
      // padded column, so it carries the padding itself. No `relative` here —
      // it would win the position group over the base `fixed` in tailwind-merge,
      // and `fixed` already anchors the absolute close button.
      photo: {
        none: "flex-col gap-4 p-6",
        left: "flex-row",
        right: "flex-row-reverse",
      },
    },
    compoundVariants: [
      {
        photo: ["left", "right"],
        size: ["sm", "md", "lg"],
        // With a photo the card is one fixed width, whatever the size axis says.
        class: "sm:max-w-[700px]",
      },
    ],
    defaultVariants: {
      size: "md",
      photo: "none",
    },
  },
);

const dialogHeaderVariants = cva(
  "flex flex-col gap-1 [&>[data-slot=dialog-icon]]:mb-3",
  {
    variants: {
      align: {
        left: "items-start text-left",
        center: "items-center text-center",
      },
    },
    defaultVariants: {
      align: "left",
    },
  },
);

const dialogIconVariants = cva(
  "flex size-12 shrink-0 items-center justify-center rounded-full text-icon-inverse [&_svg]:size-6",
  {
    variants: {
      variant: {
        success: "bg-success-middle",
        warning: "bg-warning-middle",
        error: "bg-negative-middle",
        info: "bg-info-middle",
      },
    },
    defaultVariants: {
      variant: "success",
    },
  },
);

const dialogFooterVariants = cva("flex", {
  variants: {
    direction: {
      right: "flex-row items-center justify-end gap-3",
      left: "flex-row items-center justify-start gap-3",
      "full-width": "flex-row items-center gap-3 *:flex-1",
      stacked: "flex-col-reverse gap-2 *:w-full",
    },
  },
  defaultVariants: {
    direction: "right",
  },
});

export type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;

function Dialog({
  ...props
}: DialogProps) {
  return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}

export type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;

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

export type DialogPortalProps = React.ComponentProps<typeof DialogPrimitive.Portal>;

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

export type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;

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

export type DialogOverlayProps = React.ComponentProps<typeof DialogPrimitive.Overlay>;

function DialogOverlay({
  className,
  ...props
}: DialogOverlayProps) {
  return (
    <DialogPrimitive.Overlay
      data-slot="dialog-overlay"
      className={cn(
        "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
        className,
      )}
      {...props}
    />
  );
}

export type DialogCloseButtonProps = React.ComponentProps<typeof DialogPrimitive.Close>;

/** The 40×40 icon button pinned to the top-right corner of a padded column. */
function DialogCloseButton({
  className,
  ...props
}: DialogCloseButtonProps) {
  return (
    <DialogPrimitive.Close
      data-slot="dialog-close"
      className={cn(
        "absolute top-0 right-0 inline-flex size-10 items-center justify-center rounded-lg text-icon-muted transition-colors hover:bg-bg-surface-1 focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow focus-visible:outline-none [&_svg]:size-6",
        className,
      )}
      {...props}
    >
      <Close02 />
      <span className="sr-only">Close</span>
    </DialogPrimitive.Close>
  );
}

export type DialogContentProps = React.ComponentProps<typeof DialogPrimitive.Content> &
  VariantProps<typeof dialogContentVariants> & {
    showCloseButton?: boolean;
  };

function DialogContent({
  className,
  children,
  size,
  photo,
  showCloseButton = true,
  ...props
}: DialogContentProps) {
  return (
    <DialogPortal>
      <DialogOverlay />
      <DialogPrimitive.Content
        data-slot="dialog-content"
        className={cn(dialogContentVariants({ size, photo }), className)}
        {...props}
      >
        {children}
        {/* With a photo the close button belongs to DialogPanel, which is the
            column it has to anchor to. */}
        {showCloseButton && (!photo || photo === "none") && <DialogCloseButton />}
      </DialogPrimitive.Content>
    </DialogPortal>
  );
}

export type DialogPanelProps = React.ComponentProps<"div"> & { showCloseButton?: boolean };

/** The padded content column beside a DialogPhoto. */
function DialogPanel({
  className,
  children,
  showCloseButton = true,
  ...props
}: DialogPanelProps) {
  return (
    <div
      data-slot="dialog-panel"
      className={cn("relative flex flex-1 flex-col gap-4 p-6", className)}
      {...props}
    >
      {children}
      {showCloseButton && <DialogCloseButton />}
    </div>
  );
}

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

function DialogPhoto({ className, ...props }: DialogPhotoProps) {
  return (
    <div
      data-slot="dialog-photo"
      className={cn(
        "relative w-[300px] shrink-0 self-stretch overflow-hidden bg-bg-surface-1 *:size-full *:object-cover",
        className,
      )}
      {...props}
    />
  );
}

export type DialogHeaderProps = React.ComponentProps<"div"> &
  VariantProps<typeof dialogHeaderVariants>;

function DialogHeader({
  className,
  align,
  ...props
}: DialogHeaderProps) {
  return (
    <div
      data-slot="dialog-header"
      className={cn(dialogHeaderVariants({ align }), className)}
      {...props}
    />
  );
}

export type DialogIconProps = React.ComponentProps<"div"> & VariantProps<typeof dialogIconVariants>;

function DialogIcon({
  className,
  variant,
  ...props
}: DialogIconProps) {
  return (
    <div
      data-slot="dialog-icon"
      data-variant={variant ?? "success"}
      className={cn(dialogIconVariants({ variant }), className)}
      {...props}
    />
  );
}

export type DialogFooterProps = React.ComponentProps<"div"> & VariantProps<typeof dialogFooterVariants>;

function DialogFooter({
  className,
  direction,
  ...props
}: DialogFooterProps) {
  return (
    <div
      data-slot="dialog-footer"
      className={cn(dialogFooterVariants({ direction }), className)}
      {...props}
    />
  );
}

export type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>;

function DialogTitle({
  className,
  ...props
}: DialogTitleProps) {
  return (
    <DialogPrimitive.Title
      data-slot="dialog-title"
      className={cn(
        "text-xl leading-[1.3] font-semibold text-text-strong",
        className,
      )}
      {...props}
    />
  );
}

export type DialogDescriptionProps = React.ComponentProps<typeof DialogPrimitive.Description>;

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

export {
  Dialog,
  DialogClose,
  DialogCloseButton,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogIcon,
  DialogOverlay,
  DialogPanel,
  DialogPhoto,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
  dialogContentVariants,
  dialogFooterVariants,
  dialogHeaderVariants,
  dialogIconVariants,
};
components/ui/dialog.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { Close02 } from "@/components/icons/close-02";

import { cn } from "@/lib/utils";

const dialogContentVariants = cva(
  [
    "fixed top-1/2 left-1/2 z-50 flex w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2",
    "overflow-hidden rounded-xl border border-border-default bg-bg-main text-text-strong shadow-md",
    "duration-100 outline-none",
    "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
    "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
  ].join(" "),
  {
    variants: {
      size: {
        sm: "sm:max-w-[400px]",
        md: "sm:max-w-[480px]",
        lg: "sm:max-w-[560px]",
      },
      // The photo panel turns the card into a row; without one the card IS the
      // padded column, so it carries the padding itself. No `relative` here —
      // it would win the position group over the base `fixed` in tailwind-merge,
      // and `fixed` already anchors the absolute close button.
      photo: {
        none: "flex-col gap-4 p-6",
        left: "flex-row",
        right: "flex-row-reverse",
      },
    },
    compoundVariants: [
      {
        photo: ["left", "right"],
        size: ["sm", "md", "lg"],
        // With a photo the card is one fixed width, whatever the size axis says.
        class: "sm:max-w-[700px]",
      },
    ],
    defaultVariants: {
      size: "md",
      photo: "none",
    },
  },
);

const dialogHeaderVariants = cva(
  "flex flex-col gap-1 [&>[data-slot=dialog-icon]]:mb-3",
  {
    variants: {
      align: {
        left: "items-start text-left",
        center: "items-center text-center",
      },
    },
    defaultVariants: {
      align: "left",
    },
  },
);

const dialogIconVariants = cva(
  "flex size-12 shrink-0 items-center justify-center rounded-full text-icon-inverse [&_svg]:size-6",
  {
    variants: {
      variant: {
        success: "bg-success-middle",
        warning: "bg-warning-middle",
        error: "bg-negative-middle",
        info: "bg-info-middle",
      },
    },
    defaultVariants: {
      variant: "success",
    },
  },
);

const dialogFooterVariants = cva("flex", {
  variants: {
    direction: {
      right: "flex-row items-center justify-end gap-3",
      left: "flex-row items-center justify-start gap-3",
      "full-width": "flex-row items-center gap-3 *:flex-1",
      stacked: "flex-col-reverse gap-2 *:w-full",
    },
  },
  defaultVariants: {
    direction: "right",
  },
});

export type DialogProps = DialogPrimitive.Root.Props;

function Dialog({ ...props }: DialogProps) {
  return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}

export type DialogTriggerProps = DialogPrimitive.Trigger.Props;

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

export type DialogPortalProps = DialogPrimitive.Portal.Props;

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

export type DialogCloseProps = DialogPrimitive.Close.Props;

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

export type DialogOverlayProps = DialogPrimitive.Backdrop.Props;

function DialogOverlay({
  className,
  ...props
}: DialogOverlayProps) {
  return (
    <DialogPrimitive.Backdrop
      data-slot="dialog-overlay"
      className={cn(
        "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
        className,
      )}
      {...props}
    />
  );
}

export type DialogCloseButtonProps = DialogPrimitive.Close.Props;

/** The 40×40 icon button pinned to the top-right corner of a padded column. */
function DialogCloseButton({
  className,
  ...props
}: DialogCloseButtonProps) {
  return (
    <DialogPrimitive.Close
      data-slot="dialog-close"
      className={cn(
        "absolute top-0 right-0 inline-flex size-10 items-center justify-center rounded-lg text-icon-muted transition-colors hover:bg-bg-surface-1 focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow focus-visible:outline-none [&_svg]:size-6",
        className,
      )}
      {...props}
    >
      <Close02 />
      <span className="sr-only">Close</span>
    </DialogPrimitive.Close>
  );
}

export type DialogContentProps = DialogPrimitive.Popup.Props &
  VariantProps<typeof dialogContentVariants> & {
    showCloseButton?: boolean;
  };

function DialogContent({
  className,
  children,
  size,
  photo,
  showCloseButton = true,
  ...props
}: DialogContentProps) {
  return (
    <DialogPortal>
      <DialogOverlay />
      <DialogPrimitive.Popup
        data-slot="dialog-content"
        className={cn(dialogContentVariants({ size, photo }), className)}
        {...props}
      >
        {children}
        {/* With a photo the close button belongs to DialogPanel, which is the
            column it has to anchor to. */}
        {showCloseButton && (!photo || photo === "none") && <DialogCloseButton />}
      </DialogPrimitive.Popup>
    </DialogPortal>
  );
}

export type DialogPanelProps = React.ComponentProps<"div"> & { showCloseButton?: boolean };

/** The padded content column beside a DialogPhoto. */
function DialogPanel({
  className,
  children,
  showCloseButton = true,
  ...props
}: DialogPanelProps) {
  return (
    <div
      data-slot="dialog-panel"
      className={cn("relative flex flex-1 flex-col gap-4 p-6", className)}
      {...props}
    >
      {children}
      {showCloseButton && <DialogCloseButton />}
    </div>
  );
}

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

function DialogPhoto({ className, ...props }: DialogPhotoProps) {
  return (
    <div
      data-slot="dialog-photo"
      className={cn(
        "relative w-[300px] shrink-0 self-stretch overflow-hidden bg-bg-surface-1 *:size-full *:object-cover",
        className,
      )}
      {...props}
    />
  );
}

export type DialogHeaderProps = React.ComponentProps<"div"> &
  VariantProps<typeof dialogHeaderVariants>;

function DialogHeader({
  className,
  align,
  ...props
}: DialogHeaderProps) {
  return (
    <div
      data-slot="dialog-header"
      className={cn(dialogHeaderVariants({ align }), className)}
      {...props}
    />
  );
}

export type DialogIconProps = React.ComponentProps<"div"> & VariantProps<typeof dialogIconVariants>;

function DialogIcon({
  className,
  variant,
  ...props
}: DialogIconProps) {
  return (
    <div
      data-slot="dialog-icon"
      data-variant={variant ?? "success"}
      className={cn(dialogIconVariants({ variant }), className)}
      {...props}
    />
  );
}

export type DialogFooterProps = React.ComponentProps<"div"> & VariantProps<typeof dialogFooterVariants>;

function DialogFooter({
  className,
  direction,
  ...props
}: DialogFooterProps) {
  return (
    <div
      data-slot="dialog-footer"
      className={cn(dialogFooterVariants({ direction }), className)}
      {...props}
    />
  );
}

export type DialogTitleProps = DialogPrimitive.Title.Props;

function DialogTitle({ className, ...props }: DialogTitleProps) {
  return (
    <DialogPrimitive.Title
      data-slot="dialog-title"
      className={cn(
        "text-xl leading-[1.3] font-semibold text-text-strong",
        className,
      )}
      {...props}
    />
  );
}

export type DialogDescriptionProps = DialogPrimitive.Description.Props;

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

export {
  Dialog,
  DialogClose,
  DialogCloseButton,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogIcon,
  DialogOverlay,
  DialogPanel,
  DialogPhoto,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
  dialogContentVariants,
  dialogFooterVariants,
  dialogHeaderVariants,
  dialogIconVariants,
};