shadimage
pane
libglass-coresource: ok · hit

Glass Core

The glass engine behind every surface: the displacement-map generator that bends the backdrop at the rim, the SVG refraction filter, the tuning config for both variants, and the shared CVA variants.

npx shadcn add "https://pane.theui.company/r/glass-core.json"
free

Visual style

glass-blur

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
class-variance-authority

Files6 · source cached 10/14/2026, 1:21:26 PM

registry/pane/lib/glass/types.ts
export type GlassVariant = "regular" | "clear";

export type GlassQuality = "full" | "reduced" | "none";

export interface GlassDisplacementConfig {
  /** how far the rim pulls the backdrop at its deepest point, in px — keep it
   *  under `edge` or the bevel folds the backdrop over itself and smears */
  strength: number;
  /** width of the lens-bevel band hugging the edge, in px — absolute, so a
   *  large surface keeps the same thin rim instead of warping its whole area */
  edge: number;
  /** px of separation between the R/G/B samples at the rim — this is a fringe
   *  on the edge, so single digits; tens of px read as a rainbow halo */
  chromaticAberration: number;
  /** feGaussianBlur stdDeviation applied to the bump map before displacing, in
   *  px — the map is already smooth, this only hides 8-bit banding */
  blur: number;
}

export interface GlassSpecularConfig {
  /** peak highlight opacity, 0-1 */
  intensity: number;
  /** radius in px of the glint that travels along the rim with the pointer */
  size: number;
  /** resting rotation (deg) of the two specular arcs around the rim */
  angle: number;
  /** deg the arcs rotate as the surface crosses the viewport, so scrolling
   *  swings the light around the border */
  travel: number;
  /** width in px of the soft inner glow hugging the edge */
  edge: number;
  /** width in px of the crisp specular line drawn on the edge itself */
  rim: number;
}

export interface GlassSpringConfig {
  stiffness: number;
  damping: number;
  mass?: number;
}
registry/pane/lib/glass/config.ts
import type {
  GlassDisplacementConfig,
  GlassSpecularConfig,
  GlassSpringConfig,
  GlassVariant,
} from "./types";

/**
 * Tuning surface for the glass engine — every Pane-based component reads from
 * here. Only values feeding the SVG filter or the spring physics live in JS;
 * static color/blur tokens are `--pane-*` properties in `app/globals.css`.
 */

export const GLASS_DISPLACEMENT: Record<GlassVariant, GlassDisplacementConfig> =
  {
    regular: {
      strength: 11,
      edge: 13,
      chromaticAberration: 0.9,
      blur: 0.6,
    },
    clear: {
      strength: 15,
      edge: 17,
      chromaticAberration: 1.4,
      blur: 0.6,
    },
  };

export const GLASS_SPECULAR: GlassSpecularConfig = {
  intensity: 0.55,
  size: 180,
  angle: 115,
  travel: 200,
  edge: 7,
  rim: 1.5,
};

export const GLASS_SPRING: Record<
  "press" | "hover" | "drag",
  GlassSpringConfig
> = {
  press: { stiffness: 500, damping: 30, mass: 0.6 },
  hover: { stiffness: 300, damping: 24, mass: 0.8 },
  drag: { stiffness: 260, damping: 26, mass: 1 },
};

/** Minimum element area (px^2) below which the displacement filter is skipped. */
export const GLASS_MIN_FILTER_AREA = 64;
registry/pane/lib/glass/context.tsx
"use client";

import * as React from "react";
import { GLASS_DISPLACEMENT, GLASS_SPECULAR, GLASS_SPRING } from "./config";
import type {
  GlassDisplacementConfig,
  GlassSpecularConfig,
  GlassSpringConfig,
  GlassVariant,
} from "./types";

export interface GlassConfig {
  spring: Record<"press" | "hover" | "drag", GlassSpringConfig>;
  displacement: Record<GlassVariant, GlassDisplacementConfig>;
  specular: GlassSpecularConfig;
}

