Glass dialog
A dialog panel made of glass over a light scrim, with focus handled by radix.
npx shadcn@latest add https://ui.goodtabs.co/r/glass-dialog.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/glass-dialog
A trigger over a photo. The dialog opens inside the frame.
Confirm
An alert dialog with a destructive question, on a second photo.
Usage
import { GlassDialog, GlassDialogTrigger, GlassDialogContent, GlassDialogHeader, GlassDialogTitle, GlassDialogDescription } from "@/components/ui/glass-dialog"
<GlassDialog>
<GlassDialogTrigger>Open</GlassDialogTrigger>
<GlassDialogContent>
<GlassDialogHeader>
<GlassDialogTitle>Title</GlassDialogTitle>
<GlassDialogDescription>One line.</GlassDialogDescription>
</GlassDialogHeader>
</GlassDialogContent>
</GlassDialog>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| container | HTMLElement | null | Mount and position the dialog inside this element, which needs position relative and overflow hidden. Omit it to cover the window. | |
| role | "dialog" | "alertdialog" | "dialog" | Use alertdialog for confirmations. |
Source
glass-dialog/glass-dialog.tsx
"use client";
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogHeader,
DialogPortal,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { GlassSurface } from "@/components/ui/glass-surface";
import { cn } from "@/lib/utils";
type GlassDialogContentProps = React.ComponentProps<typeof DialogPrimitive.Content> & {
/**
* Mount the dialog inside this element instead of the page, and position it inside that element.
* The element needs position relative and overflow hidden. Leave it out to cover the whole window.
*/
container?: HTMLElement | null;
};
/**
* The dialog panel as a pane of glass over a light scrim. Focus is radix's: it moves into the panel
* on open, stays inside while open, and returns to the trigger on close. Escape and the close button
* both close it. With reduced transparency asked for, the panel turns into a solid popover surface.
*/
function GlassDialogContent({ className, children, container, ...props }: GlassDialogContentProps) {
const place = container ? "absolute" : "fixed";
return (
<DialogPortal container={container ?? undefined}>
<DialogPrimitive.Overlay
data-slot="glass-dialog-overlay"
className={cn(
place,
"inset-0 isolate z-50 bg-black/30 duration-100 motion-reduce:animate-none data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
)}
/>
<DialogPrimitive.Content
data-slot="glass-dialog-content"
className={cn(
place,
"top-1/2 left-1/2 z-50 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 duration-100 outline-none motion-reduce:animate-none data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
)}
{...props}
>
<GlassSurface
radius={24}
rim={16}
strength={30}
className={cn(
"grid gap-4 rounded-3xl bg-background/70 p-5 text-sm text-foreground [@media(prefers-reduced-transparency:reduce)]:bg-popover",
className,
)}
>
{children}
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
</GlassSurface>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function GlassDialogDescription({ className, ...props }: React.ComponentProps<typeof DialogDescription>) {
return <DialogDescription className={cn("text-foreground", className)} {...props} />;
}
function GlassDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="glass-dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
export {
Dialog as GlassDialog,
DialogClose as GlassDialogClose,
GlassDialogContent,
GlassDialogDescription,
GlassDialogFooter,
DialogHeader as GlassDialogHeader,
DialogTitle as GlassDialogTitle,
DialogTrigger as GlassDialogTrigger,
};
components/ui/glass-surface.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type GlassSurfaceProps = React.ComponentProps<"div"> & {
/** Corner radius in pixels. Keep it equal to the rounded class on the surface, so the rim follows the edge. */
radius?: number;
/** Width of the bent rim in pixels. */
rim?: number;
/** How far the rim pushes what is behind it, in pixels. */
strength?: number;
/** Frost behind the glass, in pixels. */
blur?: number;
};
/** Distance from a point to the edge of a rounded rectangle centred on the origin. Negative is inside. */
function roundedBoxDistance(x: number, y: number, halfWidth: number, halfHeight: number, radius: number) {
const qx = Math.abs(x) - halfWidth + radius;
const qy = Math.abs(y) - halfHeight + radius;
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - radius;
}
/**
* Paints a displacement map: red and green hold how far each pixel is pushed, 128 is no push.
* Inside the rim the push points along the edge normal and falls off with a quarter circle, the
* profile of a lens edge, so the centre of the glass stays flat.
*/
function paintRefractionMap(width: number, height: number, radius: number, rim: number) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return null;
const image = context.createImageData(width, height);
const halfWidth = width / 2;
const halfHeight = height / 2;
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
const x = px + 0.5 - halfWidth;
const y = py + 0.5 - halfHeight;
const edge = roundedBoxDistance(x, y, halfWidth, halfHeight, radius);
const depth = Math.min(Math.max(-edge / rim, 0), 1);
const bend = 1 - Math.sqrt(1 - (1 - depth) * (1 - depth));
// The edge normal, by central differences on the distance field.
const nx = roundedBoxDistance(x + 1, y, halfWidth, halfHeight, radius) - roundedBoxDistance(x - 1, y, halfWidth, halfHeight, radius);
const ny = roundedBoxDistance(x, y + 1, halfWidth, halfHeight, radius) - roundedBoxDistance(x, y - 1, halfWidth, halfHeight, radius);
const length = Math.hypot(nx, ny) || 1;
const at = (py * width + px) * 4;
image.data[at] = 128 - (nx / length) * bend * 127;
image.data[at + 1] = 128 - (ny / length) * bend * 127;
image.data[at + 2] = 128;
image.data[at + 3] = 255;
}
}
context.putImageData(image, 0, 0);
return canvas.toDataURL();
}
/**
* A pane of glass. Chromium bends what is behind it with an SVG filter; every other browser gets the
* frosted fallback, which is the same surface without the bent rim. Either way the pane keeps enough
* fill to hold text, and it turns solid for people who ask for less transparency.
*/
function GlassSurface({
radius = 12,
rim = 14,
strength = 40,
blur = 6,
className,
style,
children,
...props
}: GlassSurfaceProps) {
const id = React.useId().replace(/[^a-zA-Z0-9]/g, "");
const surface = React.useRef<HTMLDivElement>(null);
const [map, setMap] = React.useState<string | null>(null);
React.useEffect(() => {
const el = surface.current;
if (!el) return;
// backdrop-filter with a url() only renders in Chromium, and other browsers can parse it and draw nothing.
if (!("userAgentData" in navigator) || !CSS.supports("backdrop-filter", "url(#a)")) return;
const paint = () => {
const width = Math.round(el.clientWidth);
const height = Math.round(el.clientHeight);
if (width > 0 && height > 0) setMap(paintRefractionMap(width, height, radius, rim));
};
const observer = new ResizeObserver(paint);
observer.observe(el);
return () => observer.disconnect();
}, [radius, rim]);
const refracting = map !== null;
return (
<div
ref={surface}
data-slot="glass-surface"
data-refracting={refracting || undefined}
className={cn(
"relative border bg-background/50 shadow-[inset_0_1px_0_0_color-mix(in_oklab,var(--foreground)_8%,transparent)] backdrop-blur-md contrast-more:bg-background [@media(prefers-reduced-transparency:reduce)]:bg-background [@media(prefers-reduced-transparency:reduce)]:backdrop-blur-none",
className,
)}
style={refracting ? { ...style, backdropFilter: `url(#${id})` } : style}
{...props}
>
{refracting ? (
<svg aria-hidden="true" width="0" height="0" className="absolute">
<filter id={id} x="0" y="0" width="100%" height="100%" colorInterpolationFilters="sRGB">
<feImage href={map ?? undefined} preserveAspectRatio="none" result="map" />
<feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="frost" />
<feDisplacementMap in="frost" in2="map" scale={strength} xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
) : null}
{children}
</div>
);
}
export { GlassSurface };
For AI agents
Hand your agent this page, the markdown at /components/glass-dialog/prompt.md, or the Copy AI prompt button above.
When to use
- Short confirmations and invites over image-heavy screens
- A lightweight modal that should keep the page visible behind it
When not to use
- Forms with many fields or long text: glass over busy content costs legibility
- Over flat pages, where the plain dialog is calmer
Agent notes
- Radix owns focus: it moves in on open, is trapped while open and returns to the trigger. Always include a GlassDialogTitle
- The scrim is black at 30 percent so the glass has something to bend, and the panel is 70 percent background
- Under prefers-reduced-transparency the panel is solid popover. The container prop is for demos and embedded frames; the window is the normal case
- The panel fades in over 100ms and does not animate under reduced motion