Liquid Metal Blob

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

  • Liquid Metal
  • AI / SaaS
  • React only · 0 deps
  • 1 file
Full screen

Browser support · WebGL2 / WebGL1. Without WebGL a morphing CSS chrome sphere is shown.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/liquid-metal-blob.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/liquid-metal/liquid-metal-blob.tsx
1/**
2 * LiquidMetalBlob — a morphing drop of mercury for hero centrepieces. A tiny
3 * raymarcher blends a few metaball spheres with a smooth-min, wobbles the
4 * surface with sine displacement and reflects a procedural studio. The blob
5 * leans toward the cursor and a satellite droplet is pulled out by it.
6 *
7 * Props
8 * - tint?: "silver" | "gold" | "titanium" | "black-chrome". Default "silver".
9 * - speed?: number Morph speed. Default 1.
10 * - size?: number Blob radius relative to the canvas. Default 1.
11 * - interactive?: boolean Follows the pointer. Default true.
12 * - background?: string Canvas clear colour; "transparent" by default so it sits on any page.
13 * - quality?: number Max DPR. Default 1.5.
14 * CSS vars: --fd-lmblob-fallback.
15 *
16 * Browser support: WebGL2 / WebGL1. Without WebGL a CSS chrome sphere is
17 * shown. Reduced motion: one still frame. Canvas is aria-hidden.
18 * Performance: 48-step raymarch with early exit, bounded by a sphere test —
19 * pixels outside the blob's bounds cost almost nothing.
20 */
21import { useEffect, useRef, useState } from "react";
22import type { CSSProperties, MutableRefObject, 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 vec2 uPtr; // -1..1 pointer, smoothed
197uniform float uPull; // 0..1 pointer presence
198uniform float uTint;
199uniform float uSize;
200
201float smin(float a, float b, float k){
202 float h = clamp(.5 + .5 * (b - a) / k, 0., 1.);
203 return mix(b, a, h) - k * h * (1. - h);
204}
205
206vec3 c0, c1, c2, c3;
207
208float map(vec3 p){
209 float t = uTime;
210 float d = length(p - c0) - .62 * uSize;
211 d = smin(d, length(p - c1) - .34 * uSize, .32);
212 d = smin(d, length(p - c2) - .28 * uSize, .3);
213 d = smin(d, length(p - c3) - (.16 + .06 * uPull) * uSize, .3);
214 // liquid surface wobble
215 d += .035 * sin(p.x * 5. + t * 1.7) * sin(p.y * 4.3 - t * 1.3) * sin(p.z * 4.7 + t);
216 return d;
217}
218
219vec3 normalAt(vec3 p){
220 vec2 e = vec2(.0015, 0.);
221 return normalize(vec3(map(p + e.xyy) - map(p - e.xyy), map(p + e.yxy) - map(p - e.yxy), map(p + e.yyx) - map(p - e.yyx)));
222}
223
224vec3 studio(vec3 r){
225 float el = r.y;
226 float az = atan(r.x, -r.z);
227 vec3 sky = mix(vec3(.24, .25, .28), vec3(.96, .97, .99), smoothstep(-.02, .75, el + .2 * abs(az)));
228 // studio floor: dark contact band at the horizon, lifting to a soft grey underneath
229 vec3 fl = mix(vec3(.04, .04, .045), vec3(.42, .42, .44), smoothstep(-.12, -.85, el));
230 vec3 c = mix(fl, sky, smoothstep(-.08, -.03, el));
231 c += .7 * exp(-pow((el + .01) / .025, 2.));
232 c += 1.6 * smoothstep(.1, 0., abs(az + .7) - .25) * smoothstep(.06, 0., abs(el - .4) - .22);
233 c += 1.2 * smoothstep(.05, 0., abs(az - .9) - .07) * smoothstep(.05, 0., abs(el - .15) - .32);
234 c += .6 * smoothstep(.2, 0., abs(el - .92) - .05);
235 c *= 1. - .85 * smoothstep(.07, 0., abs(az - .2) - .06) * smoothstep(-.05, .1, el);
236 c *= 1. - .7 * smoothstep(.12, 0., abs(abs(az) - 2.6) - .3) * smoothstep(-.05, .1, el);
237 return c;
238}
239
240void main(){
241 vec2 uv = (gl_FragCoord.xy - .5 * uRes) / min(uRes.x, uRes.y);
242 float t = uTime;
243 vec2 ptr = uPtr * uPull;
244 c0 = vec3(ptr * .1, 0.);
245 // satellites orbit just far enough to stretch, pinch off and re-merge
246 c1 = vec3(sin(t * .7) * .72, cos(t * .9) * .42, sin(t * .5) * .3) * uSize + vec3(ptr * .15, 0.);
247 c2 = vec3(cos(t * .6 + 2.) * .66, sin(t * .8 + 1.) * .6, cos(t * .7) * .3) * uSize;
248 c3 = mix(vec3(sin(t * .5 + 4.) * .95, cos(t * .45) * .8, .2) * uSize, vec3(ptr * vec2(.85, .7), .3), uPull * .7);
249
250 vec3 ro = vec3(0., 0., 4.);
251 vec3 rd = normalize(vec3(uv, -1.62));
252 // bounding sphere — skip the march for background pixels
253 float R = 1.3 * uSize + .2;
254 float b = dot(ro, rd);
255 float cc = dot(ro, ro) - R * R;
256 float disc = b * b - cc;
257 vec4 outc = vec4(0.);
258 if (disc > 0.) {
259 float tt = max(-b - sqrt(disc), 0.);
260 float tEnd = -b + sqrt(disc);
261 bool hit = false;
262 for (int i = 0; i < 48; i++) {
263 float d = map(ro + rd * tt);
264 if (d < .0015) { hit = true; break; }
265 tt += d * .9;
266 if (tt > tEnd) break;
267 }
268 if (hit) {
269 vec3 p = ro + rd * tt;
270 vec3 n = normalAt(p);
271 vec3 r = reflect(rd, n);
272 vec3 env = studio(r);
273 float fres = pow(1. - clamp(dot(n, -rd), 0., 1.), 5.);
274 vec3 tint = uTint < .5 ? vec3(.96, .965, .975) : uTint < 1.5 ? vec3(1., .78, .43) : uTint < 2.5 ? vec3(.78, .8, .85) : vec3(.5);
275 vec3 col = env * mix(tint, vec3(1.), fres);
276 if (uTint > 2.5) col = pow(env, vec3(1.6)) * .6 + .02;
277 // fake contact occlusion where lobes merge
278 float ao = clamp(map(p + n * .12) / .12, 0., 1.);
279 col *= .55 + .45 * ao;
280 col = col / (1. + col * .25) * 1.15;
281 outc = vec4(col, 1.);
282 }
283 }
284 // soft floor shadow / reflection glow under the blob
285 vec2 sq = (uv - vec2(ptr.x * .04, -.36 * uSize - .02)) * vec2(1., 6.) / (.24 * uSize);
286 float sh = exp(-dot(sq, sq)) * .26;
287 outc.a = outc.a + sh * (1. - outc.a);
288 gl_FragColor = outc;
289}
290`;
291
292/* ───────────────────────── component ───────────────────────── */
293
294type Tint = "silver" | "gold" | "titanium" | "black-chrome";
295const TINTS: Record<Tint, number> = { silver: 0, gold: 1, titanium: 2, "black-chrome": 3 };
296const FALLBACK: Record<Tint, string> = {
297 silver: "radial-gradient(circle at 34% 28%, #ffffff 0 8%, #c9ccd2 22%, #3a3c41 52%, #8a8e96 72%, #1b1c20 100%)",
298 gold: "radial-gradient(circle at 34% 28%, #fff6dc 0 8%, #e2bd72 22%, #4d3712 52%, #b0853a 72%, #2a1d08 100%)",
299 titanium: "radial-gradient(circle at 34% 28%, #f3f5f8 0 8%, #aeb5bf 22%, #30343a 52%, #737a85 72%, #15171b 100%)",
300 "black-chrome": "radial-gradient(circle at 34% 28%, #9a9da4 0 6%, #3f4248 20%, #040405 52%, #2a2c31 72%, #000 100%)",
301};
302
303export interface LiquidMetalBlobProps {
304 tint?: Tint;
305 speed?: number;
306 size?: number;
307 interactive?: boolean;
308 background?: string;
309 quality?: number;
310 className?: string;
311 style?: CSSProperties;
312}
313
314export function LiquidMetalBlob({
315 tint = "silver",
316 speed = 1,
317 size = 1,
318 interactive = true,
319 background = "transparent",
320 quality = 1.5,
321 className,
322 style,
323}: LiquidMetalBlobProps) {
324 const rootRef = useRef<HTMLDivElement>(null);
325 const canvasRef = useRef<HTMLCanvasElement>(null);
326 const st = useRef({ t: 0, x: 0, y: 0, tx: 0, ty: 0, pull: 0, tpull: 0, tint, speed, size });
327 st.current.tint = tint;
328 st.current.speed = speed;
329 st.current.size = size;
330
331 const hooks = useRef<StageHooks>({
332 setup(s) {
333 s.gl.clearColor(0, 0, 0, 0);
334 },
335 frame(s, _time, dt) {
336 const { gl, u } = s;
337 const S = st.current;
338 if (!s.reduced) S.t += dt * S.speed;
339 const k = s.reduced ? 1 : 1 - Math.exp(-dt * 3.2);
340 S.x += (S.tx - S.x) * k;
341 S.y += (S.ty - S.y) * k;
342 S.pull += (S.tpull - S.pull) * (s.reduced ? 1 : 1 - Math.exp(-dt * 2.2));
343 gl.clear(gl.COLOR_BUFFER_BIT);
344 gl.uniform2f(u.uRes, s.w, s.h);
345 gl.uniform1f(u.uTime, S.t + 2.4);
346 gl.uniform2f(u.uPtr, S.x, S.y);
347 gl.uniform1f(u.uPull, S.pull);
348 gl.uniform1f(u.uTint, TINTS[S.tint] ?? 0);
349 gl.uniform1f(u.uSize, S.size);
350 return true;
351 },
352 });
353 const { failed, kick } = useStage(canvasRef, FRAG, hooks, quality);
354 useEffect(() => kick.current(), [tint, size, kick]);
355
356 useEffect(() => {
357 const el = rootRef.current;
358 if (!el || !interactive) return;
359 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
360 // Track the pointer page-wide so the blob reacts before you reach it.
361 const move = (e: PointerEvent) => {
362 const r = el.getBoundingClientRect();
363 const m = Math.min(r.width, r.height);
364 const x = (e.clientX - (r.left + r.width / 2)) / (m / 2);
365 const y = -(e.clientY - (r.top + r.height / 2)) / (m / 2);
366 const d = Math.hypot(x, y);
367 const clamp = d > 1.2 ? 1.2 / d : 1;
368 st.current.tx = x * clamp;
369 st.current.ty = y * clamp;
370 st.current.tpull = Math.max(0, 1 - Math.max(0, d - 1.4) / 1.6);
371 };
372 const leave = () => {
373 st.current.tpull = 0;
374 };
375 window.addEventListener("pointermove", move, { passive: true });
376 document.addEventListener("pointerleave", leave);
377 return () => {
378 window.removeEventListener("pointermove", move);
379 document.removeEventListener("pointerleave", leave);
380 };
381 }, [interactive]);
382
383 return (
384 <div ref={rootRef} className={`fd-lmblob${className ? " " + className : ""}`} style={{ background, ["--fd-lmblob-fallback" as string]: FALLBACK[tint], ...style }}>
385 <style>{css}</style>
386 {failed ? <div className="fd-lmblob-fallback" aria-hidden="true" /> : <canvas ref={canvasRef} className="fd-lmblob-canvas" aria-hidden="true" />}
387 </div>
388 );
389}
390
391const css = `
392.fd-lmblob, .fd-lmblob *, .fd-lmblob *::before, .fd-lmblob *::after { box-sizing: border-box; }
393.fd-lmblob { position: relative; display: block; margin: 0; padding: 0; border: 0; aspect-ratio: 1; }
394.fd-lmblob-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; }
395.fd-lmblob-fallback {
396 position: absolute; left: 22%; top: 20%; width: 56%; height: 56%; margin: 0; padding: 0; border: 0; border-radius: 50%;
397 background: var(--fd-lmblob-fallback);
398 box-shadow: 0 40px 60px -30px rgba(0,0,0,.6);
399 animation: fd-lmblob-morph 9s cubic-bezier(0.65, 0, 0.35, 1) infinite alternate;
400}
401@keyframes fd-lmblob-morph {
402 0% { border-radius: 50% 50% 48% 52% / 52% 48% 52% 48%; }
403 50% { border-radius: 58% 42% 55% 45% / 45% 58% 42% 55%; }
404 100% { border-radius: 44% 56% 46% 54% / 56% 44% 56% 44%; }
405}
406@media (prefers-reduced-motion: reduce) { .fd-lmblob-fallback { animation: none; } }
407`;
408
409/* ───────────────────────── demo ───────────────────────── */
410
411export function Demo() {
412 return (
413 <section className="fd-lmblob-demo">
414 <style>{demoCss}</style>
415 <header className="fd-lmblob-demo-nav">
416 <span className="fd-lmblob-demo-logo">
417 <span className="fd-lmblob-demo-logodot" aria-hidden="true" /> Quicksilver
418 </span>
419 <nav className="fd-lmblob-demo-links" aria-label="Primary">
420 <a href="#product" onClick={(e) => e.preventDefault()}>Product</a>
421 <a href="#research" onClick={(e) => e.preventDefault()}>Research</a>
422 <a href="#enterprise" onClick={(e) => e.preventDefault()}>Enterprise</a>
423 </nav>
424 <a className="fd-lmblob-demo-signin" href="#signin" onClick={(e) => e.preventDefault()}>Sign in</a>
425 </header>
426 <div className="fd-lmblob-demo-hero">
427 <LiquidMetalBlob className="fd-lmblob-demo-blob" />
428 <div className="fd-lmblob-demo-copy">
429 <p className="fd-lmblob-demo-kicker">Introducing Quicksilver 3</p>
430 <h1 className="fd-lmblob-demo-title">An assistant that takes the shape of your work.</h1>
431 <p className="fd-lmblob-demo-body">Quicksilver adapts to your docs, your codebase and your team's voice — then gets out of the way. Now with 1M-token memory and on-device mode.</p>
432 <div className="fd-lmblob-demo-actions">
433 <a className="fd-lmblob-demo-primary" href="#try" onClick={(e) => e.preventDefault()}>Try it free</a>
434 <a className="fd-lmblob-demo-secondary" href="#film" onClick={(e) => e.preventDefault()}>Watch the film · 2:14</a>
435 </div>
436 </div>
437 </div>
438 <p className="fd-lmblob-demo-trust">Trusted by teams at Halcyon Bio, Nordlicht Energie, Pillar Health and 4,000+ others</p>
439 </section>
440 );
441}
442
443const demoCss = `
444.fd-lmblob-demo, .fd-lmblob-demo *, .fd-lmblob-demo *::before, .fd-lmblob-demo *::after { box-sizing: border-box; }
445.fd-lmblob-demo {
446 min-height: 100vh; margin: 0; padding: 0 clamp(20px, 4vw, 56px) 32px; overflow: hidden;
447 display: flex; flex-direction: column;
448 background: linear-gradient(180deg, #F4F4F6 0%, #E9E9ED 100%); color: #0B0B0F;
449 font: 400 15px/1.5 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
450}
451.fd-lmblob-demo-nav { display: flex; align-items: center; gap: 32px; height: 72px; margin: 0; padding: 0; }
452.fd-lmblob-demo-logo { display: inline-flex; align-items: center; gap: 9px; margin: 0 auto 0 0; padding: 0; color: #0B0B0F; font: 650 17px/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.02em; }
453.fd-lmblob-demo-logodot { display: block; width: 18px; height: 18px; margin: 0; padding: 0; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #fff 0 12%, #b9bcc3 30%, #2b2c30 62%, #8d9097 100%); box-shadow: 0 2px 4px rgba(0,0,0,.2); }
454.fd-lmblob-demo-links { display: flex; gap: 28px; margin: 0; padding: 0; }
455.fd-lmblob-demo-links a, .fd-lmblob-demo-signin { margin: 0; padding: 0; color: #4A4A55; text-decoration: none; font: 500 14px/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: 0; transition: color 120ms cubic-bezier(0.22, 1, 0.36, 1); }
456.fd-lmblob-demo-links a:hover, .fd-lmblob-demo-signin:hover { color: #0B0B0F; }
457.fd-lmblob-demo-hero { flex: 1; display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); align-items: center; gap: clamp(16px, 4vw, 64px); max-width: 1280px; width: 100%; margin: 0 auto; padding: 0; }
458.fd-lmblob-demo-blob.fd-lmblob { width: 118%; max-width: 760px; margin: -6% -9%; justify-self: center; }
459.fd-lmblob-demo-copy { max-width: 520px; margin: 0; padding: 0; }
460.fd-lmblob-demo-kicker { margin: 0 0 16px; padding: 0; color: #6B6B76; font: 600 13px/1.3 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: 0.04em; text-transform: uppercase; }
461.fd-lmblob-demo-title { margin: 0 0 20px; padding: 0; color: #0B0B0F; font: 700 clamp(2.5rem, 4.8vw, 4.25rem)/1.0 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.04em; }
462.fd-lmblob-demo-body { max-width: 44ch; margin: 0 0 32px; padding: 0; color: #4A4A55; font: 400 1.0625rem/1.55 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; }
463.fd-lmblob-demo-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; margin: 0; padding: 0; }
464.fd-lmblob-demo-primary { display: inline-flex; align-items: center; height: 50px; margin: 0; padding: 0 26px; border: 0; border-radius: 9999px; background: #0B0B0F; color: #F7F7F9; text-decoration: none; font: 600 15px/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; transition: transform 120ms cubic-bezier(0.22, 1, 0.36, 1), background 120ms cubic-bezier(0.22, 1, 0.36, 1); }
465.fd-lmblob-demo-primary:hover { background: #2A2A31; }
466.fd-lmblob-demo-primary:active { transform: scale(0.96); }
467.fd-lmblob-demo-secondary { margin: 0; padding: 0; color: #0B0B0F; text-decoration: none; font: 550 15px/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; }
468.fd-lmblob-demo-secondary:hover { text-decoration: underline; text-underline-offset: 4px; }
469.fd-lmblob-demo a:focus-visible { outline: 2px solid rgba(0,122,255,.7); outline-offset: 2px; border-radius: 6px; }
470.fd-lmblob-demo-trust { margin: 0; padding: 0; text-align: center; color: #8A8A94; font: 500 13px/1.4 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; }
471@media (max-width: 860px) {
472 .fd-lmblob-demo-hero { grid-template-columns: 1fr; text-align: left; }
473 .fd-lmblob-demo-blob.fd-lmblob { width: 100%; max-width: 380px; margin: -8% 0; }
474 .fd-lmblob-demo-links { display: none; }
475}
476`;

More Liquid Metal. Same file contract.

Liquid Metal

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

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.