Glass card
A 12px card of glass with a title and a line of text, bending the image behind it.
npx shadcn@latest add https://ui.goodtabs.co/r/glass-card.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/glass-card

Design tokens
Colors, type and spacing in one file.
One card over an image, 240px wide.
Stacked

Tokens
Every value named once.
Components
Small, composable, accessible.
Motion
Fast, and only with a reason.
Three cards in a row over a second image.
Usage
import { GlassCard, GlassCardTitle, GlassCardDescription } from "@/components/ui/glass-card"
<GlassCard>
<GlassCardTitle>Design tokens</GlassCardTitle>
<GlassCardDescription>Colors, type and spacing.</GlassCardDescription>
</GlassCard>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | Merged onto the glass surface. Keep any rounded class equal to radius 12. |
Source
glass-card/glass-card.tsx
import { GlassSurface } from "@/components/ui/glass-surface";
import { cn } from "@/lib/utils";
type GlassCardProps = React.ComponentProps<typeof GlassSurface>;
/** A 12px card of glass. Put it over an image or a gradient, since glass only reads over something. */
export function GlassCard({ className, ...props }: GlassCardProps) {
return (
<GlassSurface
data-slot="glass-card"
radius={12}
className={cn("flex flex-col gap-1.5 rounded-lg bg-background/60 p-4 text-foreground", className)}
{...props}
/>
);
}
export function GlassCardTitle({ className, ...props }: React.ComponentProps<"h3">) {
return <h3 data-slot="glass-card-title" className={cn("text-sm font-medium", className)} {...props} />;
}
export function GlassCardDescription({ className, ...props }: React.ComponentProps<"p">) {
return <p data-slot="glass-card-description" className={cn("text-sm text-foreground", className)} {...props} />;
}
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-card/prompt.md, or the Copy AI prompt button above.
When to use
- Small facts on top of a photo or a hero
- A feature row over an image background
When not to use
- On a flat background, where there is nothing to bend
- For long text; glass lowers contrast the more it shows through
Agent notes
- Glass needs something behind it: it bends and frosts what sits under it, so on a flat background it reads as a grey box
- Built on GlassSurface: Chromium refracts the rim, other browsers get a frosted card. It turns solid under reduced transparency
- Text is foreground on a 60 percent background fill; check it over the brightest part of your image