Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
classNamestringMerged 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