const defaultConfig: GlassConfig = {
  spring: GLASS_SPRING,
  displacement: GLASS_DISPLACEMENT,
  specular: GLASS_SPECULAR,
};

const GlassConfigContext = React.createContext<GlassConfig>(defaultConfig);

type GlassConfigOverride = {
  spring?: { [K in keyof GlassConfig["spring"]]?: Partial<GlassSpringConfig> };
  displacement?: {
    [K in GlassVariant]?: Partial<GlassDisplacementConfig>;
  };
  specular?: Partial<GlassSpecularConfig>;
};

/**
 * Overrides the JS-side tunables — springs, refraction, specular — for every
 * Pane component inside it. Optional: without a provider, components read the
 * values in `config.ts`. Overrides merge onto the nearest parent, so a subtree
 * can change just the springs and keep everything else.
 */
export function GlassConfigProvider({
  value,
  children,
}: {
  value: GlassConfigOverride;
  children: React.ReactNode;
}) {
  const parent = React.useContext(GlassConfigContext);
  const merged = React.useMemo<GlassConfig>(
    () => ({
      // two levels deep: `press: { stiffness }` keeps press's damping and mass
      spring: {
        press: { ...parent.spring.press, ...value.spring?.press },
        hover: { ...parent.spring.hover, ...value.spring?.hover },
        drag: { ...parent.spring.drag, ...value.spring?.drag },
      },
      displacement: {
        regular: {
          ...parent.displacement.regular,
          ...value.displacement?.regular,
        },
        clear: { ...parent.displacement.clear, ...value.displacement?.clear },
      },
      specular: { ...parent.specular, ...value.specular },
    }),
    [parent, value],
  );

  return (
    <GlassConfigContext.Provider value={merged}>
      {children}
    </GlassConfigContext.Provider>
  );
}

export function useGlassConfig() {
  return React.useContext(GlassConfigContext);
}
registry/pane/lib/glass/displacement.ts
export interface DisplacementMapOptions {
  width: number;
  height: number;
  radius: number;
  /** width of the lens-bevel band hugging the edge, in px */
  edge: number;
  /** neutral padding drawn around the shape, in px — see below */
  margin: number;
}

/** below this the normal is degenerate and the side test decides the direction */
const EPSILON = 1e-6;

/**
 * Largest share of the short side the bevel may occupy. A rim only reads as a
 * rim while undisturbed surface is left inside it: at a third, a 36px button
 * refracts 24 of its 36px and turns into a frosted lozenge.
 */
const MAX_BAND_RATIO = 0.2;

/**
 * Bevel width for an element of this size: `edge` px, cut down when the element
 * is too small to carry it. Exported because the filter has to scale its
 * deflection by the same factor — pulling the backdrop 15px through a 7px band
 * smears it instead of bending it.
 */
export function resolveBand(width: number, height: number, edge: number) {
  const short = Math.min(Math.max(1, width), Math.max(1, height));
  return Math.max(1, Math.min(edge, short * MAX_BAND_RATIO));
}

/**
 * Signed distance to a rounded rect centred on the origin, plus the outward
 * unit normal at that point. Negative distance is inside. Computed per pixel
 * rather than approximated with gradients: the corners are exactly where a
 * separable x/y ramp goes wrong.
 */
function fieldAt(
  px: number,
  py: number,
  halfW: number,
  halfH: number,
  r: number,
  out: { dist: number; nx: number; ny: number },
) {
  const qx = Math.abs(px) - (halfW - r);
  const qy = Math.abs(py) - (halfH - r);
  const ox = Math.max(qx, 0);
  const oy = Math.max(qy, 0);
  const outer = Math.hypot(ox, oy);

  out.dist = outer + Math.min(Math.max(qx, qy), 0) - r;

  let nx: number;
  let ny: number;
  if (outer > EPSILON) {
    // in a corner quadrant: the normal points away from the corner's centre
    nx = ox / outer;
    ny = oy / outer;
  } else if (qx > qy) {
    nx = 1;
    ny = 0;
  } else {
    nx = 0;
    ny = 1;
  }
  out.nx = px < 0 ? -nx : nx;
  out.ny = py < 0 ? -ny : ny;
}

