a modal for a decision, centred over the page, or a panel beside a live page
npx shadcn add @ag/dialogAlready installed? Preview an update
npx shadcn add @ag/dialog --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-14
DialogContenttakesbare: no surface, no padding and no motion of its own, for an object lifted off the page to be worked on (a card written on where it lies). Still a dialog: focus held, Esc and a press outside close it. With no fade of its own a view transition can carry the object in whole.backdropadds classes to the dim behind a centred one (a stronger dim, a blur).
also called modal
shelf controls
uses material, portal, scrims, scroll-area
used by command, components, form, sheet
the demo above, verbatim
"use client"
import * as React from "react"
import { flushSync } from "react-dom"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/base-nova/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
DialogTrigger,
} from "@/registry/base-nova/ui/dialog"
import { Image } from "@/registry/base-nova/ui/image"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Slider } from "@/registry/base-nova/ui/slider"
function Centre() {
return (
<Dialog>
<DialogTrigger render={<Button variant="secondary" />}>
forget smile
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>forget “smile”?</DialogTitle>
<DialogDescription>
every way it was asked goes, and the frames read for it stay for
other questions.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="ghost" />}>keep it</DialogClose>
<Button variant="destructive">forget</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// A picture lifted off the page, the dialog only placing it: carried there and
// back by a view transition (one name, worn by whichever copy is shown).
function Lifted() {
const [open, setOpen] = React.useState(false)
const change = (next: boolean) => {
if (!document.startViewTransition) return setOpen(next)
document.startViewTransition(() => flushSync(() => setOpen(next)))
}
const name = "[view-transition-name:lifted]"
return (
<Dialog open={open} onOpenChange={change}>
<DialogTrigger
className={cn(
"w-40 cursor-pointer overflow-hidden rounded-xl",
!open && name,
)}
>
<Image src="/lightbox/lake.jpg" alt="a lake" sizes="10rem" />
</DialogTrigger>
<DialogContent
bare
backdrop="bg-black/60 backdrop-blur-sm"
className="top-[12vh] w-[min(36rem,calc(100vw-2rem))] translate-y-0"
>
<DialogTitle className="sr-only">the lake</DialogTitle>
<Image
src="/lightbox/lake.jpg"
alt="a lake"
sizes="36rem"
className={cn("rounded-2xl", open && name)}
/>
</DialogContent>
</Dialog>
)
}
function Side() {
const [cut, setCut] = React.useState(0.8)
return (
<div className="flex items-center gap-4">
<Dialog place="side">
<DialogTrigger render={<Button variant="secondary" />}>
smile’s settings
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>smile</DialogTitle>
<DialogDescription>the page beside it stays live</DialogDescription>
</DialogHeader>
<DialogSection title="where a moment starts">
<Slider
label="threshold"
value={cut}
onValueChange={setCut}
snap="near"
marks={[
{ value: 0.5, label: "0.5" },
{ value: 0.8, label: "0.8" },
{ value: 1, label: "1" },
]}
format={(v) => v.toFixed(2)}
/>
</DialogSection>
</DialogContent>
</Dialog>
<span className="font-mono text-foreground/60 text-xs tabular-nums">
threshold here, live: {cut.toFixed(2)}
</span>
</div>
)
}
function OnGlass() {
return (
<MaterialProvider material="glass">
<div className="grid place-items-center rounded-2xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-12">
<Dialog>
<DialogTrigger render={<Button scheme="dark" />}>
forget smile
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>forget “smile”?</DialogTitle>
<DialogDescription>
every way it was asked goes; the frames read for it stay.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="secondary" />}>
keep it
</DialogClose>
<DialogClose render={<Button variant="destructive" />}>
forget
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</MaterialProvider>
)
}
export default function DialogDemo() {
return (
<div className="space-y-8">
<Sample name="centre" glance title="a decision, page held behind">
<Centre />
</Sample>
<Sample
name="glass"
title="in glass"
description="material glass round it: the dialog is glass, and so are the buttons in it"
>
<OnGlass />
</Sample>
<Sample
name="bare"
title="bare: an object lifted off the page"
description="no surface: what it holds is the object, carried in by a view transition and back out; still a dialog, Esc and a press outside close it"
>
<Lifted />
</Sample>
<Sample
name="side"
title="a panel beside a live page"
description="a press on the page leaves it open: change a setting, watch what it does"
>
<Side />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A dialog on Base UI (focus trapped and returned, Esc and an outside press close
// it, a title for screen readers), the parts named as shadcn names them so code
// written for shadcn composes it:
//
// <Dialog>
// <DialogTrigger render={<button />}>edit</DialogTrigger>
// <DialogContent>
// <DialogHeader>
// <DialogTitle>smile</DialogTitle>
// <DialogDescription>gemma, four frames a second</DialogDescription>
// </DialogHeader>
// …
// </DialogContent>
// </Dialog>
//
// Two places it can stand, `<Dialog place>`:
// center over the page, the page dimmed and held behind it: a decision to make
// side a panel along the right edge, the page live beside it: settings you
// change while watching what they do (a threshold dragged while the
// timeline under it re-cuts). Nothing is dimmed, nothing is held, and
// pressing the page does not close it.
// It never shifts the page: no scroll-lock padding rewrite. Two materials
// (`material` on DialogContent, or a MaterialProvider round it): plain, the page's
// popover surface; glass, the material's glass, heavier blur, dark by default.
import { Dialog as Base } from "@base-ui/react/dialog"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
type Material,
MaterialProvider,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { usePortalTarget } from "@/registry/base-nova/ui/portal"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
export type DialogPlace = "center" | "side"
const Place = React.createContext<DialogPlace>("center")
/** The place, read by every face's content. */
export const useDialogPlace = () => React.useContext(Place)
/** The dialog's state and its place. At the side it is not modal, and neither a
* press on the page nor focus moving there closes it: the page beside it is the
* point. `modal` and `disablePointerDismissal` still win when set. */
export function Dialog({
place = "center",
...props
}: React.ComponentProps<typeof Base.Root> & { place?: DialogPlace }) {
return (
<Place.Provider value={place}>
<Base.Root
modal={place === "center"}
disablePointerDismissal={place === "side"}
{...props}
/>
</Place.Provider>
)
}
/** What opens it; `render` makes any element the trigger. */
export const DialogTrigger = Base.Trigger
/** What closes it; `render` makes any element the close. */
export const DialogClose = Base.Close
/** Where each place puts the popup, and how it enters. */
export const DIALOG_PLACE: Record<DialogPlace, string> = {
center:
"fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[min(32rem,calc(100vw-2rem))] max-h-[calc(100dvh-2rem)] data-[ending-style]:scale-98 data-[starting-style]:scale-98",
side: "fixed top-3 right-3 bottom-3 w-[min(24rem,calc(100vw-1.5rem))] data-[ending-style]:translate-x-4 data-[starting-style]:translate-x-4",
}
/** The popup's frame, shared by every face: it stays whole, and its content scrolls
* inside it (DIALOG_BODY). */
const DIALOG_FRAME =
"flex flex-col outline-none data-[ending-style]:opacity-0 data-[starting-style]:opacity-0"
/** What the frame holds: it fills the frame and scrolls there, its cut edges fading
* where there is more (scroll-area); the room round the content is inside it, so
* nothing at its edge (a focus ring) is cut. A dialog scrolling as its frame
* faded its own surface, or showed a hard cut. An overlay, so its scroll stops at
* its ends rather than moving the page behind it. */
const DIALOG_BODY = "flex flex-1 flex-col gap-4 overscroll-contain p-5"
/** The popup's motion, one per face: the plain face's own transition, the glass
* face's through the material, which owns its transition (`--glass-moves`). */
export const DIALOG_MOTION: Record<Material, string> = {
plain:
"transition-[opacity,scale,translate] duration-150 ease-out motion-reduce:transition-none",
glass:
"[--glass-moves:opacity_150ms_var(--ease-out),scale_150ms_var(--ease-out),translate_150ms_var(--ease-out)]",
}
/** The page behind a modal one, dimmed (a sheet's too). */
export const DIALOG_BACKDROP =
"fixed inset-0 z-50 bg-black/40 transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0"
/** The plain face: the page's popover surface, a hairline, a deep shadow. */
const PLAIN_FACE =
"rounded-2xl border border-border bg-popover text-popover-foreground shadow-xl"
/** The glass face: the material's glass, its blur heavier than a menu's so the
* controls in it read over any frame; its own rim and shadows are its edge. Over a
* picture it is dark because what opened it stands in the dark scheme (a player's
* layer says so), never by a default of its own. The material is `relative` (its
* rim's layer hangs off it): the place's `fixed` comes after it. */
const GLASS_FACE = cn(
glassVariants({ shape: "surface" }),
"fixed border-0 [--glass-blur:28px] [--glass-radius:20px]",
)
/** A bare dialog's frame: no surface, no room round what it holds, and no motion
* of its own, so what it holds is the object and a view transition can carry it
* in from where it lay on the page (its snapshot taken whole, never mid-fade). */
const BARE_FRAME =
"flex flex-col outline-none data-[ending-style]:scale-100 data-[starting-style]:scale-100"
/** The popup, portalled, at the dialog's place: dimming the page behind it only
* when it stands in the center. `container`: where it portals (a player's own
* layer, so it shows in fullscreen). In its material, and so is everything inside
* it. `bare`: no face at all, for an object lifted off the page to be worked on
* (a card written on where it lies): still a dialog, focus held, Esc and a press
* outside closing it; `backdrop` adds to the dim behind it. */
export function DialogContent({
container,
material,
scheme,
bare = false,
backdrop,
className,
children,
ref,
...props
}: React.ComponentProps<typeof Base.Popup> & {
container?: React.ComponentProps<typeof Base.Portal>["container"]
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
/** No surface and no motion: what it holds is the object. */
bare?: boolean
/** Classes added to the dim behind a centred one (a stronger dim, a blur). */
backdrop?: string
}) {
const place = useDialogPlace()
const m = useMaterial(material)
const target = usePortalTarget()
// Over a picture (a player's layer, in fullscreen) it is read by the part of it
// under the dialog. Centred, the dim between them darkens that part as it darkens
// the whole, so the part is still the better witness of what it stands over.
const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
return (
<Base.Portal container={container ?? target}>
{place === "center" && (
<Base.Backdrop
data-slot="dialog-backdrop"
className={cn(DIALOG_BACKDROP, backdrop)}
/>
)}
<Base.Popup
ref={here}
data-slot="dialog-content"
data-place={place}
data-material={bare ? undefined : m}
data-bare={bare || undefined}
className={cn(
DIALOG_PLACE[place],
"z-50",
schemeOf(s),
bare
? BARE_FRAME
: cn(
DIALOG_FRAME,
m === "glass" ? GLASS_FACE : PLAIN_FACE,
DIALOG_MOTION[m],
),
className,
)}
{...props}
>
<MaterialProvider material={m} scheme={s}>
{bare ? (
children
) : (
// Its slot rides on `render`: the scroller's own props name it
// scroll-area.
<ScrollArea
render={<div data-slot="dialog-body" />}
className={DIALOG_BODY}
>
{children}
</ScrollArea>
)}
</MaterialProvider>
</Base.Popup>
</Base.Portal>
)
}
/** The title and description, and the close in the corner. */
export function DialogHeader({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex items-start gap-3", className)}
{...props}
>
<div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
<Base.Close
data-slot="dialog-close"
aria-label="close"
className="-mt-1 -mr-1 grid size-7 shrink-0 cursor-pointer place-items-center rounded-full text-current/55 hover:bg-current/8 hover:text-current"
>
<svg
viewBox="0 0 16 16"
aria-hidden
className="ag-icon size-3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</Base.Close>
</div>
)
}
export function DialogTitle({
className,
...props
}: React.ComponentProps<typeof Base.Title>) {
return (
<Base.Title
data-slot="dialog-title"
className={cn("truncate font-medium text-base leading-6", className)}
{...props}
/>
)
}
export function DialogDescription({
className,
...props
}: React.ComponentProps<typeof Base.Description>) {
return (
<Base.Description
data-slot="dialog-description"
className={cn("text-current/55 text-sm", className)}
{...props}
/>
)
}
/** A titled group of controls inside the dialog. */
export function DialogSection({
title,
className,
children,
...props
}: React.ComponentProps<"section"> & { title?: React.ReactNode }) {
return (
<section
data-slot="dialog-section"
className={cn("flex flex-col gap-2.5", className)}
{...props}
>
{title && (
<h3 className="font-mono text-[11px] text-current/45 uppercase tracking-wider">
{title}
</h3>
)}
{children}
</section>
)
}
/** The actions, at the foot, the main one last. */
export function DialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"mt-auto flex flex-wrap items-center justify-end gap-2",
className,
)}
{...props}
/>
)
}