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
  • Music / Media
  • React only · 0 deps
  • 1 file
Full screen

Browser support · WebGL2 / WebGL1. Without WebGL the real text gets a CSS chrome gradient via background-clip.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/liquid-metal-text.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/liquid-metal/liquid-metal-text.tsx
1/**
2 * LiquidMetalText — a giant headline cast in flowing chrome. The text is
3 * rasterised into a mask, blurred into a bevel height map, and combined with
4 * a moving liquid height field; the resulting normals reflect a procedural
5 * studio. Mouse tilt slides the highlights across the letters.
6 *
7 * Props
8 * - children: string The headline (also rendered as real, selectable DOM text for a11y/SEO).
9 * - as?: "h1" | "h2" | "p" Default "h1".
10 * - tint?: "silver" | "gold" | "titanium" | "black-chrome". Default "silver".
11 * - weight?: number Font weight. Default 800.
12 * - fontFamily?: string Default system display stack.
13 * - tilt?: boolean Pointer moves the reflection. Default true.
14 * - speed?: number Flow speed. Default 1.
15 * CSS: size the text with `font-size` on the root (e.g. clamp()); the canvas matches the DOM text box.
16 * CSS vars: --fd-lmtext-fallback.
17 *
18 * Browser support: WebGL2 / WebGL1. Without WebGL the DOM text shows a CSS
19 * chrome gradient via background-clip:text. Reduced motion: still frame.
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 sampler2D uMask; // r = crisp coverage, g = blurred (bevel height)
195uniform vec2 uRes;
196uniform float uTime;
197uniform vec2 uTilt;
198uniform float uTint;
199
200vec2 hash2(vec2 p){
201 p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
202 return -1. + 2. * fract(sin(p) * 43758.5453);
203}
204float noise(vec2 p){
205 vec2 i = floor(p), f = fract(p);
206 vec2 u = f * f * (3. - 2. * f);
207 return mix(mix(dot(hash2(i), f), dot(hash2(i + vec2(1, 0)), f - vec2(1, 0)), u.x),
208 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);
209}
210float liquid(vec2 p){
211 float t = uTime * .12;
212 vec2 w = vec2(noise(p * .8 + t), noise(p * .8 - t + 4.));
213 return noise(p * 1.3 + w * 1.6) + .45 * sin(p.x * 1.1 + p.y * .5 + w.x * 3. + t * 2.5);
214}
215vec3 studio(vec3 r){
216 float el = r.y + uTilt.y * .25;
217 float az = atan(r.x, r.z) + uTilt.x * .6;
218 vec3 sky = mix(vec3(.26, .27, .3), vec3(.95, .96, .98), smoothstep(-.05, .7, el + .25 * abs(az)));
219 vec3 fl = mix(vec3(.02), vec3(.14, .13, .12), smoothstep(-1., -.3, el));
220 vec3 c = mix(fl, sky, smoothstep(-.1, -.05, el));
221 c += .8 * exp(-pow((el + .03) / .035, 2.));
222 c += 1.4 * smoothstep(.08, 0., abs(az + .5) - .18) * smoothstep(.06, 0., abs(el - .35) - .2);
223 c += 1.1 * smoothstep(.04, 0., abs(az - .7) - .06) * smoothstep(.05, 0., abs(el - .1) - .35);
224 c *= 1. - .8 * smoothstep(.06, 0., abs(az - .25) - .05) * smoothstep(-.05, .1, el);
225 return c;
226}
227void main(){
228 vec2 uv = gl_FragCoord.xy / uRes;
229 vec2 m = texture2D(uMask, uv).rg;
230 if (m.r < .004) { gl_FragColor = vec4(0.); return; }
231 vec2 px = 1. / uRes;
232 // bevel from the blurred mask
233 float bx = texture2D(uMask, uv + vec2(px.x * 2., 0.)).g - texture2D(uMask, uv - vec2(px.x * 2., 0.)).g;
234 float by = texture2D(uMask, uv + vec2(0., px.y * 2.)).g - texture2D(uMask, uv - vec2(0., px.y * 2.)).g;
235 vec2 p = gl_FragCoord.xy / uRes.y * 3.;
236 float e = .01;
237 float h = liquid(p);
238 vec2 lg = vec2(liquid(p + vec2(e, 0.)) - h, liquid(p + vec2(0., e)) - h) / e;
239 vec3 n = normalize(vec3(-bx * 7. - lg.x * .07, -by * 7. - lg.y * .07, 1.));
240 vec2 sp = uv - .5;
241 vec3 v = normalize(vec3(sp.x * .3, sp.y * 1.1 + .12 + .06 * sin(gl_FragCoord.x / uRes.y * 2. + uTime * .3), -1.));
242 vec3 r = reflect(v, n);
243 vec3 env = studio(r);
244 vec3 tint = uTint < .5 ? vec3(.97) : uTint < 1.5 ? vec3(1., .79, .45) : uTint < 2.5 ? vec3(.8, .82, .86) : vec3(.5);
245 float fres = pow(1. - clamp(-dot(n, v), 0., 1.), 5.);
246 vec3 col = env * mix(tint, vec3(1.), fres);
247 if (uTint > 2.5) col = pow(env, vec3(1.6)) * .6 + .02;
248 col = col / (1. + col * .25) * 1.15;
249 // thin bright rim on the bevel crest
250 col += .18 * smoothstep(.45, .5, m.g) * (1. - smoothstep(.5, .6, m.g));
251 float a = m.r;
252 gl_FragColor = vec4(col * a, a);
253}
254`;
255
256/* ───────────────────────── component ───────────────────────── */
257
258type Tint = "silver" | "gold" | "titanium" | "black-chrome";
259const TINTS: Record<Tint, number> = { silver: 0, gold: 1, titanium: 2, "black-chrome": 3 };
260const FALLBACK: Record<Tint, string> = {
261 silver: "linear-gradient(180deg, #ffffff 0%, #c9ccd2 38%, #3a3c41 50%, #e7e9ec 62%, #8a8e96 100%)",
262 gold: "linear-gradient(180deg, #fff6dc 0%, #e2bd72 38%, #4d3712 50%, #f5d996 62%, #a87a33 100%)",
263 titanium: "linear-gradient(180deg, #f3f5f8 0%, #aeb5bf 38%, #30343a 50%, #d6dbe2 62%, #737a85 100%)",
264 "black-chrome": "linear-gradient(180deg, #9a9da4 0%, #3f4248 38%, #040405 50%, #55585e 62%, #121316 100%)",
265};
266
267export interface LiquidMetalTextProps {
268 children: string;
269 as?: "h1" | "h2" | "p";
270 tint?: Tint;
271 weight?: number;
272 fontFamily?: string;
273 tilt?: boolean;
274 speed?: number;
275 className?: string;
276 style?: CSSProperties;
277}
278
279const DEFAULT_FONT = `"SF Pro Display", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif`;
280
281export function LiquidMetalText({
282 children,
283 as: Tag = "h1",
284 tint = "silver",
285 weight = 800,
286 fontFamily = DEFAULT_FONT,
287 tilt = true,
288 speed = 1,
289 className,
290 style,
291}: LiquidMetalTextProps) {
292 const rootRef = useRef<HTMLDivElement>(null);
293 const textRef = useRef<HTMLElement>(null);
294 const canvasRef = useRef<HTMLCanvasElement>(null);
295 const [ready, setReady] = useState(false);
296 const st = useRef({ t: 0, tilt: { x: 0, y: 0, tx: 0, ty: 0 }, tint, speed, mask: null as HTMLCanvasElement | null, dirty: false, tex: null as WebGLTexture | null });
297 st.current.tint = tint;
298 st.current.speed = speed;
299
300 const hooks = useRef<StageHooks>({
301 setup(s) {
302 const { gl } = s;
303 st.current.tex = makeTexture(gl, gl.LINEAR, gl.CLAMP_TO_EDGE);
304 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4));
305 gl.enable(gl.BLEND);
306 gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
307 st.current.dirty = true;
308 },
309 frame(s, _time, dt) {
310 const { gl, u } = s;
311 const S = st.current;
312 gl.clearColor(0, 0, 0, 0);
313 gl.clear(gl.COLOR_BUFFER_BIT);
314 gl.activeTexture(gl.TEXTURE0);
315 gl.bindTexture(gl.TEXTURE_2D, S.tex);
316 if (S.dirty && S.mask && S.mask.width === s.w && S.mask.height === s.h) {
317 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
318 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, S.mask);
319 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
320 S.dirty = false;
321 }
322 if (!s.reduced) S.t += dt * S.speed;
323 const k = 1 - Math.exp(-dt * 4);
324 S.tilt.x += (S.tilt.tx - S.tilt.x) * k;
325 S.tilt.y += (S.tilt.ty - S.tilt.y) * k;
326 gl.uniform1i(u.uMask, 0);
327 gl.uniform2f(u.uRes, s.w, s.h);
328 gl.uniform1f(u.uTime, S.t + 5);
329 gl.uniform2f(u.uTilt, S.tilt.x, S.tilt.y);
330 gl.uniform1f(u.uTint, TINTS[S.tint] ?? 0);
331 return true;
332 },
333 });
334 const { failed, kick } = useStage(canvasRef, FRAG, hooks);
335
336 // Rasterise the DOM text (same font, size, wrapping) into an RG mask whenever layout changes.
337 useEffect(() => {
338 const textEl = textRef.current;
339 const canvas = canvasRef.current;
340 if (!textEl || !canvas || failed) return;
341 let raf = 0;
342 const build = () => {
343 raf = 0;
344 const box = textEl.getBoundingClientRect();
345 const dpr = Math.min(window.devicePixelRatio || 1, 2);
346 const W = Math.max(1, Math.round(box.width * dpr));
347 const H = Math.max(1, Math.round(box.height * dpr));
348 const cs = getComputedStyle(textEl);
349 const fs = parseFloat(cs.fontSize);
350 const mask = document.createElement("canvas");
351 mask.width = W;
352 mask.height = H;
353 const ctx = mask.getContext("2d");
354 if (!ctx) return;
355 ctx.scale(dpr, dpr);
356 ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
357 ctx.textBaseline = "alphabetic";
358 ctx.fillStyle = "#fff";
359 if ("letterSpacing" in ctx) (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = cs.letterSpacing === "normal" ? "0px" : cs.letterSpacing;
360 // Walk the text node's line boxes via Range rects so canvas wrapping matches the DOM exactly.
361 const node = textEl.firstChild;
362 if (node && node.nodeType === 3) {
363 const text = node.textContent || "";
364 const range = document.createRange();
365 let lineTop = -1;
366 let lineStart = 0;
367 const lines: { s: number; e: number; rect: DOMRect }[] = [];
368 for (let i = 0; i < text.length; i++) {
369 range.setStart(node, i);
370 range.setEnd(node, i + 1);
371 const r = range.getClientRects()[0];
372 if (!r) continue;
373 if (lineTop < 0) lineTop = r.top;
374 if (Math.abs(r.top - lineTop) > fs * 0.3) {
375 range.setStart(node, lineStart);
376 range.setEnd(node, i);
377 lines.push({ s: lineStart, e: i, rect: range.getBoundingClientRect() });
378 lineStart = i;
379 lineTop = r.top;
380 }
381 }
382 range.setStart(node, lineStart);
383 range.setEnd(node, text.length);
384 lines.push({ s: lineStart, e: text.length, rect: range.getBoundingClientRect() });
385 const m = ctx.measureText("Hg");
386 const asc = m.fontBoundingBoxAscent ?? fs * 0.8;
387 const desc = m.fontBoundingBoxDescent ?? fs * 0.2;
388 for (const ln of lines) {
389 const str = text.slice(ln.s, ln.e).replace(/\s+$/, "");
390 const y = ln.rect.top - box.top + (ln.rect.height - (asc + desc)) / 2 + asc;
391 ctx.fillText(str, ln.rect.left - box.left, y);
392 }
393 }
394 // R = crisp, G = blurred copy for the bevel
395 const blur = document.createElement("canvas");
396 blur.width = W;
397 blur.height = H;
398 const b = blur.getContext("2d");
399 if (!b) return;
400 b.filter = `blur(${Math.max(1.5, fs * dpr * 0.035)}px)`;
401 b.drawImage(mask, 0, 0);
402 const crisp = ctx.getImageData(0, 0, W, H);
403 const soft = b.getImageData(0, 0, W, H);
404 for (let i = 0; i < crisp.data.length; i += 4) {
405 const a = crisp.data[i + 3];
406 crisp.data[i] = a;
407 crisp.data[i + 1] = soft.data[i + 3];
408 crisp.data[i + 2] = 0;
409 crisp.data[i + 3] = 255;
410 }
411 ctx.setTransform(1, 0, 0, 1, 0, 0);
412 ctx.putImageData(crisp, 0, 0);
413 st.current.mask = mask;
414 st.current.dirty = true;
415 setReady(true);
416 kick.current();
417 };
418 const schedule = () => {
419 if (!raf) raf = requestAnimationFrame(build);
420 };
421 const ro = new ResizeObserver(schedule);
422 ro.observe(textEl);
423 document.fonts?.ready.then(schedule);
424 schedule();
425 return () => {
426 cancelAnimationFrame(raf);
427 ro.disconnect();
428 };
429 }, [children, weight, fontFamily, failed, kick]);
430
431 useEffect(() => {
432 const el = rootRef.current;
433 if (!el || !tilt) return;
434 const move = (e: PointerEvent) => {
435 st.current.tilt.tx = (e.clientX / window.innerWidth - 0.5) * 2;
436 st.current.tilt.ty = (e.clientY / window.innerHeight - 0.5) * -2;
437 };
438 window.addEventListener("pointermove", move, { passive: true });
439 return () => window.removeEventListener("pointermove", move);
440 }, [tilt]);
441
442 return (
443 <div
444 ref={rootRef}
445 className={`fd-lmtext${className ? " " + className : ""}`}
446 data-state={failed ? "fallback" : ready ? "ready" : "loading"}
447 style={{ ["--fd-lmtext-fallback" as string]: FALLBACK[tint], ...style }}
448 >
449 <style>{css}</style>
450 <Tag ref={textRef as never} className="fd-lmtext-text" style={{ fontWeight: weight, fontFamily }}>
451 {children}
452 </Tag>
453 {!failed && <canvas ref={canvasRef} className="fd-lmtext-canvas" aria-hidden="true" />}
454 </div>
455 );
456}
457
458const css = `
459.fd-lmtext, .fd-lmtext *, .fd-lmtext *::before, .fd-lmtext *::after { box-sizing: border-box; }
460.fd-lmtext { position: relative; display: block; margin: 0; padding: 0; border: 0; background: transparent; }
461:where(.fd-lmtext) { font-size: clamp(3rem, 11vw, 10rem); }
462.fd-lmtext-text {
463 display: block; margin: 0; padding: 0.04em 0 0.1em; border: 0; background: transparent;
464 font-size: inherit; line-height: 0.95; text-transform: none; text-decoration: none;
465 color: transparent; -webkit-text-fill-color: transparent;
466 transition: color 120ms cubic-bezier(0.22, 1, 0.36, 1);
467}
468.fd-lmtext[data-state="fallback"] .fd-lmtext-text,
469.fd-lmtext[data-state="loading"] .fd-lmtext-text {
470 background: var(--fd-lmtext-fallback); -webkit-background-clip: text; background-clip: text;
471}
472:where(.fd-lmtext-text) { letter-spacing: -0.045em; }
473.fd-lmtext-text::selection { background: rgba(10,132,255,.35); -webkit-text-fill-color: currentColor; color: #fff; }
474.fd-lmtext-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; pointer-events: none; }
475`;
476
477/* ───────────────────────── demo ───────────────────────── */
478
479export function Demo() {
480 return (
481 <section className="fd-lmtext-demo">
482 <style>{demoCss}</style>
483 <div className="fd-lmtext-demo-top">
484 <span>NOVA ATLAS</span>
485 <span>New album · 14.11.2026</span>
486 </div>
487 <div className="fd-lmtext-demo-center">
488 <p className="fd-lmtext-demo-pre">The third studio album</p>
489 <LiquidMetalText className="fd-lmtext-demo-title">MERCURY</LiquidMetalText>
490 <p className="fd-lmtext-demo-sub">Eleven tracks recorded live to tape in Reykjavík. Pre-save now and get the chrome vinyl edition — limited to 3,000 copies.</p>
491 <div className="fd-lmtext-demo-actions">
492 <a className="fd-lmtext-demo-primary" href="#presave" onClick={(e) => e.preventDefault()}>
493 <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 2.5v11l9-5.5z" fill="currentColor" /></svg>
494 Pre-save
495 </a>
496 <a className="fd-lmtext-demo-secondary" href="#vinyl" onClick={(e) => e.preventDefault()}>Chrome vinyl · €39</a>
497 </div>
498 </div>
499 <ol className="fd-lmtext-demo-tracks">
500 <li><span>01</span>Quicksilver</li>
501 <li><span>02</span>Low Orbit</li>
502 <li><span>03</span>Hg</li>
503 <li><span>04</span>Liquid Hours</li>
504 <li><span>05</span>Cold Mirror</li>
505 </ol>
506 </section>
507 );
508}
509
510const demoCss = `
511.fd-lmtext-demo, .fd-lmtext-demo *, .fd-lmtext-demo *::before, .fd-lmtext-demo *::after { box-sizing: border-box; }
512.fd-lmtext-demo {
513 position: relative; min-height: 100vh; margin: 0; padding: 28px clamp(20px, 4vw, 56px); overflow: hidden;
514 display: flex; flex-direction: column; justify-content: space-between; gap: 32px;
515 background: radial-gradient(70% 60% at 50% 45%, #1d1e24 0%, #08080A 70%); color: #F5F5F7;
516 font: 400 15px/1.5 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
517}
518.fd-lmtext-demo-top { display: flex; justify-content: space-between; margin: 0; padding: 0; color: #A1A1A6; font: 600 12px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: .14em; text-transform: uppercase; }
519.fd-lmtext-demo-center { display: flex; flex-direction: column; align-items: center; text-align: center; margin: 0; padding: 0; }
520.fd-lmtext-demo-pre { margin: 0 0 8px; padding: 0; color: #8E8E93; font: 500 15px/1.3 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; }
521.fd-lmtext-demo-title.fd-lmtext { font-size: clamp(4rem, 17vw, 15rem); }
522.fd-lmtext-demo-title.fd-lmtext .fd-lmtext-text { letter-spacing: -0.05em; }
523.fd-lmtext-demo-sub { max-width: 46ch; margin: 12px 0 28px; 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; }
524.fd-lmtext-demo-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; margin: 0; padding: 0; }
525.fd-lmtext-demo-primary, .fd-lmtext-demo-secondary {
526 display: inline-flex; align-items: center; gap: 8px; height: 48px; margin: 0; padding: 0 24px; border-radius: 9999px; text-decoration: none;
527 font: 600 15px/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em;
528 transition: transform 120ms cubic-bezier(0.22, 1, 0.36, 1), background 120ms cubic-bezier(0.22, 1, 0.36, 1);
529}
530.fd-lmtext-demo-primary { border: 0; background: #F5F5F7; color: #0B0B0F; }
531.fd-lmtext-demo-primary svg { display: block; }
532.fd-lmtext-demo-primary:hover { background: #FFFFFF; }
533.fd-lmtext-demo-secondary { border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.04); color: #F5F5F7; }
534.fd-lmtext-demo-secondary:hover { background: rgba(255,255,255,.1); }
535.fd-lmtext-demo-primary:active, .fd-lmtext-demo-secondary:active { transform: scale(0.96); }
536.fd-lmtext-demo-primary:focus-visible, .fd-lmtext-demo-secondary:focus-visible { outline: 2px solid rgba(10,132,255,.7); outline-offset: 2px; }
537.fd-lmtext-demo-tracks { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px 28px; margin: 0; padding: 0; list-style: none; }
538.fd-lmtext-demo-tracks li { margin: 0; padding: 0; list-style: none; color: #C7C7CC; font: 500 13px/1.4 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; }
539.fd-lmtext-demo-tracks span { margin: 0 8px 0 0; padding: 0; color: #6B6B76; font: 500 12px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; }
540`;

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 Blob

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