Dialog

A window overlaid on the page, rendering the content underneath inert.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { color, font, leading, radius, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  link: {
    overflow: "hidden",
    borderRadius: radius.md,
    borderWidth: 1,
    borderStyle: "solid",
    paddingInline: space.s2_5,
    paddingBlock: space.s2,
    backgroundColor: `color-mix(in oklab, ${color.muted} 50%, transparent)`,
    fontFamily: font.mono,
    fontSize: text.xs,
    lineHeight: leading.xs,
    textOverflow: "ellipsis",
    whiteSpace: "nowrap",
  },
})

export default function DialogDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Share project</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Share project</DialogTitle>
          <DialogDescription>Anyone with this link can view the project.</DialogDescription>
        </DialogHeader>
        <div {...stylex.props(styles.link)}>https://carbon.style/p/9f3a12</div>
        <DialogFooter>
          <DialogClose render={<Button variant="secondary" />}>Cancel</DialogClose>
          <Button>Copy link</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Installation

Install the following dependencies:

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

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 close control.

Copy and paste the following code into your project.

components/ui/dialog.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import { useState } from "react"
import type { CSSProperties, ReactNode } from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"
import { X } from "lucide-react"

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: {
    position: "relative",
    display: "grid",
    gap: space.s4,
    width: "100%",
    maxWidth: "24rem",
    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,
  },
  close: { position: "absolute", insetBlockStart: space.s3, insetInlineEnd: space.s3 },
  header: { display: "grid", rowGap: space.s1 },
  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,
  },
  title: { fontSize: text.base, lineHeight: leading.base, fontWeight: 500 },
  description: { color: color.mutedForeground },
})

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

function Dialog<Payload>(props: DialogPrimitive.Root.Props<Payload>) {
  return <DialogPrimitive.Root {...props} />
}

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

function DialogPortal(props: DialogPrimitive.Portal.Props) {
  return <DialogPrimitive.Portal {...props} />
}

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

type DialogOverlayProps = Omit<DialogPrimitive.Backdrop.Props, "className" | "style"> &
  StateOverrides

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

type DialogViewportProps = Omit<DialogPrimitive.Viewport.Props, "className"> & Overrides

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

type DialogContentProps = Omit<DialogPrimitive.Popup.Props, "className" | "style"> &
  StateOverrides & {
    showCloseButton?: boolean
    overlayProps?: DialogOverlayProps
    viewportProps?: DialogViewportProps
  }

function DialogContent({
  xstyle,
  className,
  style,
  children,
  showCloseButton = true,
  overlayProps,
  viewportProps,
  ...props
}: DialogContentProps) {
  return (
    <DialogPortal>
      <DialogOverlay {...overlayProps} />
      <DialogViewport {...viewportProps}>
        <DialogPrimitive.Popup
          data-slot="dialog-content"
          {...props}
          {...stateStyles(
            (state) => [
              states.popupSurface,
              styles.content,
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                styles.hidden,
              xstyle,
            ],
            { className, style },
          )}
        >
          {children}
          {showCloseButton && (
            <DialogClose
              render={<Button variant="ghost" size="icon-sm" xstyle={styles.close} />}
              aria-label="Close"
            >
              <X />
            </DialogClose>
          )}
        </DialogPrimitive.Popup>
      </DialogViewport>
    </DialogPortal>
  )
}

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

type DialogFooterProps = useRender.ComponentProps<"div"> & Overrides & { showCloseButton?: boolean }

function DialogFooter({
  render,
  ref,
  style,
  xstyle,
  className,
  children,
  showCloseButton = false,
  ...props
}: DialogFooterProps) {
  const sx = stylex.props(states.trayFooter, styles.footer, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "dialog-footer",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
      children: (
        <>
          {children}
          {showCloseButton && (
            <DialogClose render={<Button variant="secondary" />}>Close</DialogClose>
          )}
        </>
      ),
    },
  })
}

