Alert Dialog

A modal dialog that interrupts the user with important content and expects a response.

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"

export default function AlertDialogDemo() {
  return (
    <AlertDialog>
      <AlertDialogTrigger render={<Button variant="outline" />}>Show dialog</AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
          <AlertDialogDescription>
            This action cannot be undone. This will permanently delete your account from our
            servers.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction>Continue</AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and copy state-styles.ts from the same page. Copy states.ts for the shared popup surface and install the Button for the action and cancel controls. Include interop.css for media icon sizing.

Copy and paste the following code into your project.

components/ui/alert-dialog.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import { createContext, use } from "react"
import type { ComponentProps, CSSProperties } from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { Button } from "@/components/ui/button"
import { stateStyles } from "@/lib/state-styles"
import { breakpoints } from "@/theme/breakpoints.stylex"
import { states } from "@/theme/states"
import {
  color,
  duration,
  ease,
  layer,
  leading,
  radius,
  shadow,
  space,
  text,
} from "@/theme/tokens.stylex"

const styles = stylex.create({
  overlayHidden: { opacity: 0 },
  hidden: { opacity: 0, transform: "scale(0.95)" },
  overlay: {
    position: "fixed",
    inset: 0,
    zIndex: layer.popup,
    backgroundColor: color.overlay,
    backdropFilter: "blur(2px)",
    transitionProperty: "opacity",
    transitionDuration: {
      default: duration.modal,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
  },
  viewport: {
    position: "fixed",
    inset: 0,
    zIndex: layer.popup,
    display: "grid",
    alignItems: "safe center",
    justifyItems: "center",
    overflow: "auto",
    padding: space.s4,
  },
  content: {
    display: "grid",
    gap: space.s4,
    width: "100%",
    borderRadius: radius.xl,
    padding: space.s5,
    boxShadow: shadow.popupLifted,
    fontSize: text.sm,
    lineHeight: leading.sm,
    transitionProperty: "opacity, transform",
    transitionDuration: {
      default: duration.modal,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
  },
  header: { display: "grid", rowGap: space.s1_5, justifyItems: "center", textAlign: "center" },
  headerInline: {
    gridTemplateColumns: { default: null, [breakpoints.md]: "auto 1fr" },
    justifyItems: { default: "center", [breakpoints.md]: "start" },
    textAlign: { default: "center", [breakpoints.md]: "start" },
  },
  textColumn: { gridColumnStart: { default: null, [breakpoints.md]: 2 } },
  media: {
    width: space.s10,
    height: space.s10,
    marginBlockEnd: space.s2,
    "--carbon-icon-size": space.s6,
  },
  mediaInline: {
    gridColumnStart: { default: null, [breakpoints.md]: 1 },
    gridRowStart: { default: null, [breakpoints.md]: "1" },
    gridRowEnd: { default: null, [breakpoints.md]: "span 2" },
    marginBlockEnd: { default: space.s2, [breakpoints.md]: 0 },
    marginInlineEnd: { default: null, [breakpoints.md]: space.s4 },
  },
  title: { fontSize: text.base, lineHeight: leading.base, fontWeight: 500 },
  description: { color: color.mutedForeground, textWrap: "balance" },
  footer: {
    display: "flex",
    flexDirection: { default: "column-reverse", [breakpoints.md]: "row" },
    justifyContent: { default: null, [breakpoints.md]: "flex-end" },
    gap: space.s2,
    marginInline: `calc(-1 * ${space.s5})`,
    marginBlockStart: `calc(${space.s1} - ${radius.xl})`,
    marginBlockEnd: `calc(-1 * ${space.s5})`,
    borderEndStartRadius: radius.xl,
    borderEndEndRadius: radius.xl,
  },
  footerSplit: { display: "grid", gridTemplateColumns: "1fr 1fr" },
})

const sizes = stylex.create({
  default: { maxWidth: { default: "20rem", [breakpoints.md]: "24rem" } },
  sm: { maxWidth: "20rem" },
})

const alertDialogStyles = { surface: states.popupSurface, base: styles.content, sizes }

type AlertDialogSize = keyof typeof sizes

const AlertDialogContext = createContext<{ size: AlertDialogSize }>({ size: "default" })

type Overrides = { xstyle?: StyleXStyles; className?: string }
type StateOverrides = Overrides & { style?: CSSProperties }

function AlertDialog<Payload>(props: AlertDialogPrimitive.Root.Props<Payload>) {
  return <AlertDialogPrimitive.Root {...props} />
}

function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) {
  return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
}

function AlertDialogPortal(props: AlertDialogPrimitive.Portal.Props) {
  return <AlertDialogPrimitive.Portal {...props} />
}

type AlertDialogOverlayProps = Omit<AlertDialogPrimitive.Backdrop.Props, "className" | "style"> &
  StateOverrides

function AlertDialogOverlay({ xstyle, className, style, ...props }: AlertDialogOverlayProps) {
  return (
    <AlertDialogPrimitive.Backdrop
      data-slot="alert-dialog-overlay"
      {...props}
      {...stateStyles(
        (state) => [
          styles.overlay,
          (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
            styles.overlayHidden,
          xstyle,
        ],
        { className, style },
      )}
    />
  )
}

type AlertDialogViewportProps = Omit<AlertDialogPrimitive.Viewport.Props, "className"> & Overrides

function AlertDialogViewport({ xstyle, className, ...props }: AlertDialogViewportProps) {
  const sx = stylex.props(styles.viewport, xstyle)
  return (
    <AlertDialogPrimitive.Viewport
      data-slot="alert-dialog-viewport"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type AlertDialogContentProps = Omit<AlertDialogPrimitive.Popup.Props, "className" | "style"> &
  StateOverrides & {
    size?: AlertDialogSize
    overlayProps?: AlertDialogOverlayProps
    viewportProps?: AlertDialogViewportProps
  }

function AlertDialogContent({
  xstyle,
  className,
  style,
  size = "default",
  overlayProps,
  viewportProps,
  children,
  ...props
}: AlertDialogContentProps) {
  return (
    <AlertDialogPortal>
      <AlertDialogOverlay {...overlayProps} />
      <AlertDialogViewport {...viewportProps}>
        <AlertDialogPrimitive.Popup
          data-slot="alert-dialog-content"
          data-size={size}
          {...props}
          {...stateStyles(
            (state) => [
              states.popupSurface,
              styles.content,
              sizes[size],
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                styles.hidden,
              xstyle,
            ],
            { className, style },
          )}
        >
          <AlertDialogContext value={{ size }}>{children}</AlertDialogContext>
        </AlertDialogPrimitive.Popup>
      </AlertDialogViewport>
    </AlertDialogPortal>
  )
}

function AlertDialogHeader({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const { size } = use(AlertDialogContext)
  const sx = stylex.props(styles.header, size === "default" && styles.headerInline, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "alert-dialog-header",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function AlertDialogMedia({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const { size } = use(AlertDialogContext)
  const sx = stylex.props(
    states.iconTile,
    styles.media,
    size === "default" && styles.mediaInline,
    xstyle,
  )
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "alert-dialog-media",
      ...sx,
      ...props,
      className: clsx("carbon-icons", sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function AlertDialogTitle({
  xstyle,
  className,
  ...props
}: AlertDialogPrimitive.Title.Props & Overrides) {
  const { size } = use(AlertDialogContext)
  const sx = stylex.props(styles.title, size === "default" && styles.textColumn, xstyle)
  return (
    <AlertDialogPrimitive.Title
      data-slot="alert-dialog-title"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

function AlertDialogDescription({
  xstyle,
  className,
  ...props
}: AlertDialogPrimitive.Description.Props & Overrides) {
  const { size } = use(AlertDialogContext)
  const sx = stylex.props(styles.description, size === "default" && styles.textColumn, xstyle)
  return (
    <AlertDialogPrimitive.Description
      data-slot="alert-dialog-description"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

function AlertDialogFooter({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const { size } = use(AlertDialogContext)
  const sx = stylex.props(
    states.trayFooter,
    styles.footer,
    size === "sm" && styles.footerSplit,
    xstyle,
  )
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "alert-dialog-footer",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function AlertDialogAction(props: ComponentProps<typeof Button>) {
  return <Button data-slot="alert-dialog-action" {...props} />
}

type AlertDialogCancelProps = AlertDialogPrimitive.Close.Props &
  Pick<ComponentProps<typeof Button>, "variant" | "size" | "xstyle">

function AlertDialogCancel({
  variant = "secondary",
  size,
  xstyle,
  ...props
}: AlertDialogCancelProps) {
  return (
    <AlertDialogPrimitive.Close
      data-slot="alert-dialog-cancel"
      render={<Button variant={variant} size={size} xstyle={xstyle} />}
      {...props}
    />
  )
}

export {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogMedia,
  AlertDialogOverlay,
  AlertDialogPortal,
  alertDialogStyles,
  AlertDialogTitle,
  AlertDialogTrigger,
  AlertDialogViewport,
  type AlertDialogSize,
}

Update the import paths to match your project setup.

Usage

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
<AlertDialog>
  <AlertDialogTrigger render={<Button variant="outline" />}>Show dialog</AlertDialogTrigger>
  <AlertDialogContent>
    <AlertDialogHeader>
      <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
      <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
    </AlertDialogHeader>
    <AlertDialogFooter>
      <AlertDialogCancel>Cancel</AlertDialogCancel>
      <AlertDialogAction>Continue</AlertDialogAction>
    </AlertDialogFooter>
  </AlertDialogContent>
</AlertDialog>

An alert dialog is always modal and never closes on an outside press or on Escape. Every path out of it is a button you render.

Composition

AlertDialog
├── AlertDialogTrigger
└── AlertDialogContent     portal + overlay + viewport + popup
    ├── AlertDialogHeader
    │   ├── AlertDialogMedia
    │   ├── AlertDialogTitle
    │   └── AlertDialogDescription
    └── AlertDialogFooter
        ├── AlertDialogCancel
        └── AlertDialogAction

AlertDialogFooter is a tray, and the content above it is a panel raised out of it. Keep the footer the last child of AlertDialogContent. It bleeds to the popup's edges and draws the panel's bottom edge itself, so the panel needs no wrapper.

Size

Use the size prop on AlertDialogContent. sm stays narrow at every width and splits its footer into two equal columns.

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
  type AlertDialogSize,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { HStack } from "@/components/ui/stack"

const sizes: AlertDialogSize[] = ["default", "sm"]

export default function AlertDialogSizeDemo() {
  return (
    <HStack gap={2}>
      {sizes.map((size) => (
        <AlertDialog key={size}>
          <AlertDialogTrigger render={<Button variant="outline" />}>{size}</AlertDialogTrigger>
          <AlertDialogContent size={size}>
            <AlertDialogHeader>
              <AlertDialogTitle>Publish this release?</AlertDialogTitle>
              <AlertDialogDescription>
                Everyone on your team will be notified.
              </AlertDialogDescription>
            </AlertDialogHeader>
            <AlertDialogFooter>
              <AlertDialogCancel>Cancel</AlertDialogCancel>
              <AlertDialogAction>Publish</AlertDialogAction>
            </AlertDialogFooter>
          </AlertDialogContent>
        </AlertDialog>
      ))}
    </HStack>
  )
}

Media

Use AlertDialogMedia for an icon above the title. At default size it moves beside the text from the md breakpoint up.

import { CircleFadingPlus } from "lucide-react"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogMedia,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"

export default function AlertDialogMediaDemo() {
  return (
    <AlertDialog>
      <AlertDialogTrigger render={<Button variant="outline" />}>Share project</AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogMedia>
            <CircleFadingPlus />
          </AlertDialogMedia>
          <AlertDialogTitle>Share this project?</AlertDialogTitle>
          <AlertDialogDescription>
            Anyone with the link will be able to view and edit it.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction>Share</AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

Destructive

Pass variant="destructive" to AlertDialogAction, and tint the media through xstyle to match.

import * as stylex from "@stylexjs/stylex"
import { Trash2 } from "lucide-react"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogMedia,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { color } from "@/theme/tokens.stylex"

const styles = stylex.create({
  media: { backgroundColor: color.buttonDestructiveBg, color: color.destructive },
})

export default function AlertDialogDestructiveDemo() {
  return (
    <AlertDialog>
      <AlertDialogTrigger render={<Button variant="destructive" />}>Delete chat</AlertDialogTrigger>
      <AlertDialogContent size="sm">
        <AlertDialogHeader>
          <AlertDialogMedia xstyle={styles.media}>
            <Trash2 />
          </AlertDialogMedia>
          <AlertDialogTitle>Delete chat?</AlertDialogTitle>
          <AlertDialogDescription>
            This permanently deletes the conversation and any memories saved during it.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction variant="destructive">Delete</AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

AlertDialogAction does not close the dialog. It is a plain Button, so close it from onClick or once the work it starts settles. That way a failed delete leaves the dialog open.

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI AlertDialog part of the same name and accepts its props. See the Base UI reference for the full list. Each styled part also takes xstyle and className.

AlertDialogContent

PropTypeDefault
size"default" | "sm""default"
overlayPropsAlertDialogOverlayProps—
viewportPropsAlertDialogViewportProps—

AlertDialogContent passes size to the header, media and footer through context, so each of them changes layout without an ancestor selector.

AlertDialogAction

Takes every Button prop. variant defaults to "default".

AlertDialogCancel

Renders AlertDialog.Close as a Button and closes the dialog on press.

PropTypeDefault
variantButtonVariant"secondary"
sizeButtonSize—

alertDialogStyles

KeyContents
surfaceThe shared popup surface: fill, foreground, hairline
baseThe popup box: radius, padding, grid, shadow, motion
sizesdefault and sm, each a maxWidth

Data attributes

AttributeOnEffect
data-slot="alert-dialog-content"the popupPopup slot
data-slot="alert-dialog-overlay"the scrimBackdrop surface
data-sizethe popupThe size prop, for selecting on from outside
data-starting-style, data-ending-stylepopup and overlayBase UI writes them while the dialog animates
data-open, data-closedpopup and overlayBase UI writes them