- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Sizes
The panel keeps its surface, header, body and footer; only the width changes.
Header, body and footer
An optional icon above the title, a body that scrolls on its own, and a footer that stacks on phones.
Installation
$ pnpm dlx shadcn@latest add @cobalt/dialog
The dialog works with any theme and falls back to --background, --border and --muted for its surface, border and icon disc. Add theme-cobalt for the exact surface, border and shadow tokens.
$ pnpm dlx shadcn@latest add @cobalt/theme-cobalt
Parts
| Part | Use |
|---|---|
Dialog | The root. Controls open and onOpenChange. |
DialogTrigger | What opens the dialog. Use render to pass a Button. |
DialogContent | The panel. Renders the backdrop, the portal and a close button. |
DialogHeader | Shrink-wrapped top block; holds the icon, title and description. |
DialogIcon | A 40px disc above the title. variant="destructive" tints it red. |
DialogTitle | The accessible name. Required. |
DialogDescription | Supporting copy, wired to the panel's aria-describedby. |
DialogBody | Scrolling region between the header and the footer. |
DialogFooter | Actions. Stacks full-width on phones, a row from sm up. |
DialogClose | Closes the dialog. Use render to pass a Button. |
Sizes
DialogContent takes size: sm (400px), default (480px, the default), lg (640px) or xl (800px). On a phone the panel keeps 16px of page on each side.
placement="bottom" anchors the panel 40px above the bottom edge and slides it up from below, for compose-style dialogs that leave the page visible. backdrop="light" dims the page with black/20 instead of black/70. Both are opt-in; the default stays centred with the black/70 backdrop.
For a confirmation, pass role="alertdialog" and showCloseButton={false}, and add disablePointerDismissal to Dialog so a press on the backdrop leaves it open. Focus lands on the first tabbable element, which is the safe action when the footer lists it first.
Motion
The black/70 backdrop fades over 300ms. The panel fades and sharpens from scale(0.85) and a 4px blur over 300ms on cubic-bezier(0.32, 0.72, 0, 1), and plays the same transition in reverse before it unmounts. With prefers-reduced-motion: reduce the scale and blur are dropped and only the fade remains.
Source
"use client";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { RiCloseLine } from "@remixicon/react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "cn";
import * as React from "react";
import { useDialogScrollFade } from "@/components/hooks/use-dialog-scroll-fade";
import { Button } from "@/components/ui/button";
// A centred dialog over a dimmed page: focus is trapped inside and returns to
// the trigger on close, the page behind stops scrolling, and Escape or a press
// on the backdrop dismisses it.
//
// Surface and motion: the black/70 backdrop fades over 300ms; the panel fades
// and sharpens from scale 0.85 and a 4px blur over 300ms on the iOS sheet
// curve, and plays the same transition in reverse before it unmounts. Reduced
// motion keeps the fade and drops the scale and blur.
//
// Every colour references a token with a semantic fallback, so the dialog also
// works without theme-cobalt.
const dialogSizes = {
sm: "max-w-[400px]",
default: "max-w-[480px]",
lg: "max-w-[640px]",
xl: "max-w-[800px]",
} as const;
// A dialog opened from state (no DialogTrigger) has no trigger to give focus
// back to. Remember what had focus when `open` turned true: render runs before
// the dialog moves focus inside.
const DialogOpenerContext =
React.createContext<React.RefObject<Element | null> | null>(null);
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
const opener = React.useRef<Element | null>(null);
const wasOpen = React.useRef(false);
if (props.open && !wasOpen.current && typeof document !== "undefined") {
opener.current = document.activeElement;
}
wasOpen.current = !!props.open;
return (
<DialogOpenerContext.Provider value={opener}>
<DialogPrimitive.Root data-slot="dialog" {...props} />
</DialogOpenerContext.Provider>
);
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/70 transition-opacity duration-300 ease-out data-ending-style:opacity-0 data-starting-style:opacity-0",
className
)}
{...props}
/>
);
}
// Keep the panel within the visible area when a mobile keyboard resizes or
// pans the visual viewport. The backdrop still covers the layout viewport.
function DialogViewport({ children }: { children: React.ReactNode }) {
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const viewport = window.visualViewport;
const element = ref.current;
if (!viewport || !element) {
return;
}
const update = () => {
element.style.top = `${viewport.offsetTop}px`;
element.style.height = `${viewport.height}px`;
};
update();
viewport.addEventListener("resize", update);
viewport.addEventListener("scroll", update);
return () => {
viewport.removeEventListener("resize", update);
viewport.removeEventListener("scroll", update);
};
}, []);
return (
<div
ref={ref}
className="pointer-events-none fixed inset-x-0 top-0 z-50 h-dvh"
>
{children}
</div>
);
}
function DialogContent({
className,
children,
size = "default",
placement = "center",
backdrop = "default",
showCloseButton = true,
finalFocus,
...props
}: DialogPrimitive.Popup.Props & {
size?: keyof typeof dialogSizes;
/**
* `bottom` anchors the panel 40px above the bottom edge and slides it up
* from below, for compose-style dialogs that leave the page visible.
*/
placement?: "center" | "bottom";
/** `light` dims the page with black/20 instead of black/70. */
backdrop?: "default" | "light";
showCloseButton?: boolean;
}) {
const opener = React.useContext(DialogOpenerContext);
return (
<DialogPortal>
<DialogOverlay
className={backdrop === "light" ? "bg-black/20" : undefined}
/>
<DialogViewport>
<DialogPrimitive.Popup
data-slot="dialog-content"
data-size={size}
className={cn(
// A column, so DialogBody can scroll on its own while the header and
// footer stay put. overflow-clip rather than hidden: a hidden box is
// still programmatically scrollable, so focusing a field clipped by
// the body's scroller could scroll the whole panel instead.
"group/dialog pointer-events-auto absolute top-1/2 left-1/2 z-50 flex max-h-[calc(100%-2rem)] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 transform-gpu flex-col gap-4 overflow-clip rounded-[24px] border border-[color:var(--surface-strong,var(--border))] bg-[var(--surface-overlay,var(--background))] py-6 text-sm text-foreground shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] outline-none transition-[opacity,scale,filter] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:opacity-0 data-starting-style:opacity-0 motion-safe:data-ending-style:scale-[0.85] motion-safe:data-ending-style:blur-[4px] motion-safe:data-starting-style:scale-[0.85] motion-safe:data-starting-style:blur-[4px] sm:max-h-[calc(100%-3rem)]",
dialogSizes[size],
placement === "bottom" &&
"top-auto bottom-10 translate-y-0 [transition-property:opacity,scale,filter,translate] motion-safe:data-ending-style:translate-y-6 motion-safe:data-starting-style:translate-y-6",
className
)}
data-close-button={showCloseButton ? "" : undefined}
finalFocus={
finalFocus ??
(() => {
const element = opener?.current;
return element instanceof HTMLElement &&
element !== document.body &&
element.isConnected
? element
: true;
})
}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="close"
size="icon-xs"
className="absolute top-6 right-5"
/>
}
>
<RiCloseLine className="size-6" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogViewport>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn(
// Reserve the close button's column so a long title never runs under
// it. A centred header (text-center) reserves it on both sides, so the
// title stays on the dialog's centre line.
"flex shrink-0 flex-col items-start px-6 group-data-close-button/dialog:pe-14 group-data-close-button/dialog:[&.text-center]:ps-14",
className
)}
{...props}
/>
);
}
const dialogIconVariants = cva(
"mb-4 flex size-10 shrink-0 items-center justify-center rounded-full [&_svg:not([class*='size-'])]:size-5",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default:
"bg-[var(--border-subtle,var(--muted))] text-[color:var(--icon-foreground,var(--muted-foreground))]",
destructive:
"bg-[var(--notification-error-background,color-mix(in_oklch,var(--destructive)_15%,transparent))] text-[color:var(--notification-error-foreground,var(--destructive))]",
},
},
}
);
function DialogIcon({
className,
variant,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof dialogIconVariants>) {
return (
<span
data-slot="dialog-icon"
className={cn(dialogIconVariants({ variant }), className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-[18px] leading-[26px] font-medium tracking-normal text-foreground",
className
)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"mt-1 text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
);
}
function DialogScrollFade({ progress }: { progress: number }) {
return (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6"
>
<div
className="absolute inset-0 backdrop-blur-[1px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
style={{ opacity: progress }}
/>
<div
className="absolute inset-x-0 top-0 h-4 backdrop-blur-[4px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
style={{ opacity: progress }}
/>
<div
className="absolute inset-0 bg-linear-to-b from-[var(--surface-overlay,var(--background))] to-transparent"
style={{ opacity: progress }}
/>
</div>
);
}
// The content between the header and the footer. It scrolls on its own when
// the panel reaches the viewport's height, with the header and footer held in
// place and a progressive blur softening the top edge once scrolled.
function DialogBody({
className,
onScroll,
children,
...props
}: React.ComponentProps<"div">) {
const fade = useDialogScrollFade();
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div
data-slot="dialog-body"
onScroll={(event) => {
fade.onScroll(event);
onScroll?.(event);
}}
className={cn(
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-1 text-sm text-muted-foreground",
className
)}
{...props}
>
{children}
</div>
<DialogScrollFade progress={fade.progress} />
</div>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
// Stacked full-width on phones (primary action on top), a trailing row
// from sm up. 8px extra over the dialog's gap-4 sets the actions apart
// from the content.
"mt-2 flex shrink-0 flex-col-reverse gap-2 px-6 sm:flex-row sm:items-center sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
}
export {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogIcon,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
dialogIconVariants,
};