function DialogTitle({ xstyle, className, ...props }: DialogPrimitive.Title.Props & Overrides) {
  const sx = stylex.props(styles.title, xstyle)
  return (
    <DialogPrimitive.Title
      data-slot="dialog-title"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

function DialogDescription({
  xstyle,
  className,
  ...props
}: DialogPrimitive.Description.Props & Overrides) {
  const sx = stylex.props(styles.description, xstyle)
  return (
    <DialogPrimitive.Description
      data-slot="dialog-description"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type DefinedDialogRootProps<Payload> = Omit<
  DialogPrimitive.Root.Props<Payload>,
  "children" | "handle" | "open" | "defaultOpen" | "triggerId" | "defaultTriggerId"
>

type DefinedDialog<Payload> = {
  open: (payload: Payload) => void
  close: () => void
  Root: (props: DefinedDialogRootProps<Payload>) => ReactNode
}

function defineDialog<Payload = void>(
  render: (payload: Payload) => ReactNode,
): DefinedDialog<Payload> {
  const handle = DialogPrimitive.createHandle<Payload>()

  function Root({ onOpenChange, onOpenChangeComplete, ...props }: DefinedDialogRootProps<Payload>) {
    const [mounted, setMounted] = useState(false)

    return (
      <DialogPrimitive.Root
        handle={handle}
        onOpenChange={(open, details) => {
          onOpenChange?.(open, details)
          if (open && !details.isCanceled) setMounted(true)
        }}
        onOpenChangeComplete={(open) => {
          onOpenChangeComplete?.(open)
          if (!open) setMounted(false)
        }}
        {...props}
      >
        {({ payload }) => (mounted ? render(payload as Payload) : null)}
      </DialogPrimitive.Root>
    )
  }

  return {
    open: (payload: Payload) => handle.openWithPayload(payload),
    close: () => handle.close(),
    Root,
  }
}

export {
  defineDialog,
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
  DialogViewport,
  type DefinedDialog,
}

Update the import paths to match your project setup.

Usage

import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
<Dialog>
  <DialogTrigger render={<Button variant="outline" />}>Share project</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Share project</DialogTitle>
      <DialogDescription>Anyone with this link can view the project.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
      <Button>Copy link</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Composition

Dialog
├── DialogTrigger
└── DialogContent          portal + overlay + viewport + popup
    ├── DialogHeader
    │   ├── DialogTitle
    │   └── DialogDescription
    └── DialogFooter
        └── DialogClose

DialogContent renders the portal, the overlay, the viewport and the popup in one part. Pass props to the overlay and the viewport through overlayProps and viewportProps, or compose DialogPortal, DialogOverlay, DialogViewport yourself.

DialogFooter is drawn as a tray, and the content above it is drawn as a raised panel with rounded bottom corners and a hairline shadow. Keep the footer the last child of DialogContent. It extends to the popup's edges and draws the panel's bottom edge itself, so the panel needs no wrapper.

Custom close button

Use showCloseButton={false} to drop the corner button, then put your own controls in the footer.

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

export default function DialogCustomCloseDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>
      <DialogContent showCloseButton={false}>
        <DialogHeader>
          <DialogTitle>Leave without saving?</DialogTitle>
          <DialogDescription>Your changes to this draft will be lost.</DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <DialogClose render={<Button variant="secondary" />}>Keep editing</DialogClose>
          <DialogClose render={<Button variant="destructive" />}>Discard</DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

No close button

Use showCloseButton={false} on DialogContent to hide the corner button. DialogFooter takes showCloseButton too, which appends a Close button to the row.

import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"

export default function DialogNoCloseButtonDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>
      <DialogContent showCloseButton={false}>
        <DialogHeader>
          <DialogTitle>Session expired</DialogTitle>
          <DialogDescription>Sign in again to pick up where you left off.</DialogDescription>
        </DialogHeader>
        <DialogFooter showCloseButton />
      </DialogContent>
    </Dialog>
  )
}

Scrollable content

A dialog taller than the window scrolls in its viewport. Nothing is clipped, and you do not need to set a height.

import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { VStack } from "@/components/ui/stack"

const clauses = [
  "You may use the service for any lawful purpose, on any number of projects.",
  "Accounts are personal. Do not share credentials with anyone outside your team.",
  "We store usage data for ninety days, then delete it.",
  "Either party may end this agreement at any time, with no notice period.",
  "Fees already paid for the current billing period are not refunded.",
  "We may update these terms, and will announce any change two weeks ahead.",
  "The service is provided as is, without warranty of any kind.",
  "These terms are governed by the laws of the jurisdiction you signed up from.",
]

export default function DialogScrollableContentDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Read terms</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Terms of service</DialogTitle>
          <DialogDescription>Last updated 14 September 2026.</DialogDescription>
        </DialogHeader>
        <VStack gap={3}>
          {clauses.map((clause, index) => (
            <p key={clause}>
              {index + 1}. {clause}
            </p>
          ))}
        </VStack>
        <DialogFooter showCloseButton />
      </DialogContent>
    </Dialog>
  )
}

To scroll the body instead, clamp maxHeight on the content through xstyle and give the body overflow-y: auto. The content is a grid. Set gridTemplateRows so the body is the row that shrinks to fit the clamp.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { VStack } from "@/components/ui/stack"
import { color, radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  content: { maxHeight: "min(80vh, 28rem)", gridTemplateRows: "auto 1fr auto" },
  body: {
    overflowY: "auto",
    marginInline: `calc(-1 * ${space.s5})`,
    paddingInline: space.s5,
  },
  row: {
    borderRadius: radius.md,
    borderWidth: 1,
    borderStyle: "solid",
    paddingInline: space.s3,
    paddingBlock: space.s2,
  },
  meta: { color: color.mutedForeground },
})

const items = [
  { title: "Update the Q4 revenue projection", owner: "Sarah", due: "20 Feb" },
  { title: "Schedule the vendor review", owner: "Mike", due: "22 Feb" },
  { title: "Draft the headcount proposal", owner: "Lisa", due: "25 Feb" },
  { title: "Review the marketing allocation", owner: "James", due: "28 Feb" },
  { title: "Write the customer success playbook", owner: "Sarah", due: "1 Mar" },
  { title: "Segment the NPS survey by account size", owner: "James", due: "3 Mar" },
  { title: "Kick off the retention task force", owner: "Mike", due: "5 Mar" },
  { title: "Finalise the Q4 content calendar", owner: "Lisa", due: "7 Mar" },
]

