Liquid Glass

The material itself: a lens that refracts what’s behind it, with squircle bevel, specular rim, dispersion and spring physics.

  • Liquid Glass
  • Media & publishing
  • React only · 0 deps
  • 1 file
Full screen

Browser support · Real refraction (SVG displacement inside backdrop-filter) in Chromium browsers. Safari and Firefox fall back to a frosted blur with the same rim and highlights.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/liquid-glass.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/liquid-glass/liquid-glass.tsx
1/**
2 * LiquidGlass — Apple's Liquid Glass material, rebuilt for the web.
3 *
4 * A surface for controls and navigation that float ABOVE content. It bends
5 * what is behind it like a real glass slab: a convex squircle bevel refracts
6 * the backdrop (Snell's law, IOR 1.5) so lines and text bend near the rim
7 * while the centre stays flat and readable; a light-facing specular rim, a
8 * faint counter-light, slight chromatic dispersion and a touch glow finish it.
9 *
10 * <LiquidGlass
11 * as="button" // any element, default "div"
12 * variant="regular" // "regular" (blur + luminosity, for text) | "clear" (over photos/video)
13 * tone="light" // regular only: "light" | "dark" foreground/fill scheme
14 * radius="capsule" // "capsule" | number (px) — keep radii concentric
15 * tint="#007AFF" // optional accent tint (glassProminent)
16 * dim // clear only: 35% dark dimming layer for bright content
17 * interactive // springy press, pointer highlight, touch glow
18 * bezel={20} refraction={1} dispersion={0.05} blur={8}
19 * />
20 *
21 * Also exported: Spring (tiny rAF spring integrator), useSpring,
22 * useReducedMotion, useGlassEnv, stretchTransform, createVelocityTracker,
23 * GlassScope (render the stylesheet once for many glass elements).
24 *
25 * Browser support: real refraction uses an SVG displacement filter inside
26 * `backdrop-filter: url(#…)`, which only Chromium (Chrome, Edge, Arc, Brave,
27 * Opera) supports. Safari and Firefox get a tuned frosted recipe (blur +
28 * saturate + brightness) with the same specular rim and highlights.
29 * Accessibility: purely visual; pass real elements via `as` (button, nav…).
30 * Honors prefers-reduced-transparency (opaque frosted fill),
31 * prefers-contrast: more (opaque + solid border) and prefers-reduced-motion
32 * (no spring/scale, glow fades only).
33 */
34import { createContext, forwardRef, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
35import type { AllHTMLAttributes, CSSProperties, ElementType, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
36
37export type GlassVariant = "regular" | "clear";
38export type GlassTone = "light" | "dark";
39
40export interface LiquidGlassProps extends Omit<AllHTMLAttributes<HTMLElement>, "as"> {
41 /** Element to render. Default "div". */
42 as?: ElementType;
43 /** "regular" blurs + adjusts luminosity (use with text); "clear" is highly translucent (use over rich media). */
44 variant?: GlassVariant;
45 /** Regular variant colour scheme. Clear always uses light-on-dark foreground. Default "light". */
46 tone?: GlassTone;
47 /** Corner radius in px, "capsule" (default), or "auto" — read from the element's own border-radius (use for animated morphs). */
48 radius?: number | "capsule" | "auto";
49 /** Accent tint colour (any CSS colour) — makes the glass "prominent". */
50 tint?: string;
51 /** Adds the 35% dark dimming layer HIG asks for when clear glass sits on bright content. */
52 dim?: boolean;
53 /** Press bounce, pointer-following highlight and touch glow. */
54 interactive?: boolean;
55 /** Scale while pressed (interactive only). Default 0.96. Use >1 for "lift". */
56 pressScale?: number;
57 /** Width of the refracting bevel in px. Default ≈ 26% of the short side (6–40px). */
58 bezel?: number;
59 /** Refraction strength multiplier. Default 1. */
60 refraction?: number;
61 /** Chromatic dispersion 0–0.3. Default 0.05. */
62 dispersion?: number;
63 /** Backdrop blur in px. Defaults: regular 8, clear 0.6. */
64 blur?: number;
65 /** Drop shadow under the glass. Default true. */
66 shadow?: boolean;
67 children?: ReactNode;
68}
69
70/* ───────────────────────── environment ───────────────────────── */
71
72const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
73
74type GlassEnv = { refraction: boolean; reducedMotion: boolean; reducedTransparency: boolean };
75const SSR_ENV: GlassEnv = { refraction: false, reducedMotion: false, reducedTransparency: false };
76
77function detectChromium(): boolean {
78 const nav = navigator as Navigator & { userAgentData?: { brands?: { brand: string }[] } };
79 const brands = nav.userAgentData?.brands?.map((b) => b.brand).join(" ") ?? "";
80 if (/Chromium|Google Chrome|Microsoft Edge|Opera|Brave/i.test(brands)) return true;
81 const ua = nav.userAgent;
82 return /\b(Chrome|Chromium|HeadlessChrome)\/\d/.test(ua) && !/Firefox|FxiOS/.test(ua);
83}
84
85/** Runtime capabilities. SSR-safe: everything is false until mounted. */
86export function useGlassEnv(): GlassEnv {
87 const [env, setEnv] = useState<GlassEnv>(SSR_ENV);
88 useEffect(() => {
89 const mm = (q: string) => window.matchMedia(q);
90 const rm = mm("(prefers-reduced-motion: reduce)");
91 const rt = mm("(prefers-reduced-transparency: reduce)");
92 const hc = mm("(prefers-contrast: more)");
93 const read = () =>
94 setEnv({
95 refraction: detectChromium() && !rt.matches && !hc.matches,
96 reducedMotion: rm.matches,
97 reducedTransparency: rt.matches || hc.matches,
98 });
99 read();
100 [rm, rt, hc].forEach((q) => q.addEventListener?.("change", read));
101 return () => [rm, rt, hc].forEach((q) => q.removeEventListener?.("change", read));
102 }, []);
103 return env;
104}
105
106export function useReducedMotion(): boolean {
107 return useGlassEnv().reducedMotion;
108}
109
110/* ───────────────────────── physics ───────────────────────── */
111
112export type SpringConfig = { stiffness?: number; damping?: number; mass?: number; precision?: number };
113
114/** Semi-implicit Euler spring integrated in rAF. Drives transforms through `onUpdate`. */
115export class Spring {
116 value: number;
117 target: number;
118 velocity = 0;
119 stiffness: number;
120 damping: number;
121 mass: number;
122 precision: number;
123 private raf = 0;
124 private last = 0;
125 constructor(value: number, public onUpdate: (v: number, s: Spring) => void, cfg: SpringConfig = {}) {
126 this.value = this.target = value;
127 this.stiffness = cfg.stiffness ?? 380;
128 this.damping = cfg.damping ?? 30;
129 this.mass = cfg.mass ?? 1;
130 this.precision = cfg.precision ?? 0.001;
131 }
132 /** Animate to `target`, optionally injecting a velocity (units/s). */
133 to(target: number, velocity?: number) {
134 this.target = target;
135 if (velocity !== undefined) this.velocity = velocity;
136 if (!this.raf && typeof requestAnimationFrame !== "undefined") {
137 this.last = performance.now();
138 this.raf = requestAnimationFrame(this.tick);
139 }
140 }
141 /** Jump without animating. */
142 set(v: number) {
143 this.stop();
144 this.value = this.target = v;
145 this.velocity = 0;
146 this.onUpdate(v, this);
147 }
148 stop() {
149 if (this.raf) cancelAnimationFrame(this.raf);
150 this.raf = 0;
151 }
152 get active() {
153 return this.raf !== 0;
154 }
155 private tick = (now: number) => {
156 const dt = Math.min(Math.max((now - this.last) / 1000, 0), 1 / 30);
157 this.last = now;
158 const steps = 4;
159 const h = dt / steps;
160 for (let i = 0; i < steps; i++) {
161 const f = -this.stiffness * (this.value - this.target) - this.damping * this.velocity;
162 this.velocity += (f / this.mass) * h;
163 this.value += this.velocity * h;
164 }
165 const p = this.precision;
166 if (Math.abs(this.velocity) < p * 10 && Math.abs(this.value - this.target) < p) {
167 this.value = this.target;
168 this.velocity = 0;
169 this.raf = 0;
170 this.onUpdate(this.value, this);
171 return;
172 }
173 this.onUpdate(this.value, this);
174 this.raf = requestAnimationFrame(this.tick);
175 };
176}
177
178/** A stable Spring for the component's lifetime. `onUpdate` may change between renders. */
179export function useSpring(initial: number, onUpdate: (v: number) => void, cfg?: SpringConfig): Spring {
180 const cb = useRef(onUpdate);
181 cb.current = onUpdate;
182 const ref = useRef<Spring | null>(null);
183 if (!ref.current) ref.current = new Spring(initial, (v) => cb.current(v), cfg);
184 useEffect(() => () => ref.current?.stop(), []);
185 return ref.current;
186}
187
188/** Area-preserving stretch along a velocity vector (px/s). */
189export function stretchTransform(vx: number, vy: number, gain = 0.00018, max = 0.32): string {
190 const speed = Math.hypot(vx, vy);
191 if (speed < 1) return "";
192 const a = Math.atan2(vy, vx);
193 const s = 1 + Math.min(max, speed * gain);
194 return `rotate(${a}rad) scale(${s}, ${1 / s}) rotate(${-a}rad)`;
195}
196
197/** Tracks pointer velocity (px/s) over the last ~80ms. */
198export function createVelocityTracker() {
199 let samples: { x: number; y: number; t: number }[] = [];
200 return {
201 reset() {
202 samples = [];
203 },
204 push(x: number, y: number, t = performance.now()) {
205 samples.push({ x, y, t });
206 while (samples.length > 2 && t - samples[0].t > 80) samples.shift();
207 },
208 get(): { vx: number; vy: number } {
209 if (samples.length < 2) return { vx: 0, vy: 0 };
210 const a = samples[0];
211 const b = samples[samples.length - 1];
212 const dt = Math.max((b.t - a.t) / 1000, 1 / 240);
213 return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt };
214 },
215 };
216}
217
218/* ───────────────────────── optics: displacement + specular maps ───────────────────────── */
219
220const LUT_N = 512;
221let LUT: { disp: Float32Array; nxy: Float32Array; nz: Float32Array } | null = null;
222
223/** Convex squircle bevel, t: 0 at the rim → 1 where the slab becomes flat. */
224const surface = (t: number) => Math.pow(1 - Math.pow(1 - t, 4), 0.25);
225const surfaceSlope = (t: number) => {
226 const tt = Math.min(Math.max(t, 1e-4), 1);
227 return Math.pow(1 - tt, 3) * Math.pow(1 - Math.pow(1 - tt, 4), -0.75);
228};
229
230function lut() {
231 if (LUT) return LUT;
232 const disp = new Float32Array(LUT_N);
233 const nxy = new Float32Array(LUT_N);
234 const nz = new Float32Array(LUT_N);
235 const IOR = 1.5;
236 let max = 0;
237 for (let i = 0; i < LUT_N; i++) {
238 const t = i / (LUT_N - 1);
239 const s = surfaceSlope(t);
240 const theta = Math.atan(s); // angle of the surface normal from vertical
241 const refr = Math.asin(Math.sin(theta) / IOR); // Snell
242 const thickness = 0.55 + surface(t); // base slab + bevel height (in bezel units)
243 const d = Math.tan(theta - refr) * thickness;
244 disp[i] = d;
245 max = Math.max(max, d);
246 const l = Math.hypot(s, 1);
247 nxy[i] = s / l;
248 nz[i] = 1 / l;
249 }
250 for (let i = 0; i < LUT_N; i++) disp[i] /= max;
251 LUT = { disp, nxy, nz };
252 return LUT;
253}
254
255const sample = (arr: Float32Array, t: number) => {
256 const f = Math.min(Math.max(t, 0), 1) * (LUT_N - 1);
257 const i = Math.floor(f);
258 const j = Math.min(i + 1, LUT_N - 1);
259 return arr[i] + (arr[j] - arr[i]) * (f - i);
260};
261
262let canvas: HTMLCanvasElement | null = null;
263
264/**
265 * Builds the displacement map (R,G = dx,dy around 128) and the specular rim
266 * image for a w×h rounded rect. Returns data URLs.
267 */
268export function renderGlassMaps(w: number, h: number, radius: number, bezel: number, dpr = 1, draft = false) {
269 const L = lut();
270 if (!canvas) canvas = document.createElement("canvas");
271 const ctx = canvas.getContext("2d");
272 if (!ctx) return null;
273 const hw = w / 2;
274 const hh = h / 2;
275 const r = Math.min(radius, hw, hh);
276 const rn = Math.min(Math.max(r, bezel), hw, hh); // inflated radius → crease-free normals
277
278 const build = (scale: number, kind: "disp" | "spec") => {
279 const mw = Math.max(2, Math.round(w * scale));
280 const mh = Math.max(2, Math.round(h * scale));
281 canvas!.width = mw;
282 canvas!.height = mh;
283 const img = ctx.createImageData(mw, mh);
284 const data = img.data;
285 // light from the top-left, a faint counter light from the bottom-right
286 const l1x = -0.5, l1y = -0.72, l1z = 0.48;
287 const l2x = 0.55, l2y = 0.7, l2z = 0.45;
288 for (let j = 0; j < mh; j++) {
289 const py = (j + 0.5) / scale - hh;
290 const ay = Math.abs(py);
291 for (let i = 0; i < mw; i++) {
292 const px = (i + 0.5) / scale - hw;
293 const ax = Math.abs(px);
294 const o = (j * mw + i) * 4;
295 // signed distance to the true rounded rect (negative inside)
296 const qx = ax - hw + r;
297 const qy = ay - hh + r;
298 const dist = qx > 0 && qy > 0 ? Math.hypot(qx, qy) - r : Math.max(qx, qy) - r;
299 const inside = -dist;
300 if (kind === "disp") {
301 data[o + 2] = 128;
302 data[o + 3] = 255;
303 }
304 if (inside <= 0 || inside >= bezel) {
305 if (kind === "disp") {
306 data[o] = 128;
307 data[o + 1] = 128;
308 }
309 continue;
310 }
311 const t = inside / bezel;
312 // outward normal from the inflated rounded rect
313 const nx0 = ax - hw + rn;
314 const ny0 = ay - hh + rn;
315 let nx: number, ny: number;
316 if (nx0 > 0 && ny0 > 0) {
317 const l = Math.hypot(nx0, ny0);
318 nx = nx0 / l;
319 ny = ny0 / l;
320 } else if (nx0 > ny0) {
321 nx = 1;
322 ny = 0;
323 } else {
324 nx = 0;
325 ny = 1;
326 }
327 if (px < 0) nx = -nx;
328 if (py < 0) ny = -ny;
329 if (kind === "disp") {
330 const m = sample(L.disp, t);
331 // sample inward: content near the rim is pulled from inside the slab
332 data[o] = Math.round(128 - nx * m * 127);
333 data[o + 1] = Math.round(128 - ny * m * 127);
334 } else {
335 const sxy = sample(L.nxy, t);
336 const sz = sample(L.nz, t);
337 const Nx = nx * sxy, Ny = ny * sxy, Nz = sz;
338 const d1 = Math.max(0, Nx * l1x + Ny * l1y + Nz * l1z);
339 const d2 = Math.max(0, Nx * l2x + Ny * l2y + Nz * l2z);
340 const rim = Math.pow(sxy, 7); // grazing fresnel line right at the edge
341 const a = Math.min(1, 0.95 * Math.pow(d1, 5) * sxy + 0.42 * Math.pow(d2, 5) * sxy + 0.22 * rim);
342 data[o] = 255;
343 data[o + 1] = 255;
344 data[o + 2] = 255;
345 data[o + 3] = Math.round(a * 255);
346 }
347 }
348 }
349 ctx.putImageData(img, 0, 0);
350 return canvas!.toDataURL("image/png");
351 };
352
353 const base = Math.min(1, (draft ? 300 : 520) / Math.max(w, h));
354 const disp = build(base, "disp");
355 const specScale = draft ? base : Math.min(Math.min(dpr, 2), 1100 / Math.max(w, h));
356 const spec = build(specScale, "spec");
357 return { disp, spec };
358}
359
360/* ───────────────────────── styles ───────────────────────── */
361
362const ScopeCtx = createContext(false);
363
364/** Renders the glass stylesheet once for every LiquidGlass inside it. */
365export function GlassScope({ children }: { children: ReactNode }) {
366 return (
367 <ScopeCtx.Provider value>
368 <style>{GLASS_CSS}</style>
369 {children}
370 </ScopeCtx.Provider>
371 );
372}
373
374/* ───────────────────────── component ───────────────────────── */
375
376export const LiquidGlass = forwardRef<HTMLElement, LiquidGlassProps>(function LiquidGlass(
377 {
378 as: Tag = "div",
379 variant = "regular",
380 tone = "light",
381 radius = "capsule",
382 tint,
383 dim = false,
384 interactive = false,
385 pressScale = 0.96,
386 bezel,
387 refraction = 1,
388 dispersion = 0.05,
389 blur,
390 shadow = true,
391 className,
392 style,
393 children,
394 ...rest
395 },
396 ref,
397) {
398 const scoped = useContext(ScopeCtx);
399 const rawId = useId();
400 const fid = "fd-lg-" + rawId.replace(/[^a-zA-Z0-9_-]/g, "");
401 const el = useRef<HTMLElement | null>(null);
402 const specEl = useRef<HTMLSpanElement | null>(null);
403 const feImg = useRef<SVGFEImageElement | null>(null);
404 const dR = useRef<SVGFEDisplacementMapElement | null>(null);
405 const dG = useRef<SVGFEDisplacementMapElement | null>(null);
406 const dB = useRef<SVGFEDisplacementMapElement | null>(null);
407 useImperativeHandle(ref, () => el.current as HTMLElement);
408 const env = useGlassEnv();
409
410 // keep live params in a ref so the observer never needs re-subscribing
411 const params = useRef({ radius, bezel, refraction, dispersion, refract: env.refraction });
412 params.current = { radius, bezel, refraction, dispersion, refract: env.refraction };
413
414 /* displacement + specular maps, regenerated on size change only */
415 useIsoLayoutEffect(() => {
416 const node = el.current;
417 if (!node) return;
418 let raf = 0;
419 let timer = 0;
420 let lastKey = "";
421 const apply = (draft = false) => {
422 raf = 0;
423 const w = node.offsetWidth;
424 const h = node.offsetHeight;
425 if (w < 4 || h < 4) return;
426 const p = params.current;
427 const rad =
428 p.radius === "capsule"
429 ? Math.min(w, h) / 2
430 : Math.min(p.radius === "auto" ? parseFloat(getComputedStyle(node).borderTopLeftRadius) || 0 : p.radius, w / 2, h / 2);
431 const bz = Math.max(4, Math.min(p.bezel ?? Math.min(Math.max(Math.min(w, h) * 0.26, 6), 40), Math.min(w, h) / 2));
432 const dpr = Math.min(window.devicePixelRatio || 1, 2);
433 const key = [w, h, rad, bz, dpr].join(":");
434 if (key !== lastKey) {
435 const maps = renderGlassMaps(w, h, rad, bz, dpr, draft);
436 if (!maps) return;
437 if (!draft) lastKey = key;
438 if (specEl.current) specEl.current.style.backgroundImage = `url("${maps.spec}")`;
439 feImg.current?.setAttribute("href", maps.disp);
440 }
441 const scale = bz * 0.9 * p.refraction * 2;
442 const disp = Math.min(Math.max(p.dispersion, 0), 0.3);
443 for (const [e, k] of [[dR.current, 1 - disp], [dG.current, 1], [dB.current, 1 + disp]] as const) e?.setAttribute("scale", String(scale * k));
444 if (p.refract) node.style.setProperty("--fd-lg-url", `url(#${fid})`);
445 else node.style.removeProperty("--fd-lg-url");
446 };
447 let first = true;
448 const onResize = () => {
449 feImg.current?.setAttribute("width", String(node.offsetWidth));
450 feImg.current?.setAttribute("height", String(node.offsetHeight));
451 if (first) {
452 first = false;
453 return;
454 }
455 // live resize (morphs, springs): cheap low-res map in the same frame (ResizeObserver
456 // runs before paint), full quality once the size settles
457 apply(true);
458 window.clearTimeout(timer);
459 timer = window.setTimeout(() => {
460 cancelAnimationFrame(raf);
461 raf = requestAnimationFrame(() => apply(false));
462 }, 120);
463 };
464 onResize();
465 apply(false);
466 const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(onResize) : null;
467 ro?.observe(node);
468 return () => {
469 ro?.disconnect();
470 cancelAnimationFrame(raf);
471 window.clearTimeout(timer);
472 };
473 }, [fid, radius, bezel, refraction, dispersion, env.refraction]);
474
475 /* interaction: press spring, pointer highlight, touch glow */
476 const press = useSpring(1, (v) => el.current?.style.setProperty("--fd-lg-s", v.toFixed(4)), { stiffness: 520, damping: 22 });
477 useEffect(() => {
478 const node = el.current;
479 if (!node || !interactive) return;
480 const pos = (e: PointerEvent) => {
481 const r = node.getBoundingClientRect();
482 const s = Number(node.style.getPropertyValue("--fd-lg-s")) || 1;
483 node.style.setProperty("--fd-lg-mx", `${((e.clientX - r.left) / s).toFixed(1)}px`);
484 node.style.setProperty("--fd-lg-my", `${((e.clientY - r.top) / s).toFixed(1)}px`);
485 };
486 const down = (e: PointerEvent) => {
487 if ((node as HTMLButtonElement).disabled || node.getAttribute("aria-disabled") === "true") return;
488 pos(e);
489 node.classList.add("fd-lg--pressed");
490 node.classList.remove("fd-lg--released");
491 if (env.reducedMotion) return;
492 press.stiffness = 900;
493 press.damping = 48;
494 press.to(pressScale);
495 };
496 const up = () => {
497 if (!node.classList.contains("fd-lg--pressed")) return;
498 node.classList.remove("fd-lg--pressed");
499 node.classList.add("fd-lg--released");
500 if (env.reducedMotion) return;
501 press.stiffness = 420;
502 press.damping = 14;
503 press.to(1, (1 - pressScale) * 18);
504 };
505 const key = (e: KeyboardEvent) => {
506 if (e.repeat || (e.key !== " " && e.key !== "Enter")) return;
507 node.style.setProperty("--fd-lg-mx", "50%");
508 node.style.setProperty("--fd-lg-my", "50%");
509 node.classList.add("fd-lg--pressed");
510 if (!env.reducedMotion) {
511 press.stiffness = 900;
512 press.damping = 48;
513 press.to(pressScale);
514 }
515 };
516 const keyUp = (e: KeyboardEvent) => {
517 if (e.key === " " || e.key === "Enter") up();
518 };
519 node.addEventListener("pointermove", pos);
520 node.addEventListener("pointerdown", down);
521 node.addEventListener("pointerenter", pos);
522 window.addEventListener("pointerup", up);
523 window.addEventListener("pointercancel", up);
524 node.addEventListener("keydown", key);
525 node.addEventListener("keyup", keyUp);
526 node.addEventListener("blur", up);
527 return () => {
528 node.removeEventListener("pointermove", pos);
529 node.removeEventListener("pointerdown", down);
530 node.removeEventListener("pointerenter", pos);
531 window.removeEventListener("pointerup", up);
532 window.removeEventListener("pointercancel", up);
533 node.removeEventListener("keydown", key);
534 node.removeEventListener("keyup", keyUp);
535 node.removeEventListener("blur", up);
536 };
537 }, [interactive, pressScale, env.reducedMotion, press]);
538
539 const vars: Record<string, string | number> = {};
540 if (radius !== "auto") vars["--fd-lg-r"] = radius === "capsule" ? "9999px" : `${radius}px`;
541 if (blur !== undefined) vars["--fd-lg-blur"] = `${blur}px`;
542 if (tint) vars["--fd-lg-tint"] = tint;
543
544 const cls = [
545 "fd-lg",
546 `fd-lg--${variant}`,
547 variant === "regular" ? `fd-lg--${tone}` : "fd-lg--dark",
548 tint ? "fd-lg--tinted" : "",
549 dim ? "fd-lg--dim" : "",
550 interactive ? "fd-lg--interactive" : "",
551 shadow ? "" : "fd-lg--flat",
552 env.refraction ? "fd-lg--refract" : "",
553 className ?? "",
554 ]
555 .filter(Boolean)
556 .join(" ");
557
558 return (
559 <Tag ref={el} className={cls} style={{ ...(vars as CSSProperties), ...style }} {...rest}>
560 {!scoped && <style>{GLASS_CSS}</style>}
561 <svg className="fd-lg-svg" aria-hidden="true" focusable="false" width="0" height="0">
562 <filter id={fid} colorInterpolationFilters="sRGB">
563 <feImage ref={feImg} x="0" y="0" width="1" height="1" preserveAspectRatio="none" result="map" />
564 <feDisplacementMap ref={dR} in="SourceGraphic" in2="map" scale="0" xChannelSelector="R" yChannelSelector="G" result="dr" />
565 <feColorMatrix in="dr" type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="r" />
566 <feDisplacementMap ref={dG} in="SourceGraphic" in2="map" scale="0" xChannelSelector="R" yChannelSelector="G" result="dg" />
567 <feColorMatrix in="dg" type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="g" />
568 <feDisplacementMap ref={dB} in="SourceGraphic" in2="map" scale="0" xChannelSelector="R" yChannelSelector="G" result="db" />
569 <feColorMatrix in="db" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="b" />
570 <feComposite in="r" in2="g" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="rg" />
571 <feComposite in="rg" in2="b" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" />
572 </filter>
573 </svg>
574 <span className="fd-lg-layer fd-lg-fill" aria-hidden="true" />
575 {dim && <span className="fd-lg-layer fd-lg-dimmer" aria-hidden="true" />}
576 <span ref={specEl} className="fd-lg-layer fd-lg-spec" aria-hidden="true" />
577 <span className="fd-lg-layer fd-lg-edge" aria-hidden="true" />
578 {interactive && <span className="fd-lg-layer fd-lg-glow" aria-hidden="true" />}
579 {children}
580 </Tag>
581 );
582});
583
584/* ───────────────────────── demo: a lens over an editorial spread ───────────────────────── */
585
586type LensShape = "capsule" | "circle" | "rounded";
587const LENS_SIZE: Record<LensShape, (compact: boolean) => { w: number; h: number; r: number }> = {
588 capsule: (c) => (c ? { w: 232, h: 96, r: 48 } : { w: 340, h: 132, r: 66 }),
589 circle: (c) => (c ? { w: 148, h: 148, r: 74 } : { w: 196, h: 196, r: 98 }),
590 rounded: (c) => (c ? { w: 220, h: 150, r: 34 } : { w: 300, h: 200, r: 44 }),
591};
592
593export function Demo() {
594 const stage = useRef<HTMLDivElement | null>(null);
595 const lens = useRef<HTMLElement | null>(null);
596 const [variant, setVariant] = useState<GlassVariant>("clear");
597 const [shape, setShape] = useState<LensShape>("capsule");
598 const [dim, setDim] = useState(false);
599 const [touched, setTouched] = useState(false);
600 const [compact, setCompact] = useState(false);
601 const env = useGlassEnv();
602 const motion = useRef({ x: 0, y: 0, dragging: false });
603
604 const paint = () => {
605 const node = lens.current;
606 if (!node) return;
607 const st = env.reducedMotion ? "" : stretchTransform(sx.velocity, sy.velocity, 0.00022, 0.28);
608 node.style.setProperty("--fd-lg-pre", `translate(${sx.value.toFixed(2)}px, ${sy.value.toFixed(2)}px) ${st}`);
609 };
610 const sx = useSpring(0, paint, { stiffness: 380, damping: 30 });
611 const sy = useSpring(0, paint, { stiffness: 380, damping: 30 });
612 const sw = useSpring(340, (v) => lens.current?.style.setProperty("width", `${v}px`), { stiffness: 380, damping: 28 });
613 const sh = useSpring(132, (v) => lens.current?.style.setProperty("height", `${v}px`), { stiffness: 380, damping: 28 });
614 const sr = useSpring(66, (v) => lens.current?.style.setProperty("border-radius", `${v}px`), { stiffness: 380, damping: 30 });
615
616 const bounds = () => {
617 const s = stage.current!;
618 const n = lens.current!;
619 return { maxX: s.clientWidth - n.offsetWidth, maxY: s.clientHeight - n.offsetHeight - (compact ? 92 : 96) };
620 };
621
622 // initial placement + a gentle entrance glide
623 useEffect(() => {
624 const s = stage.current;
625 if (!s) return;
626 const c = s.clientWidth < 640;
627 setCompact(c);
628 const z = LENS_SIZE.capsule(c);
629 sw.set(z.w);
630 sh.set(z.h);
631 sr.set(z.r);
632 const h1 = s.querySelector(".fd-lgx-h1")?.getBoundingClientRect();
633 const sr0 = s.getBoundingClientRect();
634 const tx = h1 ? Math.min(h1.left - sr0.left + (c ? 24 : h1.width * 0.18), s.clientWidth - z.w - 12) : 24;
635 const ty = h1 ? h1.top - sr0.top + h1.height * (c ? 0.5 : 0.46) - z.h / 2 : 140;
636 if (env.reducedMotion) {
637 sx.set(tx);
638 sy.set(ty);
639 } else {
640 sx.set(tx - 140);
641 sy.set(ty + 40);
642 sx.stiffness = sy.stiffness = 90;
643 sx.damping = sy.damping = 16;
644 sx.to(tx);
645 sy.to(ty);
646 }
647 const onResize = () => {
648 const b = bounds();
649 sx.to(Math.min(Math.max(sx.target, 0), Math.max(0, b.maxX)));
650 sy.to(Math.min(Math.max(sy.target, 0), Math.max(0, b.maxY)));
651 };
652 window.addEventListener("resize", onResize);
653 return () => window.removeEventListener("resize", onResize);
654 // eslint-disable-next-line react-hooks/exhaustive-deps
655 }, [env.reducedMotion]);
656
657 // morph between shapes, keeping the lens centred
658 useEffect(() => {
659 if (!stage.current) return;
660 const z = LENS_SIZE[shape](compact);
661 const cx = sx.target + sw.target / 2;
662 const cy = sy.target + sh.target / 2;
663 const s = stage.current;
664 const nx = Math.min(Math.max(cx - z.w / 2, 0), s.clientWidth - z.w);
665 const ny = Math.min(Math.max(cy - z.h / 2, 0), s.clientHeight - z.h - 96);
666 if (env.reducedMotion) {
667 sw.set(z.w), sh.set(z.h), sr.set(z.r), sx.set(nx), sy.set(ny);
668 return;
669 }
670 sx.stiffness = sy.stiffness = 380;
671 sx.damping = sy.damping = 30;
672 sw.to(z.w), sh.to(z.h), sr.to(z.r), sx.to(nx), sy.to(ny);
673 // eslint-disable-next-line react-hooks/exhaustive-deps
674 }, [shape]);
675
676 // dragging with elastic stretch and a springy throw
677 useEffect(() => {
678 const node = lens.current;
679 if (!node) return;
680 const vt = createVelocityTracker();
681 let ox = 0, oy = 0;
682 const down = (e: PointerEvent) => {
683 if (e.button !== 0) return;
684 node.setPointerCapture(e.pointerId);
685 setTouched(true);
686 motion.current.dragging = true;
687 ox = e.clientX - sx.value;
688 oy = e.clientY - sy.value;
689 vt.reset();
690 vt.push(e.clientX, e.clientY);
691 sx.stiffness = sy.stiffness = env.reducedMotion ? 4000 : 700;
692 sx.damping = sy.damping = env.reducedMotion ? 130 : 42;
693 };
694 const move = (e: PointerEvent) => {
695 if (!motion.current.dragging) return;
696 vt.push(e.clientX, e.clientY);
697 const b = bounds();
698 // rubber-band past the edges
699 const rb = (v: number, max: number) => (v < 0 ? v * 0.35 : v > max ? max + (v - max) * 0.35 : v);
700 sx.to(rb(e.clientX - ox, b.maxX));
701 sy.to(rb(e.clientY - oy, b.maxY));
702 };
703 const up = () => {
704 if (!motion.current.dragging) return;
705 motion.current.dragging = false;
706 const { vx, vy } = vt.get();
707 const b = bounds();
708 const k = env.reducedMotion ? 0 : 0.11;
709 sx.stiffness = sy.stiffness = 260;
710 sx.damping = sy.damping = 20;
711 sx.to(Math.min(Math.max(sx.target + vx * k, 0), b.maxX), env.reducedMotion ? 0 : vx * 0.4);
712 sy.to(Math.min(Math.max(sy.target + vy * k, 0), b.maxY), env.reducedMotion ? 0 : vy * 0.4);
713 };
714 const key = (e: KeyboardEvent) => {
715 const step = e.shiftKey ? 64 : 20;
716 const d: Record<string, [number, number]> = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
717 const v = d[e.key];
718 if (!v) return;
719 e.preventDefault();
720 setTouched(true);
721 const b = bounds();
722 sx.stiffness = sy.stiffness = 380;
723 sx.damping = sy.damping = 30;
724 sx.to(Math.min(Math.max(sx.target + v[0], 0), b.maxX));
725 sy.to(Math.min(Math.max(sy.target + v[1], 0), b.maxY));
726 };
727 node.addEventListener("pointerdown", down);
728 node.addEventListener("pointermove", move);
729 node.addEventListener("pointerup", up);
730 node.addEventListener("pointercancel", up);
731 node.addEventListener("keydown", key);
732 return () => {
733 node.removeEventListener("pointerdown", down);
734 node.removeEventListener("pointermove", move);
735 node.removeEventListener("pointerup", up);
736 node.removeEventListener("pointercancel", up);
737 node.removeEventListener("keydown", key);
738 };
739 // eslint-disable-next-line react-hooks/exhaustive-deps
740 }, [env.reducedMotion, compact]);
741
742 return (
743 <div className="fd-lgx" ref={stage}>
744 <style>{DEMO_CSS}</style>
745 <GlassScope>
746 <div className="fd-lgx-scroll" tabIndex={0} aria-label="Lumen, issue 14 — scrollable article">
747 <Spread />
748 </div>
749
750 <LiquidGlass
751 ref={lens}
752 as="div"
753 role="button"
754 tabIndex={0}
755 aria-label="Glass lens. Drag it, or use the arrow keys to move it."
756 className="fd-lgx-lens"
757 variant={variant}
758 tone="light"
759 radius="auto"
760 dim={variant === "clear" && dim}
761 interactive
762 pressScale={1.06}
763 refraction={1.1}
764 dispersion={0.06}
765 >
766 <span className={`fd-lgx-hint${touched ? " is-gone" : ""}`} aria-hidden="true">
767 <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 1.5v13M1.5 8h13M8 1.5 6 3.5M8 1.5l2 2M8 14.5l-2-2M8 14.5l2-2M1.5 8l2-2M1.5 8l2 2M14.5 8l-2-2M14.5 8l-2 2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
768 Drag me
769 </span>
770 </LiquidGlass>
771
772 <LiquidGlass as="div" className="fd-lgx-bar" variant="regular" tone="light" role="toolbar" aria-label="Lens settings">
773 <Segmented
774 label="Variant"
775 value={variant}
776 onChange={(v) => setVariant(v as GlassVariant)}
777 options={[
778 { value: "regular", label: "Regular" },
779 { value: "clear", label: "Clear" },
780 ]}
781 />
782 <span className="fd-lgx-sep" aria-hidden="true" />
783 <Segmented
784 label="Shape"
785 value={shape}
786 onChange={(v) => setShape(v as LensShape)}
787 options={[
788 { value: "capsule", label: "Capsule", icon: <rect x="2" y="5.5" width="14" height="7" rx="3.5" /> },
789 { value: "circle", label: "Circle", icon: <circle cx="9" cy="9" r="6" /> },
790 { value: "rounded", label: "Rounded", icon: <rect x="2.5" y="4" width="13" height="10" rx="3" /> },
791 ]}
792 iconOnly
793 />
794 <span className="fd-lgx-sep" aria-hidden="true" />
795 <button
796 type="button"
797 className="fd-lgx-dim"
798 role="switch"
799 aria-checked={dim}
800 disabled={variant !== "clear"}
801 onClick={() => setDim((d) => !d)}
802 title="35% dimming layer for clear glass over bright content"
803 >
804 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
805 <circle cx="9" cy="9" r="6.25" stroke="currentColor" strokeWidth="1.5" />
806 <path d="M9 2.75a6.25 6.25 0 0 1 0 12.5Z" fill="currentColor" />
807 </svg>
808 <span>Dim</span>
809 </button>
810 </LiquidGlass>
811 </GlassScope>
812 </div>
813 );
814}
815
816function Segmented({
817 label,
818 value,
819 options,
820 onChange,
821 iconOnly,
822}: {
823 label: string;
824 value: string;
825 options: { value: string; label: string; icon?: ReactNode }[];
826 onChange: (v: string) => void;
827 iconOnly?: boolean;
828}) {
829 const idx = Math.max(0, options.findIndex((o) => o.value === value));
830 const onKey = (e: ReactKeyboardEvent) => {
831 const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
832 if (!d) return;
833 e.preventDefault();
834 const n = (idx + d + options.length) % options.length;
835 onChange(options[n].value);
836 const btn = (e.currentTarget as HTMLElement).querySelectorAll("button")[n];
837 btn?.focus();
838 };
839 return (
840 <div className={`fd-lgx-seg${iconOnly ? " is-icons" : ""}`} role="radiogroup" aria-label={label} onKeyDown={onKey} style={{ "--n": options.length, "--i": idx } as CSSProperties}>
841 <span className="fd-lgx-seg-thumb" aria-hidden="true" />
842 {options.map((o) => (
843 <button
844 key={o.value}
845 type="button"
846 role="radio"
847 aria-checked={o.value === value}
848 aria-label={iconOnly ? o.label : undefined}
849 title={iconOnly ? o.label : undefined}
850 tabIndex={o.value === value ? 0 : -1}
851 className="fd-lgx-seg-btn"
852 onClick={() => onChange(o.value)}
853 >
854 {o.icon ? (
855 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
856 {o.icon}
857 </svg>
858 ) : null}
859 {!iconOnly && <span>{o.label}</span>}
860 </button>
861 ))}
862 </div>
863 );
864}
865
866/** The content layer: an editorial spread with type, procedural photographs and fine line work. */
867function Spread() {
868 return (
869 <article className="fd-lgx-paper">
870 <header className="fd-lgx-mast">
871 <span className="fd-lgx-logo">LUMEN</span>
872 <span>Journal of light &amp; material</span>
873 <span>No. 14 — Winter 2026</span>
874 <span>€14 / CHF 16</span>
875 </header>
876 <div className="fd-lgx-rules" aria-hidden="true" />
877 <section className="fd-lgx-hero">
878 <p className="fd-lgx-kicker">Feature · Optics</p>
879 <h1 className="fd-lgx-h1">
880 Light,<br />
881 <em>bent.</em>
882 </h1>
883 <p className="fd-lgx-dek">
884 Glass doesn’t hide what’s behind it. It borrows it, curves it around its edges and hands it back a little brighter. A field guide to the
885 physics behind the interfaces of 2026.
886 </p>
887 <p className="fd-lgx-byline">Words by Ines Vahlkamp · Photographs by Teodor Brask</p>
888 </section>
889
890 <section className="fd-lgx-plates" aria-label="Photographs">
891 <figure className="fd-lgx-plate">
892 <div className="fd-lgx-ph fd-lgx-ph--dune" />
893 <figcaption>
894 <b>Atacama, 04:52.</b> First light on the salt flats near San Pedro.
895 </figcaption>
896 </figure>
897 <figure className="fd-lgx-plate">
898 <div className="fd-lgx-ph fd-lgx-ph--sea" />
899 <figcaption>
900 <b>Faroe Islands.</b> Swell lines off Gásadalur, 1/2000 s.
901 </figcaption>
902 </figure>
903 <figure className="fd-lgx-plate">
904 <div className="fd-lgx-ph fd-lgx-ph--citrus" />
905 <figcaption>
906 <b>Mercato di Ballarò.</b> Blood oranges, Palermo, February.
907 </figcaption>
908 </figure>
909 <figure className="fd-lgx-plate">
910 <div className="fd-lgx-ph fd-lgx-ph--house" />
911 <figcaption>
912 <b>Glasshouse, Kyoto.</b> 1,184 panes, none of them flat.
913 </figcaption>
914 </figure>
915 </section>
916
917 <section className="fd-lgx-cols">
918 <p>
919 <span className="fd-lgx-drop">E</span>very lens is a negotiation between thickness and curvature. Near its rim, a slab of glass meets the
920 air at a steep angle, and the light passing through is bent hardest there — straight lines bow, letters lean inward, colour separates into
921 a thin prism of red and blue. Towards the centre the surface flattens out and the world comes through almost untouched.
922 </p>
923 <p>
924 That is the whole trick behind the new generation of interface materials. A control drawn as a slab of glass reads as an object that
925 floats above the page, not a hole cut into it. It stays legible because the middle stays calm, and it feels physical because the edges
926 do exactly what a real edge would do.
927 </p>
928 <p>
929 Designers at the Hokuto studio in Kyoto spent eleven months measuring how light falls across curved panes before settling on a squircle
930 profile: a curve that rises quickly at the rim and then levels off, so the refraction band stays narrow and bright.
931 </p>
932 </section>
933
934 <section className="fd-lgx-index" aria-label="Refractive index table">
935 <h2 className="fd-lgx-h2">Index of refraction</h2>
936 <ul>
937 <li><span>Air</span><i /><b>1.000</b></li>
938 <li><span>Water, 20 °C</span><i /><b>1.333</b></li>
939 <li><span>Crown glass</span><i /><b>1.520</b></li>
940 <li><span>Flint glass</span><i /><b>1.620</b></li>
941 <li><span>Sapphire</span><i /><b>1.770</b></li>
942 <li><span>Diamond</span><i /><b>2.417</b></li>
943 </ul>
944 </section>
945
946 <section className="fd-lgx-test" aria-label="Calibration plate">
947 <div className="fd-lgx-bars" />
948 <div className="fd-lgx-grid" />
949 <p className="fd-lgx-cap">Plate 7 — calibration chart used for every photograph in this issue.</p>
950 </section>
951
952 <section className="fd-lgx-quote">
953 <p>“The best material is the one you only notice at its edges.”</p>
954 <span>— Mariko Tsuji, Hokuto Glassworks</span>
955 </section>
956 <footer className="fd-lgx-foot">
957 <span>LUMEN 14</span>
958 <span>Page 38</span>
959 </footer>
960 </article>
961 );
962}
963
964const FONT = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif`;
965const DISPLAY = `"SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",Roboto,sans-serif`;
966const SERIF = `"New York","Iowan Old Style","Source Serif 4",Georgia,"Times New Roman",serif`;
967
968const DEMO_CSS = `
969.fd-lgx,.fd-lgx *,.fd-lgx *::before,.fd-lgx *::after{box-sizing:border-box}
970.fd-lgx{position:relative;height:100vh;min-height:600px;width:100%;overflow:hidden;margin:0;padding:0;background:#F3EFE6;color:#0B0B0F;font:400 15px/1.5 ${FONT};letter-spacing:normal;touch-action:pan-y}
971.fd-lgx-scroll{position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;margin:0;padding:0;outline:none;scrollbar-width:none;overscroll-behavior:contain}
972.fd-lgx-scroll::-webkit-scrollbar{display:none}
973.fd-lgx-paper{display:block;max-width:1180px;margin:0 auto;padding:28px 40px 160px;background:none;color:#0B0B0F}
974.fd-lgx-paper h1,.fd-lgx-paper h2,.fd-lgx-paper p,.fd-lgx-paper figure,.fd-lgx-paper figcaption,.fd-lgx-paper ul,.fd-lgx-paper li{margin:0;padding:0;border:0;background:none;color:inherit}
975.fd-lgx-mast{display:flex;justify-content:space-between;align-items:baseline;gap:16px;margin:0;padding:0 0 12px;font:500 12px/1.3 ${FONT};letter-spacing:0.08em;text-transform:uppercase;color:#0B0B0F}
976.fd-lgx-logo{font:800 24px/1 ${DISPLAY};letter-spacing:0.18em}
977.fd-lgx-rules{height:14px;margin:0 0 40px;background:repeating-linear-gradient(180deg,#0B0B0F 0 1.5px,transparent 1.5px 4px)}
978.fd-lgx-hero{display:grid;grid-template-columns:1.25fr 1fr;grid-template-areas:"k k" "h d" "h b";column-gap:56px;align-items:end;margin:0 0 56px}
979.fd-lgx-kicker{grid-area:k;margin:0 0 8px;font:600 13px/1.3 ${FONT};letter-spacing:0.12em;text-transform:uppercase;color:#C2410C}
980.fd-lgx-h1{grid-area:h;margin:0;font:800 clamp(4rem,11vw,9.5rem)/0.86 ${DISPLAY};letter-spacing:-0.055em;color:#0B0B0F}
981.fd-lgx-h1 em{font:italic 400 1em/0.86 ${SERIF};letter-spacing:-0.035em;color:#1D4ED8}
982.fd-lgx-dek{grid-area:d;margin:0 0 18px;font:400 1.25rem/1.45 ${SERIF};color:#1C1C22;max-width:30ch}
983.fd-lgx-byline{grid-area:b;margin:0 0 10px;font:500 13px/1.4 ${FONT};color:#6B6B76}
984.fd-lgx-plates{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:0 0 56px}
985.fd-lgx-plate{display:block;margin:0}
986.fd-lgx-plate figcaption{margin:10px 0 0;font:400 13px/1.45 ${FONT};color:#3A3A44}
987.fd-lgx-plate figcaption b{font-weight:650;color:#0B0B0F}
988.fd-lgx-ph{display:block;aspect-ratio:4/5;border-radius:4px;overflow:hidden;position:relative}
989.fd-lgx-ph--dune{background:
990 radial-gradient(circle at 70% 38%,#FFF6D6 0 7%,rgba(255,220,150,0.6) 8%,transparent 22%),
991 linear-gradient(176deg,transparent 0 55%,rgba(120,40,20,0.25) 55.5%,transparent 70%),
992 repeating-linear-gradient(172deg,rgba(90,30,10,0.0) 0 10px,rgba(90,30,10,0.18) 10px 11px),
993 linear-gradient(180deg,#2B1B4F 0%,#B6427A 32%,#F28C4B 52%,#F6C177 62%,#C8693A 63%,#7A2E1C 100%)}
994.fd-lgx-ph--sea{background:
995 repeating-linear-gradient(178deg,rgba(255,255,255,0) 0 13px,rgba(255,255,255,0.55) 13px 14.5px,rgba(255,255,255,0) 14.5px 22px),
996 linear-gradient(180deg,#D8E8EE 0%,#9CC2CF 28%,#1F6F7A 30%,#0E4652 62%,#06222B 100%)}
997.fd-lgx-ph--citrus{background:
998 radial-gradient(circle at 28% 30%,#FFB347 0 13%,#E8590C 14%,#B42318 18%,transparent 18.5%),
999 radial-gradient(circle at 70% 26%,#FFC56E 0 11%,#F76707 12%,#A61E1E 15.5%,transparent 16%),
1000 radial-gradient(circle at 52% 58%,#FFD08A 0 15%,#F08C00 16%,#C2255C 21%,transparent 21.5%),
1001 radial-gradient(circle at 22% 80%,#FFE08A 0 12%,#FAB005 13%,#E8590C 17%,transparent 17.5%),
1002 radial-gradient(circle at 80% 78%,#FF8787 0 12%,#E03131 13%,#7A0E14 17%,transparent 17.5%),
1003 linear-gradient(160deg,#2F5D3A,#14301E)}
1004.fd-lgx-ph--house{background:
1005 repeating-linear-gradient(90deg,rgba(255,255,255,0.75) 0 1.5px,transparent 1.5px 26px),
1006 repeating-linear-gradient(0deg,rgba(255,255,255,0.75) 0 1.5px,transparent 1.5px 26px),
1007 radial-gradient(ellipse at 50% 110%,#E7F5C8 0%,#7FB069 30%,#2D6A4F 60%,#0B2B26 100%)}
1008.fd-lgx-cols{columns:3 260px;column-gap:40px;margin:0 0 64px;font:400 1.0625rem/1.6 ${SERIF};color:#1C1C22}
1009.fd-lgx-cols p{margin:0 0 16px;font:inherit}
1010.fd-lgx-drop{float:left;margin:4px 10px 0 0;font:800 4.4rem/0.8 ${DISPLAY};color:#C2410C}
1011.fd-lgx-index{margin:0 0 64px;padding:28px 0 0;border-top:2px solid #0B0B0F}
1012.fd-lgx-h2{margin:0 0 18px;font:700 2rem/1.1 ${DISPLAY};letter-spacing:-0.025em;color:#0B0B0F}
1013.fd-lgx-index ul{list-style:none;margin:0;padding:0;columns:2 300px;column-gap:56px}
1014.fd-lgx-index li{display:flex;align-items:baseline;gap:10px;margin:0;padding:10px 0;border-bottom:1px solid #0B0B0F;font:500 1.0625rem/1.3 ${FONT};break-inside:avoid}
1015.fd-lgx-index li i{flex:1;height:1px;border-bottom:1.5px dotted #6B6B76}
1016.fd-lgx-index li b{font:600 1.0625rem/1.3 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;color:#1D4ED8}
1017.fd-lgx-test{display:grid;grid-template-columns:2fr 1fr;gap:16px;margin:0 0 64px}
1018.fd-lgx-bars{height:220px;border-radius:4px;background:linear-gradient(90deg,#FFFFFF 0 14.28%,#FFD60A 14.28% 28.56%,#32D7E6 28.56% 42.84%,#30D158 42.84% 57.12%,#FF2D9B 57.12% 71.4%,#FF3B30 71.4% 85.68%,#0A3DFF 85.68%);box-shadow:inset 0 -46px 0 #0B0B0F,inset 0 -48px 0 #fff}
1019.fd-lgx-grid{height:220px;border-radius:4px;background:repeating-conic-gradient(#0B0B0F 0 25%,#F3EFE6 0 50%) 0 0/22px 22px}
1020.fd-lgx-cap{grid-column:1/-1;font:400 13px/1.4 ${FONT};color:#6B6B76}
1021.fd-lgx-quote{margin:0 0 56px;padding:56px 0;border-top:1px solid #0B0B0F;border-bottom:1px solid #0B0B0F;text-align:center}
1022.fd-lgx-quote p{margin:0 auto 14px;max-width:18ch;font:italic 400 clamp(2rem,4.6vw,4rem)/1.06 ${SERIF};letter-spacing:-0.025em;color:#0B0B0F}
1023.fd-lgx-quote span{font:500 13px/1.4 ${FONT};letter-spacing:0.06em;text-transform:uppercase;color:#6B6B76}
1024.fd-lgx-foot{display:flex;justify-content:space-between;font:500 12px/1 ${FONT};letter-spacing:0.08em;text-transform:uppercase;color:#6B6B76}
1025
1026.fd-lgx-lens{position:absolute;left:0;top:0;width:340px;height:132px;border-radius:66px;display:flex;align-items:flex-end;justify-content:center;cursor:grab;touch-action:none;will-change:transform;z-index:2}
1027.fd-lgx-lens.fd-lg--pressed{cursor:grabbing}
1028.fd-lgx-hint{display:inline-flex;align-items:center;gap:6px;margin:0 0 -40px;padding:6px 12px;border-radius:999px;background:rgba(11,11,15,0.82);color:#fff;font:600 12px/1 ${FONT};letter-spacing:0.01em;text-shadow:none;white-space:nowrap;pointer-events:none;transition:opacity var(--fd-dur-3) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-ease-out)}
1029.fd-lgx-hint svg{display:block}
1030.fd-lgx-hint.is-gone{opacity:0;transform:translateY(-6px)}
1031
1032.fd-lgx-bar{position:absolute;left:50%;bottom:24px;transform:translateX(-50%);display:flex;align-items:center;gap:6px;padding:6px;z-index:3;white-space:nowrap}
1033.fd-lgx-sep{display:block;width:1px;height:22px;margin:0 2px;background:rgba(11,11,15,0.14)}
1034.fd-lgx-seg{position:relative;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(max-content,1fr);margin:0;padding:0}
1035.fd-lgx-seg-thumb{position:absolute;top:0;bottom:0;left:0;width:calc(100% / var(--n));transform:translateX(calc(100% * var(--i)));border-radius:999px;background:#0B0B0F;box-shadow:0 2px 8px rgba(0,0,0,0.18);transition:transform var(--fd-dur-3) var(--fd-spring)}
1036.fd-lgx-seg-btn,.fd-lgx-dim{position:relative;display:inline-flex;align-items:center;justify-content:center;gap:6px;height:40px;min-width:40px;margin:0;padding:0 16px;border:0;border-radius:999px;background:transparent;color:#1C1C22;font:600 14px/1 ${FONT};letter-spacing:-0.005em;text-shadow:none;cursor:pointer;-webkit-appearance:none;appearance:none;transition:color var(--fd-dur-2) var(--fd-ease-out),background-color var(--fd-dur-1) linear}
1037.fd-lgx-seg.is-icons .fd-lgx-seg-btn{padding:0 11px}
1038.fd-lgx-seg-btn svg,.fd-lgx-dim svg{display:block;flex:none}
1039.fd-lgx-seg-btn[aria-checked="true"]{color:#fff}
1040.fd-lgx-seg-btn:not([aria-checked="true"]):hover,.fd-lgx-dim:not(:disabled):hover{background:rgba(11,11,15,0.06)}
1041.fd-lgx-dim[aria-checked="true"]{background:#0B0B0F;color:#fff}
1042.fd-lgx-dim:disabled{opacity:0.35;cursor:default}
1043.fd-lgx-seg-btn:focus-visible,.fd-lgx-dim:focus-visible,.fd-lgx-scroll:focus-visible{outline:2px solid color-mix(in srgb,#007AFF 70%,transparent);outline-offset:2px}
1044
1045@media (max-width:820px){
1046 .fd-lgx-paper{padding:20px 20px 150px}
1047 .fd-lgx-mast span:not(.fd-lgx-logo):not(:nth-child(3)){display:none}
1048 .fd-lgx-hero{grid-template-columns:1fr;grid-template-areas:"k" "h" "d" "b";margin-bottom:40px}
1049 .fd-lgx-h1{margin:0 0 20px;font-size:clamp(4.4rem,24vw,7rem)}
1050 .fd-lgx-plates{grid-template-columns:repeat(2,1fr);gap:12px}
1051 .fd-lgx-test{grid-template-columns:1fr}
1052 .fd-lgx-bars,.fd-lgx-grid{height:150px}
1053 .fd-lgx-bar{bottom:20px;gap:2px;padding:4px;max-width:calc(100% - 16px)}
1054 .fd-lgx-sep{margin:0 1px}
1055 .fd-lgx-seg-btn,.fd-lgx-dim{height:38px;min-width:36px;padding:0 10px;font-size:13px}
1056 .fd-lgx-seg.is-icons .fd-lgx-seg-btn{padding:0 8px}
1057 .fd-lgx-seg.is-icons .fd-lgx-seg-btn{padding:0 9px}
1058 .fd-lgx-dim span{display:none}
1059}
1060@media (prefers-reduced-motion: reduce){
1061 .fd-lgx-seg-thumb,.fd-lgx-hint{transition-duration:120ms;transition-timing-function:linear}
1062}
1063`;
1064
1065const GLASS_CSS = `
1066.fd-lg{
1067 --fd-ease-out:cubic-bezier(0.22,1,0.36,1);--fd-ease-in-out:cubic-bezier(0.65,0,0.35,1);--fd-spring:cubic-bezier(0.34,1.56,0.64,1);
1068 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
1069 --fd-lg-url:blur(0px);
1070 --fd-lg-blur:8px;--fd-lg-sat:1.8;--fd-lg-bright:1.06;--fd-lg-contrast:0.92;
1071 --fd-lg-fill:rgba(255,255,255,0.30);
1072 --fd-lg-fg:#0B0B0F;
1073 --fd-lg-edge-a:rgba(255,255,255,0.95);--fd-lg-edge-b:rgba(255,255,255,0.18);--fd-lg-edge-c:rgba(255,255,255,0.55);
1074 --fd-lg-shadow:0 12px 32px -10px rgba(10,10,20,0.28),0 2px 6px -1px rgba(10,10,20,0.10);
1075 --fd-lg-spec-o:0.9;
1076 isolation:isolate;box-sizing:border-box;margin:0;border:0;
1077 background:transparent;color:var(--fd-lg-fg);text-decoration:none;
1078 -webkit-appearance:none;appearance:none;-webkit-tap-highlight-color:transparent;
1079 -webkit-backdrop-filter:blur(var(--fd-lg-blur)) saturate(var(--fd-lg-sat)) brightness(var(--fd-lg-bright)) contrast(var(--fd-lg-contrast));
1080 backdrop-filter:var(--fd-lg-url) blur(var(--fd-lg-blur)) saturate(var(--fd-lg-sat)) brightness(var(--fd-lg-bright)) contrast(var(--fd-lg-contrast));
1081 box-shadow:var(--fd-lg-shadow);
1082}
1083:where(.fd-lg){position:relative;padding:0;border-radius:var(--fd-lg-r,0px);font:500 15px/1.25 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:normal;text-align:center}
1084.fd-lg *,.fd-lg *::before,.fd-lg *::after{box-sizing:border-box}
1085.fd-lg--flat{--fd-lg-shadow:none}
1086.fd-lg--regular.fd-lg--dark{--fd-lg-fill:rgba(30,30,36,0.42);--fd-lg-fg:#FFFFFF;--fd-lg-bright:0.92;--fd-lg-edge-a:rgba(255,255,255,0.55);--fd-lg-edge-b:rgba(255,255,255,0.08);--fd-lg-edge-c:rgba(255,255,255,0.28);--fd-lg-spec-o:0.55;--fd-lg-shadow:0 14px 36px -12px rgba(0,0,0,0.6),0 2px 6px -1px rgba(0,0,0,0.3)}
1087.fd-lg--clear{--fd-lg-blur:0.5px;--fd-lg-sat:1.3;--fd-lg-bright:1.03;--fd-lg-contrast:1;--fd-lg-fill:rgba(255,255,255,0.05);--fd-lg-fg:#FFFFFF;--fd-lg-spec-o:1;--fd-lg-shadow:0 10px 30px -10px rgba(0,0,0,0.35),0 1px 3px rgba(0,0,0,0.12);text-shadow:0 1px 2px rgba(0,0,0,0.28)}
1088.fd-lg--clear:not(.fd-lg--refract){--fd-lg-blur:2.5px;--fd-lg-sat:1.5;--fd-lg-fill:rgba(255,255,255,0.12)}
1089.fd-lg--regular:not(.fd-lg--refract){--fd-lg-blur:16px}
1090.fd-lg--tinted{--fd-lg-fg:#FFFFFF;text-shadow:none}
1091.fd-lg--tinted.fd-lg--regular{--fd-lg-fill:color-mix(in srgb,var(--fd-lg-tint) 84%,transparent)}
1092.fd-lg--tinted.fd-lg--clear{--fd-lg-fill:color-mix(in srgb,var(--fd-lg-tint) 58%,transparent)}
1093.fd-lg-svg{position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;display:block;margin:0;padding:0;border:0}
1094.fd-lg-layer{position:absolute;inset:0;z-index:-1;display:block;margin:0;padding:0;border:0;border-radius:inherit;pointer-events:none;background:none}
1095.fd-lg-fill{background:var(--fd-lg-fill);box-shadow:inset 0 0 18px -6px rgba(255,255,255,0.22)}
1096.fd-lg-dimmer{background:rgba(0,0,0,0.35)}
1097.fd-lg-spec{background-size:100% 100%;background-repeat:no-repeat;opacity:var(--fd-lg-spec-o);transition:opacity var(--fd-dur-2) var(--fd-ease-out)}
1098.fd-lg-edge{padding:1px;background:linear-gradient(140deg,var(--fd-lg-edge-a),var(--fd-lg-edge-b) 34%,var(--fd-lg-edge-b) 62%,var(--fd-lg-edge-c));
1099 -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#000 0 0) content-box exclude,linear-gradient(#000 0 0)}
1100.fd-lg--interactive{transform:var(--fd-lg-pre,translate(0,0)) scale(var(--fd-lg-s,1));cursor:pointer;touch-action:manipulation}
1101.fd-lg-glow{opacity:0;transition:opacity var(--fd-dur-3) var(--fd-ease-out);
1102 background:radial-gradient(circle at var(--fd-lg-mx,50%) var(--fd-lg-my,50%),rgba(255,255,255,0.55),rgba(255,255,255,0.14) 38%,transparent 70%);
1103 background-size:100% 100%;mix-blend-mode:plus-lighter}
1104.fd-lg--interactive:hover .fd-lg-glow{opacity:0.35}
1105.fd-lg--pressed .fd-lg-glow{opacity:1;transition-duration:var(--fd-dur-1)}
1106.fd-lg--released .fd-lg-glow{transition-duration:var(--fd-dur-4)}
1107.fd-lg:focus-visible{outline:2px solid color-mix(in srgb,#0A84FF 70%,transparent);outline-offset:2px}
1108.fd-lg[disabled],.fd-lg[aria-disabled="true"]{cursor:default}
1109@media (prefers-reduced-transparency: reduce){
1110 .fd-lg{-webkit-backdrop-filter:none;backdrop-filter:none;text-shadow:none}
1111 .fd-lg .fd-lg-fill{background:#ECECF0}
1112 .fd-lg--dark .fd-lg-fill{background:#2A2A31}
1113 .fd-lg--tinted .fd-lg-fill{background:var(--fd-lg-tint)}
1114 .fd-lg--regular.fd-lg--light:not(.fd-lg--tinted){--fd-lg-fg:#0B0B0F}
1115 .fd-lg-spec{opacity:0.25}
1116 .fd-lg-dimmer{display:none}
1117}
1118@media (prefers-contrast: more){
1119 .fd-lg{-webkit-backdrop-filter:none;backdrop-filter:none;text-shadow:none}
1120 .fd-lg .fd-lg-fill{background:#FFFFFF}
1121 .fd-lg--dark .fd-lg-fill{background:#000000}
1122 .fd-lg--tinted .fd-lg-fill{background:var(--fd-lg-tint)}
1123 .fd-lg--regular.fd-lg--light:not(.fd-lg--tinted){--fd-lg-fg:#000000}
1124 .fd-lg-spec,.fd-lg-dimmer{display:none}
1125 .fd-lg-edge{-webkit-mask:none;mask:none;background:none;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.75)}
1126 .fd-lg--dark .fd-lg-edge{box-shadow:inset 0 0 0 1px rgba(255,255,255,0.8)}
1127}
1128@media (prefers-reduced-motion: reduce){
1129 .fd-lg--interactive{transform:var(--fd-lg-pre,none)}
1130 .fd-lg-glow{transition-duration:var(--fd-dur-1)}
1131}
1132`;

More Liquid Glass. Same file contract.

Glass Button

glass, glassProminent, clear and clear-prominent capsules in four sizes, with a springy press and a glow from the touch point.

Glass Tab Bar

Floating iOS 26 tab bar: a liquid lens springs between tabs, search sits apart, and it minimizes as you scroll.

Glass Toggle

An iOS 26 switch whose knob swells into clear Liquid Glass while pressed or dragged, then settles back to solid.