/**
 * Builds the bump map fed to feDisplacementMap: a *normal* map, not a grayscale
 * one. R carries the horizontal component of the bevel's slope and G the
 * vertical, both centred on 128, so every point of the rim bends the backdrop
 * along its own outward normal — left edge samples further left, top edge
 * further up, corners diagonally. A gray ring can't express that: R and G move
 * together, so the whole rim displaces one way regardless of which side it's on.
 *
 * The ramp is quadratic, neutral across the interior and growing to full
 * deflection at the border, so interior pixels get zero offset rather than a
 * small one. Growing a pane then adds untouched middle instead of spreading
 * distortion over the content.
 *
 * `edge` is absolute px so a card and a button carry the same rim; see
 * `resolveBand` for the small-element cap.
 *
 * `margin` grows the map past the shape on every side and the caller places it
 * at `-margin`, keeping it registered 1:1 with the element. Deflection is held
 * at full outside the shape so the downstream blur can't dip it back toward
 * neutral right at the rim.
 *
 * Returns null without a DOM (SSR): the caller then renders no filter and the
 * CSS blur/tint layers carry the material alone.
 */
export function buildDisplacementMap({
  width,
  height,
  radius,
  edge,
  margin,
}: DisplacementMapOptions): string | null {
  if (typeof document === "undefined") return null;

  const w = Math.max(1, Math.round(width));
  const h = Math.max(1, Math.round(height));
  const m = Math.max(0, Math.round(margin));
  const short = Math.min(w, h);
  const r = Math.max(0, Math.min(radius, short / 2));
  const band = resolveBand(w, h, edge);

  const mapW = w + m * 2;
  const mapH = h + m * 2;

  const canvas = document.createElement("canvas");
  canvas.width = mapW;
  canvas.height = mapH;
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  const image = ctx.createImageData(mapW, mapH);
  const data = image.data;
  const halfW = w / 2;
  const halfH = h / 2;
  const field = { dist: 0, nx: 0, ny: 0 };

  for (let y = 0; y < mapH; y++) {
    // sample at pixel centres, in the element's own coordinate space
    const py = y + 0.5 - m - halfH;

    for (let x = 0; x < mapW; x++) {
      const px = x + 0.5 - m - halfW;
      fieldAt(px, py, halfW, halfH, r, field);

      const t = field.dist >= 0 ? 1 : Math.max(0, 1 + field.dist / band);
      const amount = t * t;

      const i = (y * mapW + x) * 4;
      data[i] = 128 + Math.round(field.nx * amount * 127);
      data[i + 1] = 128 + Math.round(field.ny * amount * 127);
      data[i + 2] = 128;
      data[i + 3] = 255;
    }
  }

  ctx.putImageData(image, 0, 0);
  return canvas.toDataURL("image/png");
}
registry/pane/lib/glass/glass-filter.tsx
"use client";

import * as React from "react";
import { useGlassConfig } from "./context";
import { buildDisplacementMap, resolveBand } from "./displacement";
import type { GlassVariant } from "./types";

interface GlassFilterProps {
  id: string;
  variant: GlassVariant;
  width: number;
  height: number;
  radius: number;
}

/**
 * Renders the hidden SVG that holds the actual refraction filter. Referenced
 * from CSS as `backdrop-filter: blur(...) url(#id)`. Chromium applies the
 * full displacement + chromatic-aberration chain; browsers that don't
 * support an SVG filter reference inside backdrop-filter simply ignore it
 * and fall back to the blur/saturate/specular layers, which is why this
 * only ever adds a bonus layer, never load-bearing.
 */