export default function DialogStickyFooterDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Review action items</DialogTrigger>
      <DialogContent xstyle={styles.content}>
        <DialogHeader>
          <DialogTitle>Action items</DialogTitle>
          <DialogDescription>Eight items captured in this meeting.</DialogDescription>
        </DialogHeader>
        <VStack gap={2} xstyle={styles.body}>
          {items.map((item) => (
            <div key={item.title} {...stylex.props(styles.row)}>
              <p>{item.title}</p>
              <p {...stylex.props(styles.meta)}>
                {item.owner} &middot; due {item.due}
              </p>
            </div>
          ))}
        </VStack>
        <DialogFooter showCloseButton>
          <Button>Send reminders</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Imperative API

defineDialog builds a dialog with no trigger in the tree. Call open(payload) from any event handler; the payload is typed and is passed to the render function.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  defineDialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import { VStack } from "@/components/ui/stack"
import { color } from "@/theme/tokens.stylex"

type Release = { version: string; date: string; summary: string }

const releaseDialog = defineDialog<Release>((release) => (
  <DialogContent>
    <DialogHeader>
      <DialogTitle>carbon {release.version}</DialogTitle>
      <DialogDescription>Released {release.date}.</DialogDescription>
    </DialogHeader>
    <p>{release.summary}</p>
    <DialogFooter>
      <Button variant="secondary" onClick={() => releaseDialog.close()}>
        Close
      </Button>
    </DialogFooter>
  </DialogContent>
))

const releases: Release[] = [
  {
    version: "0.4.0",
    date: "14 September 2026",
    summary: "Dialog and alert dialog, both on the Base UI viewport.",
  },
  {
    version: "0.3.0",
    date: "2 September 2026",
    summary: "Button groups, and a dropdown menu that reverses mid-exit.",
  },
  {
    version: "0.2.0",
    date: "18 August 2026",
    summary: "The token layer moved to light-dark() pairs.",
  },
]

const styles = stylex.create({
  meta: { color: color.mutedForeground },
})

export default function DialogImperativeDemo() {
  return (
    <VStack gap={2} align="start">
      {releases.map((release) => (
        <Button key={release.version} variant="outline" onClick={() => releaseDialog.open(release)}>
          {release.version}
          <span {...stylex.props(styles.meta)}>{release.date}</span>
        </Button>
      ))}
      <releaseDialog.Root />
    </VStack>
  )
}

Define it at module scope

defineDialog<Payload>(render) returns { open, close, Root }. Keep it in a module so any caller can import it.

import { defineDialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"

export const renameDialog = defineDialog<{ id: string; name: string }>((project) => (
  <RenameDialogContent {...project} />
))

Put state in the body

The render function runs only while the dialog is on screen, so state declared inside it starts fresh on every open.

function RenameDialogContent({ id, name }: { id: string; name: string }) {
  const [value, setValue] = useState(name)

  return (
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Rename {name}</DialogTitle>
      </DialogHeader>
      <input value={value} onChange={(event) => setValue(event.target.value)} />
      <DialogFooter>
        <Button
          onClick={() => {
            renameProject(id, value)
            renameDialog.close()
          }}
        >
          Save
        </Button>
      </DialogFooter>
    </DialogContent>
  )
}

Mount the root once

export function AppDialogs() {
  return <renameDialog.Root />
}

Open it from anywhere

renameDialog.open({ id: project.id, name: project.name })

API reference

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

Every part wraps the Base UI Dialog 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.

Dialog

PropTypeDefault
openboolean—
defaultOpenbooleanfalse
onOpenChange(open: boolean, details) => void—
modalboolean | "trap-focus"true
disablePointerDismissalbooleanfalse
handleDialogHandle—

Call details.cancel() inside onOpenChange to block a close. Use this to write an unsaved-changes guard.

DialogContent

PropTypeDefault
showCloseButtonbooleantrue
overlayPropsDialogOverlayProps—
viewportPropsDialogViewportProps—
initialFocusboolean | RefObject | (type) => …—
finalFocusboolean | RefObject | (type) => …—

DialogFooter

PropTypeDefault
showCloseButtonbooleanfalse

defineDialog

MemberType
open(payload)(payload: Payload) => void
close()() => void
Rootevery Dialog prop except open, defaultOpen, handle and children

Mount Root somewhere that stays mounted across navigation. It renders into <body> through a portal, so its place in the tree only decides when it mounts and unmounts.

Data attributes

AttributeOnEffect
data-slot="dialog-content"the popupPopup slot
data-slot="dialog-overlay"the scrimBackdrop surface
data-starting-style, data-ending-stylepopup and overlayBase UI writes them while the dialog animates
data-open, data-closedpopup and overlayBase UI writes them
data-nested, data-nested-dialog-openpopupSet when dialogs are stacked