Liquid Metal

Flowing chrome: a warped height field reflecting a procedural studio, in silver, gold, titanium, black chrome or oil-slick.

  • Liquid Metal
  • Luxury / Watches
  • React only · 0 deps
  • 1 file
Full screen

Browser support · WebGL2 / WebGL1. Without WebGL an animated CSS chrome gradient is shown.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/liquid-metal.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/liquid-metal/liquid-metal.tsx
1/**
2 * LiquidMetal — a flowing chrome surface. Domain-warped noise drives a height
3 * field; its normals reflect a procedural studio environment (soft boxes,
4 * horizon line, floor bounce) with Fresnel and an optional thin-film
5 * iridescence. The cursor bulges the surface and taps send out ripples.
6 *
7 * Props
8 * - tint?: "silver" | "gold" | "titanium" | "black-chrome" | "iridescent". Default "silver".
9 * - speed?: number Flow speed multiplier. Default 1.
10 * - distortion?: number Warp amount 0–2. Default 1.
11 * - scale?: number Feature size; larger = broader pools. Default 1.
12 * - interactive?: boolean Cursor bulge + tap ripples. Default true.
13 * - quality?: number Max render DPR (metal is smooth; 1.5 looks identical to 2 at ~half the cost). Default 1.5.
14 * - children Content rendered above the surface.
15 * CSS vars: --fd-lmetal-radius, --fd-lmetal-fallback.
16 *
17 * Browser support: WebGL2 / WebGL1. Without WebGL an animated CSS chrome
18 * gradient is shown. Reduced motion: a single still frame; no ripples.
19 * The canvas is aria-hidden.
20 */
21import { useEffect, useRef, useState } from "react";
22import type { CSSProperties, MutableRefObject, ReactNode, RefObject } from "react";
23
24/* ───────────────────────── tiny WebGL stage ───────────────────────── */
25
26type GL = WebGLRenderingContext | WebGL2RenderingContext;
27interface Stage {
28 gl: GL;
29 gl2: boolean;
30 prog: WebGLProgram;
31 u: Record<string, WebGLUniformLocation | null>;
32 w: number;
33 h: number;
34 dpr: number;
35 reduced: boolean;
36}
37interface StageHooks {
38 setup?: (s: Stage) => void;
39 /** Set uniforms; return true to keep animating. */
40 frame: (s: Stage, time: number, dt: number) => boolean;
41}
42
43function createStage(canvas: HTMLCanvasElement, frag: string): Stage | null {
44 const attrs: WebGLContextAttributes = { antialias: false, alpha: true, premultipliedAlpha: true, powerPreference: "high-performance" };
45 let gl: GL | null = canvas.getContext("webgl2", attrs) as WebGL2RenderingContext | null;
46 const gl2 = !!gl;
47 if (!gl) gl = (canvas.getContext("webgl", attrs) || canvas.getContext("experimental-webgl", attrs)) as WebGLRenderingContext | null;
48 if (!gl) return null;
49 const head = gl2 ? "#version 300 es\n" : "";
50 const vs = head + (gl2 ? "#define attribute in\n" : "") + "attribute vec2 aP;void main(){gl_Position=vec4(aP,0.,1.);}";
51 const fs =
52 head +
53 "#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n" +
54 (gl2 ? "#define texture2D texture\n#define gl_FragColor fdOut\nout vec4 fdOut;\n" : "") +
55 frag;
56 const make = (type: number, src: string) => {
57 const s = gl!.createShader(type);
58 if (!s) return null;
59 gl!.shaderSource(s, src);
60 gl!.compileShader(s);
61 if (!gl!.getShaderParameter(s, gl!.COMPILE_STATUS)) {
62 console.warn("[fd-shader]", gl!.getShaderInfoLog(s));
63 return null;
64 }
65 return s;
66 };
67 const v = make(gl.VERTEX_SHADER, vs);
68 const f = make(gl.FRAGMENT_SHADER, fs);
69 const prog = gl.createProgram();
70 if (!v || !f || !prog) return null;
71 gl.attachShader(prog, v);
72 gl.attachShader(prog, f);
73 gl.bindAttribLocation(prog, 0, "aP");
74 gl.linkProgram(prog);
75 if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
76 gl.useProgram(prog);
77 const buf = gl.createBuffer();
78 gl.bindBuffer(gl.ARRAY_BUFFER, buf);
79 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
80 gl.enableVertexAttribArray(0);
81 gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
82 const u: Stage["u"] = {};
83 for (const m of frag.matchAll(/uniform\s+\w+\s+(\w+)/g)) u[m[1]] = gl.getUniformLocation(prog, m[1]);
84 return { gl, gl2, prog, u, w: 1, h: 1, dpr: 1, reduced: false };
85}
86
87function useStage(ref: RefObject<HTMLCanvasElement | null>, frag: string, hooks: MutableRefObject<StageHooks>, maxDpr = 2) {
88 const [failed, setFailed] = useState(false);
89 const kick = useRef<() => void>(() => {});
90 useEffect(() => {
91 const canvas = ref.current;
92 if (!canvas) return;
93 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
94 let stage: Stage | null = null;
95 let raf = 0;
96 let last = 0;
97 let time = 0;
98 let visible = true;
99 let alive = true;
100 const loop = (now: number) => {
101 raf = 0;
102 if (!stage || !alive) return;
103 const dt = last ? Math.min((now - last) / 1000, 0.05) : 1 / 60;
104 last = now;
105 if (!reduced) time += dt;
106 const more = hooks.current.frame(stage, time, dt);
107 stage.gl.drawArrays(stage.gl.TRIANGLES, 0, 3);
108 if (more && !reduced && visible && !document.hidden) raf = requestAnimationFrame(loop);
109 else last = 0;
110 };
111 const request = () => {
112 if (!raf && alive && stage) raf = requestAnimationFrame(loop);
113 };
114 const resize = () => {
115 if (!stage) return;
116 const r = canvas.getBoundingClientRect();
117 const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
118 const w = Math.max(1, Math.round(r.width * dpr));
119 const h = Math.max(1, Math.round(r.height * dpr));
120 if (canvas.width !== w || canvas.height !== h) {
121 canvas.width = w;
122 canvas.height = h;
123 }
124 stage.w = w;
125 stage.h = h;
126 stage.dpr = dpr;
127 stage.gl.viewport(0, 0, w, h);
128 request();
129 };
130 const init = () => {
131 stage = createStage(canvas, frag);
132 if (!stage) {
133 setFailed(true);
134 return;
135 }
136 stage.reduced = reduced;
137 hooks.current.setup?.(stage);
138 resize();
139 };
140 const onLost = (e: Event) => {
141 e.preventDefault();
142 cancelAnimationFrame(raf);
143 raf = 0;
144 stage = null;
145 };
146 const onRestored = () => init();
147 const onVis = () => !document.hidden && request();
148 canvas.addEventListener("webglcontextlost", onLost);
149 canvas.addEventListener("webglcontextrestored", onRestored);
150 document.addEventListener("visibilitychange", onVis);
151 const ro = new ResizeObserver(resize);
152 ro.observe(canvas);
153 const io = new IntersectionObserver(([e]) => {
154 visible = e.isIntersecting;
155 if (visible) request();
156 });
157 io.observe(canvas);
158 kick.current = request;
159 init();
160 return () => {
161 alive = false;
162 cancelAnimationFrame(raf);
163 ro.disconnect();
164 io.disconnect();
165 canvas.removeEventListener("webglcontextlost", onLost);
166 canvas.removeEventListener("webglcontextrestored", onRestored);
167 document.removeEventListener("visibilitychange", onVis);
168 kick.current = () => {};
169 };
170 }, [ref, frag, hooks, maxDpr]);
171 return { failed, kick };
172}
173
174function makeTexture(gl: GL, filter: number, wrap: number) {
175 const t = gl.createTexture();
176 gl.bindTexture(gl.TEXTURE_2D, t);
177 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
178 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
179 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
180 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
181 return t;
182}
183
184const hexToRgb = (hex: string): [number, number, number] => {
185 let h = hex.replace("#", "").trim();
186 if (h.length === 3) h = h.split("").map((c) => c + c).join("");
187 const n = parseInt(h.slice(0, 6), 16) || 0;
188 return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
189};
190
191/* ───────────────────────── shader ───────────────────────── */
192
193const FRAG = /* glsl */ `
194uniform vec2 uRes;
195uniform float uTime;
196uniform float uDist;
197uniform float uScale;
198uniform vec3 uPtr; // px x, px y, strength
199uniform vec4 uRip[4]; // px x, px y, age (s), amplitude
200uniform float uTint;
201uniform float uDpr;
202
203vec2 hash2(vec2 p){
204 p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
205 return -1. + 2. * fract(sin(p) * 43758.5453);
206}
207float noise(vec2 p){
208 vec2 i = floor(p), f = fract(p);
209 vec2 u = f * f * (3. - 2. * f);
210 return mix(mix(dot(hash2(i), f), dot(hash2(i + vec2(1, 0)), f - vec2(1, 0)), u.x),
211 mix(dot(hash2(i + vec2(0, 1)), f - vec2(0, 1)), dot(hash2(i + vec2(1, 1)), f - vec2(1, 1)), u.x), u.y);
212}
213
214float height(vec2 p, vec2 px){
215 float t = uTime * .11;
216 // two-octave warp field
217 vec2 w = vec2(noise(p * .7 + vec2(t, -t * .7)) + .5 * noise(p * 1.4 - t),
218 noise(p * .7 + vec2(-t * .8, t) + 5.2) + .5 * noise(p * 1.4 + t + 3.1));
219 vec2 q = p + uDist * 1.1 * w;
220 float h = noise(q * .9 + t * .4) + .4 * noise(q * 1.9 - t * .3) + .14 * noise(q * 4.1 + t * .2);
221 // long liquid bands, the signature of poured chrome
222 h += .45 * sin(q.x * .9 + q.y * .45 + h * 2.2 + t * 2.);
223
224 // cursor bulge
225 vec2 dp = (px - uPtr.xy) / uDpr;
226 float d2 = dot(dp, dp);
227 h += uPtr.z * .9 * exp(-d2 / 9000.);
228 // tap ripples
229 for (int i = 0; i < 4; i++) {
230 vec4 r = uRip[i];
231 if (r.w <= 0.) continue;
232 float d = length(px - r.xy) / uDpr;
233 float front = r.z * 420.;
234 float x = d - front;
235 h += r.w * sin(x * .06) * exp(-x * x / 5200.) * exp(-r.z * 1.4);
236 }
237 return h;
238}
239
240vec3 studio(vec3 r){
241 // r: reflected direction, z toward viewer, y up
242 float el = r.y;
243 float az = atan(r.x, r.z);
244 // the wall behind the camera: graded charcoal → pale, so flat metal reads moody, not grey
245 vec3 sky = mix(vec3(.3, .31, .34), vec3(.92, .93, .96), smoothstep(0., .7, el + .3 * abs(az)));
246 vec3 floorC = mix(vec3(.02, .021, .025), vec3(.14, .13, .12), smoothstep(-1., -.3, el));
247 vec3 c = mix(floorC, sky, smoothstep(-.12, -.06, el));
248 // bright cyc sweep right above the horizon
249 c += vec3(1.) * .7 * exp(-pow((el + .04) / .03, 2.));
250 // soft boxes: a big key left, a strip right, a top light
251 float b1 = smoothstep(.08, 0., abs(az + .55) - .2) * smoothstep(.06, 0., abs(el - .35) - .22);
252 float b2 = smoothstep(.04, 0., abs(az - .62) - .07) * smoothstep(.05, 0., abs(el - .18) - .3);
253 float b3 = smoothstep(.15, 0., abs(el - .9) - .05);
254 c += vec3(1.5) * b1 + vec3(1.2) * b2 + vec3(.7) * b3;
255 // dark flags give chrome its deep contrast bands
256 c *= 1. - .85 * smoothstep(.06, 0., abs(az - .3) - .05) * smoothstep(-.05, .1, el);
257 c *= 1. - .6 * smoothstep(.06, 0., abs(az + 1.25) - .1) * smoothstep(-.05, .1, el);
258 return c;
259}
260
261vec3 tintOf(float k, out float black){
262 black = 0.;
263 if (k < .5) return vec3(.96, .965, .975); // silver
264 if (k < 1.5) return vec3(1., .78, .42); // gold
265 if (k < 2.5) return vec3(.78, .8, .84); // titanium
266 if (k < 3.5) { black = 1.; return vec3(.55, .56, .6); } // black chrome
267 return vec3(.95); // iridescent base
268}
269
270void main(){
271 vec2 px = gl_FragCoord.xy;
272 float m = min(uRes.x, uRes.y);
273 vec2 p = (px - .5 * uRes) / m * 1.7 / uScale;
274 float e = 1.5 / m;
275 float h = height(p, px);
276 float hx = height(p + vec2(e, 0.), px + vec2(1.5, 0.));
277 float hy = height(p + vec2(0., e), px + vec2(0., 1.5));
278 vec3 n = normalize(vec3(-(hx - h) / e, -(hy - h) / e, 1.) * vec3(.26, .26, 1.));
279
280 // slight perspective so the top of the frame "looks" further away
281 vec2 sp = (px / uRes - .5);
282 vec3 v = normalize(vec3(sp.x * .35, sp.y * .35 + .1, -1.));
283 vec3 r = reflect(v, n);
284 vec3 env = studio(r);
285
286 float black;
287 vec3 tint = tintOf(uTint, black);
288 float cosT = clamp(dot(n, -v), 0., 1.);
289 float fres = pow(1. - cosT, 5.);
290 vec3 F = mix(tint, vec3(1.), fres);
291 vec3 col = env * F;
292
293 if (black > .5) col = pow(env, vec3(1.7)) * .55 * F + .02;
294 if (uTint > 3.5) {
295 // thin film: thickness from height + view angle
296 float film = h * .55 + cosT * 1.2 + uTime * .03;
297 vec3 iri = .62 + .38 * cos(6.2831 * (film + vec3(0., .33, .67)));
298 // film shows most on mid-tones; highlights stay white, shadows stay deep
299 float lum = dot(col, vec3(.333));
300 col *= mix(vec3(1.), iri * 1.12, .42 * smoothstep(.05, .5, lum) * (1. - .5 * smoothstep(.9, 1.4, lum)));
301 }
302 if (uTint > .5 && uTint < 1.5) col += vec3(.06, .03, 0.) * fres;
303
304 // gentle filmic shoulder + vignette
305 col = col / (1. + col * .28) * 1.18;
306 float vig = 1. - .32 * dot(sp, sp);
307 col *= vig;
308 gl_FragColor = vec4(col, 1.);
309}
310`;
311
312/* ───────────────────────── component ───────────────────────── */
313
314export type MetalTint = "silver" | "gold" | "titanium" | "black-chrome" | "iridescent";
315const TINTS: Record<MetalTint, number> = { silver: 0, gold: 1, titanium: 2, "black-chrome": 3, iridescent: 4 };
316const FALLBACKS: Record<MetalTint, string> = {
317 silver: "linear-gradient(160deg, #f4f5f7 0%, #9da1a8 22%, #fdfdfd 38%, #2b2c30 52%, #c7cad0 66%, #5d6067 84%, #eef0f3 100%)",
318 gold: "linear-gradient(160deg, #fff1cc 0%, #b98a3e 22%, #fff6dc 38%, #3a2a12 52%, #e5c27a 66%, #7a5a26 84%, #fbe6b3 100%)",
319 titanium: "linear-gradient(160deg, #e6e9ee 0%, #868d98 22%, #f1f3f6 38%, #24272d 52%, #b3b9c2 66%, #535963 84%, #dfe3e9 100%)",
320 "black-chrome": "linear-gradient(160deg, #6b6e75 0%, #1b1c20 22%, #9a9da4 38%, #050506 52%, #45484e 66%, #0f1013 84%, #7a7d84 100%)",
321 iridescent: "linear-gradient(160deg, #f3e9ff 0%, #8fb7d8 22%, #fff5e1 38%, #2e2a3a 52%, #d7c1f0 66%, #5d7f80 84%, #f2f0e6 100%)",
322};
323
324export interface LiquidMetalProps {
325 tint?: MetalTint;
326 speed?: number;
327 distortion?: number;
328 scale?: number;
329 interactive?: boolean;
330 quality?: number;
331 className?: string;
332 style?: CSSProperties;
333 children?: ReactNode;
334}
335
336export function LiquidMetal({
337 tint = "silver",
338 speed = 1,
339 distortion = 1,
340 scale = 1,
341 interactive = true,
342 quality = 1.5,
343 className,
344 style,
345 children,
346}: LiquidMetalProps) {
347 const rootRef = useRef<HTMLDivElement>(null);
348 const canvasRef = useRef<HTMLCanvasElement>(null);
349 const props = useRef({ tint, speed, distortion, scale, interactive });
350 props.current = { tint, speed, distortion, scale, interactive };
351 const st = useRef({
352 t: 0,
353 ptr: { x: 0, y: 0, tx: 0, ty: 0, s: 0, ts: 0 },
354 rip: [] as { x: number; y: number; age: number; amp: number }[],
355 tint: TINTS[tint],
356 });
357
358 const hooks = useRef<StageHooks>({
359 frame(s, _time, dt) {
360 const { gl, u } = s;
361 const p = props.current;
362 const S = st.current;
363 if (!s.reduced) S.t += dt * p.speed;
364 const pt = S.ptr;
365 const k = s.reduced ? 1 : 1 - Math.exp(-dt * 6);
366 pt.x += (pt.tx - pt.x) * k;
367 pt.y += (pt.ty - pt.y) * (1 - Math.exp(-dt * 6));
368 pt.s += (pt.ts - pt.s) * (1 - Math.exp(-dt * 3));
369 S.rip = S.rip.filter((r) => (r.age += dt) < 2.6);
370 const rip = new Float32Array(16);
371 S.rip.slice(-4).forEach((r, i) => rip.set([r.x * s.dpr, s.h - r.y * s.dpr, r.age, r.amp], i * 4));
372 gl.uniform2f(u.uRes, s.w, s.h);
373 gl.uniform1f(u.uTime, S.t + 7);
374 gl.uniform1f(u.uDist, p.distortion);
375 gl.uniform1f(u.uScale, p.scale);
376 gl.uniform3f(u.uPtr, pt.x * s.dpr, s.h - pt.y * s.dpr, p.interactive ? pt.s : 0);
377 gl.uniform4fv(u.uRip, rip);
378 gl.uniform1f(u.uTint, TINTS[p.tint] ?? 0);
379 gl.uniform1f(u.uDpr, s.dpr);
380 return true;
381 },
382 });
383 const { failed, kick } = useStage(canvasRef, FRAG, hooks, quality);
384 useEffect(() => kick.current(), [tint, distortion, scale, kick]);
385
386 useEffect(() => {
387 const el = rootRef.current;
388 if (!el || !interactive) return;
389 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
390 if (reduced) return;
391 let lastX = 0;
392 let lastY = 0;
393 let travel = 0;
394 const local = (e: PointerEvent) => {
395 const r = el.getBoundingClientRect();
396 return [e.clientX - r.left, e.clientY - r.top];
397 };
398 const move = (e: PointerEvent) => {
399 const [x, y] = local(e);
400 const pt = st.current.ptr;
401 if (pt.ts === 0) {
402 pt.x = x;
403 pt.y = y;
404 lastX = x;
405 lastY = y;
406 }
407 pt.tx = x;
408 pt.ty = y;
409 pt.ts = 1;
410 travel += Math.hypot(x - lastX, y - lastY);
411 lastX = x;
412 lastY = y;
413 if (travel > 260) {
414 travel = 0;
415 st.current.rip.push({ x, y, age: 0, amp: 0.22 });
416 }
417 };
418 const down = (e: PointerEvent) => {
419 const [x, y] = local(e);
420 st.current.rip.push({ x, y, age: 0, amp: 0.6 });
421 };
422 const leave = () => {
423 st.current.ptr.ts = 0;
424 };
425 el.addEventListener("pointermove", move);
426 el.addEventListener("pointerdown", down);
427 el.addEventListener("pointerleave", leave);
428 return () => {
429 el.removeEventListener("pointermove", move);
430 el.removeEventListener("pointerdown", down);
431 el.removeEventListener("pointerleave", leave);
432 };
433 }, [interactive]);
434
435 return (
436 <div ref={rootRef} className={`fd-lmetal${className ? " " + className : ""}`} style={{ ["--fd-lmetal-fallback" as string]: FALLBACKS[tint], ...style }}>
437 <style>{css}</style>
438 {failed ? <div className="fd-lmetal-fallback" aria-hidden="true" /> : <canvas ref={canvasRef} className="fd-lmetal-canvas" aria-hidden="true" />}
439 {children ? <div className="fd-lmetal-content">{children}</div> : null}
440 </div>
441 );
442}
443
444const css = `
445.fd-lmetal, .fd-lmetal *, .fd-lmetal *::before, .fd-lmetal *::after { box-sizing: border-box; }
446.fd-lmetal {
447 position: relative; display: block; margin: 0; padding: 0; border: 0; overflow: hidden;
448 border-radius: var(--fd-lmetal-radius, 0px); background: #8d9096; color: inherit; isolation: isolate;
449}
450.fd-lmetal-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; z-index: 0; }
451.fd-lmetal-fallback {
452 position: absolute; inset: -20%; margin: 0; padding: 0; border: 0; z-index: 0;
453 background: var(--fd-lmetal-fallback); background-size: 160% 160%;
454 filter: blur(18px) saturate(1.1);
455 animation: fd-lmetal-flow 14s cubic-bezier(0.65, 0, 0.35, 1) infinite alternate;
456}
457@keyframes fd-lmetal-flow { from { background-position: 0% 0%; } to { background-position: 100% 100%; } }
458@media (prefers-reduced-motion: reduce) { .fd-lmetal-fallback { animation: none; } }
459.fd-lmetal-content { position: relative; z-index: 1; margin: 0; padding: 0; height: 100%; }
460`;
461
462/* ───────────────────────── demo ───────────────────────── */
463
464const FINISHES: { id: MetalTint; name: string; price: string; swatch: string }[] = [
465 { id: "silver", name: "Polished steel", price: "CHF 12,400", swatch: "linear-gradient(135deg,#fafafa,#8e9299 55%,#e9eaec)" },
466 { id: "titanium", name: "Grade 5 titanium", price: "CHF 13,900", swatch: "linear-gradient(135deg,#e3e6eb,#6d737d 55%,#c9ced6)" },
467 { id: "gold", name: "18k yellow gold", price: "CHF 38,500", swatch: "linear-gradient(135deg,#fff0c4,#b0853a 55%,#f3d891)" },
468 { id: "black-chrome", name: "Black DLC", price: "CHF 14,800", swatch: "linear-gradient(135deg,#7b7e85,#0d0e10 55%,#4a4d53)" },
469 { id: "iridescent", name: "Oil-slick PVD", price: "CHF 16,200", swatch: "linear-gradient(135deg,#f4e3ff,#6f8fb3 45%,#e7d39a 75%,#b9e2d4)" },
470];
471
472export function Demo() {
473 const [finish, setFinish] = useState<MetalTint>("silver");
474 const current = FINISHES.find((f) => f.id === finish)!;
475 return (
476 <section className="fd-lmetal-demo">
477 <style>{demoCss}</style>
478 <header className="fd-lmetal-demo-head">
479 <p className="fd-lmetal-demo-kicker">Halden Chronométrie · Ref. 41 Mercure</p>
480 <h1 className="fd-lmetal-demo-title">Cast in liquid light.</h1>
481 <p className="fd-lmetal-demo-sub">A 41 mm monobloc case, hand-polished for 31 hours until it reads like poured metal. Limited to 410 pieces worldwide.</p>
482 </header>
483 <LiquidMetal className="fd-lmetal-demo-panel" tint={finish}>
484 <div className="fd-lmetal-demo-bar" role="group" aria-label="Case finish">
485 <div className="fd-lmetal-demo-swatches">
486 {FINISHES.map((f) => (
487 <button
488 key={f.id}
489 type="button"
490 className="fd-lmetal-demo-swatch"
491 aria-pressed={finish === f.id}
492 aria-label={f.name}
493 title={f.name}
494 onClick={() => setFinish(f.id)}
495 style={{ background: f.swatch }}
496 />
497 ))}
498 </div>
499 <div className="fd-lmetal-demo-info">
500 <span className="fd-lmetal-demo-finish">{current.name}</span>
501 <span className="fd-lmetal-demo-price">{current.price}</span>
502 </div>
503 <a className="fd-lmetal-demo-cta" href="#reserve" onClick={(e) => e.preventDefault()}>Reserve</a>
504 </div>
505 </LiquidMetal>
506 <dl className="fd-lmetal-demo-specs">
507 <div><dt>Movement</dt><dd>HC-07 manual, 72 h</dd></div>
508 <div><dt>Water resistance</dt><dd>100 m</dd></div>
509 <div><dt>Thickness</dt><dd>9.8 mm</dd></div>
510 <div><dt>Deliveries</dt><dd>From March 2027</dd></div>
511 </dl>
512 </section>
513 );
514}
515
516const demoCss = `
517.fd-lmetal-demo, .fd-lmetal-demo *, .fd-lmetal-demo *::before, .fd-lmetal-demo *::after { box-sizing: border-box; }
518.fd-lmetal-demo {
519 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
520 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
521 min-height: 100vh; margin: 0; padding: clamp(48px, 9vh, 104px) clamp(16px, 4vw, 48px) 48px;
522 display: flex; flex-direction: column; align-items: center; gap: clamp(32px, 5vh, 56px);
523 background: #000; color: #F5F5F7;
524 font: 400 15px/1.5 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
525}
526.fd-lmetal-demo-head { max-width: 760px; margin: 0; padding: 0; text-align: center; }
527.fd-lmetal-demo-kicker { margin: 0 0 14px; padding: 0; color: #A1A1A6; font: 500 13px/1.3 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: 0.06em; text-transform: uppercase; }
528.fd-lmetal-demo-title {
529 margin: 0 0 18px; padding: 0; letter-spacing: -0.045em;
530 font: 700 clamp(2.75rem, 7vw, 5.5rem)/1 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif;
531 color: #F5F5F7;
532 background: linear-gradient(180deg, #FFFFFF 0%, #D2D3D8 55%, #8E9097 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
533}
534.fd-lmetal-demo-sub { max-width: 52ch; margin: 0 auto; padding: 0; color: #A1A1A6; font: 400 1.0625rem/1.5 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; }
535.fd-lmetal-demo-panel.fd-lmetal { width: 100%; max-width: 1280px; aspect-ratio: 16 / 8; min-height: 380px; --fd-lmetal-radius: 28px; cursor: grab; }
536.fd-lmetal-demo-panel:active { cursor: grabbing; }
537.fd-lmetal-demo-panel.fd-lmetal .fd-lmetal-content { display: flex; align-items: flex-end; justify-content: center; padding: 24px; }
538.fd-lmetal-demo-bar {
539 display: flex; align-items: center; gap: 18px; margin: 0; padding: 8px 8px 8px 14px;
540 border: 1px solid rgba(255,255,255,.12); border-radius: 9999px;
541 background: rgba(14, 14, 16, .72); -webkit-backdrop-filter: blur(20px) saturate(1.4); backdrop-filter: blur(20px) saturate(1.4);
542 box-shadow: 0 20px 50px -20px rgba(0,0,0,.6);
543}
544.fd-lmetal-demo-swatches { display: flex; gap: 8px; margin: 0; padding: 0; }
545.fd-lmetal-demo-swatch {
546 appearance: none; -webkit-appearance: none; display: block; width: 26px; height: 26px; margin: 0; padding: 0;
547 border: 0; border-radius: 50%; cursor: pointer;
548 box-shadow: inset 0 0 0 1px rgba(255,255,255,.25), 0 0 0 2px rgba(14,14,16,1), 0 0 0 3px transparent;
549 transition: transform 120ms var(--fd-ease-out), box-shadow 240ms var(--fd-ease-out);
550}
551.fd-lmetal-demo-swatch:hover { transform: scale(1.08); }
552.fd-lmetal-demo-swatch:active { transform: scale(0.96); }
553.fd-lmetal-demo-swatch[aria-pressed="true"] { box-shadow: inset 0 0 0 1px rgba(255,255,255,.25), 0 0 0 2px rgba(14,14,16,1), 0 0 0 3.5px #F5F5F7; }
554.fd-lmetal-demo-swatch:focus-visible { outline: 2px solid rgba(10,132,255,.7); outline-offset: 4px; }
555.fd-lmetal-demo-info { display: flex; flex-direction: column; min-width: 128px; margin: 0; padding: 0 4px; }
556.fd-lmetal-demo-finish { margin: 0; padding: 0; color: #F5F5F7; font: 600 13px/1.3 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: 0; }
557.fd-lmetal-demo-price { margin: 0; padding: 0; color: #A1A1A6; font: 500 12px/1.3 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; font-variant-numeric: tabular-nums; letter-spacing: 0; }
558.fd-lmetal-demo-cta {
559 display: inline-flex; align-items: center; height: 40px; margin: 0; padding: 0 20px; border: 0; border-radius: 9999px;
560 background: #F5F5F7; color: #0B0B0F; text-decoration: none;
561 font: 600 14px/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em;
562 transition: transform 120ms var(--fd-ease-out), background 120ms var(--fd-ease-out);
563}
564.fd-lmetal-demo-cta:hover { background: #FFFFFF; }
565.fd-lmetal-demo-cta:active { transform: scale(0.96); transition-timing-function: var(--fd-spring); }
566.fd-lmetal-demo-cta:focus-visible { outline: 2px solid rgba(10,132,255,.7); outline-offset: 2px; }
567.fd-lmetal-demo-specs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; width: 100%; max-width: 1280px; margin: 0; padding: 0 8px; }
568.fd-lmetal-demo-specs div { margin: 0; padding: 16px 0 0; border-top: 1px solid #2A2A2E; }
569.fd-lmetal-demo-specs dt { margin: 0 0 4px; padding: 0; color: #86868B; font: 500 12px/1.3 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: 0.02em; }
570.fd-lmetal-demo-specs dd { margin: 0; padding: 0; color: #F5F5F7; font: 600 17px/1.3 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; }
571@media (max-width: 720px) {
572 .fd-lmetal-demo-panel.fd-lmetal { aspect-ratio: 4 / 5; min-height: 0; }
573 .fd-lmetal-demo-bar { flex-wrap: wrap; justify-content: center; border-radius: 24px; padding: 12px; gap: 12px; }
574 .fd-lmetal-demo-info { align-items: center; }
575 .fd-lmetal-demo-specs { grid-template-columns: 1fr 1fr; }
576}
577`;

More Liquid Metal. Same file contract.

Liquid Metal Button

A capsule CTA with a live chrome rim that ripples from your touch — plus a zero-JS CSS chrome variant.

Liquid Metal Text

A giant headline cast in flowing chrome with bevelled edges; tilt your pointer and the studio lights slide across the letters.

Liquid Metal Blob

A raymarched drop of mercury that morphs, merges and reaches for your cursor — a hero centrepiece on a transparent canvas.