export function GlassFilter({
  id,
  variant,
  width,
  height,
  radius,
}: GlassFilterProps) {
  const cfg = useGlassConfig().displacement[variant];

  /**
   * How much of the configured bevel this element can carry. Small controls get
   * a narrower band (see `resolveBand`) and the deflection has to come down
   * with it: bending the backdrop further than the band is wide drags samples
   * from past the far side of the rim.
   */
  const scale = resolveBand(width, height, cfg.edge) / cfg.edge;
  const strength = cfg.strength * scale;

  /** px of slack around the element: enough for the blurred map to settle and
   *  for the rim to sample backdrop from just outside the border box */
  const margin = Math.ceil(strength + cfg.blur * 3);

  const mapUri = React.useMemo(() => {
    if (width <= 0 || height <= 0) return null;
    return buildDisplacementMap({
      width,
      height,
      radius,
      edge: cfg.edge,
      margin,
    });
  }, [width, height, radius, cfg.edge, margin]);

  if (!mapUri) return null;

  /* the map's full deflection is 127/255 off centre, i.e. half a unit, so a
     `scale` of 2n buys n px of pull at the rim — config states the px. */
  const base = strength * 2;
  const aberration = cfg.chromaticAberration * scale * 2;

  return (
    <svg
      aria-hidden="true"
      focusable="false"
      className="absolute h-0 w-0 overflow-hidden"
    >
      <defs>
        {/* everything sized in px against the element's own box: with
            percentage regions the map's placement is left to the filter
            region and the backdrop ends up offset from the surface it
            belongs to */}
        <filter
          id={id}
          filterUnits="userSpaceOnUse"
          primitiveUnits="userSpaceOnUse"
          x={-margin}
          y={-margin}
          width={width + margin * 2}
          height={height + margin * 2}
          colorInterpolationFilters="sRGB"
        >
          <feImage
            href={mapUri}
            preserveAspectRatio="none"
            x={-margin}
            y={-margin}
            width={width + margin * 2}
            height={height + margin * 2}
            result="map"
          />
          <feGaussianBlur
            in="map"
            stdDeviation={cfg.blur}
            result="mapBlurred"
          />

          <feDisplacementMap
            in="SourceGraphic"
            in2="mapBlurred"
            scale={base - aberration}
            xChannelSelector="R"
            yChannelSelector="G"
            result="dispR"
          />
          <feDisplacementMap
            in="SourceGraphic"
            in2="mapBlurred"
            scale={base}
            xChannelSelector="R"
            yChannelSelector="G"
            result="dispG"
          />
          <feDisplacementMap
            in="SourceGraphic"
            in2="mapBlurred"
            scale={base + aberration}
            xChannelSelector="R"
            yChannelSelector="G"
            result="dispB"
          />

          <feColorMatrix
            in="dispR"
            type="matrix"
            values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0"
            result="onlyR"
          />
          <feColorMatrix
            in="dispG"
            type="matrix"
            values="0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0"
            result="onlyG"
          />
          <feColorMatrix
            in="dispB"
            type="matrix"
            values="0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0"
            result="onlyB"
          />

          <feBlend in="onlyR" in2="onlyG" mode="screen" result="rg" />
          <feBlend in="rg" in2="onlyB" mode="screen" />
        </filter>
      </defs>
    </svg>
  );
}
registry/pane/lib/glass/variants.ts
import { cva, type VariantProps } from "class-variance-authority";

export const paneVariants = cva(
  "relative isolate text-[color:var(--pane-fg)] transition-colors",
  {
    variants: {
      variant: {
        regular: "",
        clear: "",
      },
      interactive: {
        true: "cursor-pointer select-none",
        false: "",
      },
    },
    defaultVariants: {
      variant: "regular",
      interactive: false,
    },
  },
);

export type PaneVariantProps = VariantProps<typeof paneVariants>;

export const paneLayerVariants = cva(
  "pointer-events-none absolute inset-0 rounded-[inherit]",
  {
    variants: {
      layer: {
        backdrop: "",
        tint: "",
        highlight: "mix-blend-screen",
        border: "",
      },
    },
  },
);

More from pane

Browse all components in this library →