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 · lightclass-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: "",
},
},
},
);