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

Browser support · WebGL2 / WebGL1. Falls back to MetalButtonCSS (gradients + sweep) without WebGL.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/liquid-metal-button.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/liquid-metal/liquid-metal-button.tsx
1/**
2 * LiquidMetalButton — a capsule CTA whose rim (or whole body) is live liquid
3 * chrome. A WebGL canvas renders the metal behind a CSS-masked capsule so the
4 * label stays crisp DOM text. Pressing sends a ripple out from the touch point.
5 *
6 * Also exports MetalButtonCSS — a zero-JS fallback with conic/linear chrome
7 * gradients and an animated specular sweep. Same API, works everywhere.
8 *
9 * Props (both)
10 * - variant?: "rim" | "solid" Metal only on the ring, or the whole button. Default "rim".
11 * - tint?: "silver" | "gold" | "titanium" | "black-chrome". Default "silver".
12 * - size?: "md" | "lg". Default "lg".
13 * - icon?: ReactNode Trailing icon.
14 * - plus every native <button> prop.
15 * CSS vars: --fd-lmbtn-ring (rim thickness), --fd-lmbtn-face (inner fill), --fd-lmbtn-ink (label colour).
16 *
17 * Browser support: WebGL2 / WebGL1; without WebGL the CSS chrome is used.
18 * Accessibility: native <button>, visible focus ring; canvas is aria-hidden.
19 * Reduced motion: still metal, no ripple, no sweep.
20 */
21import { forwardRef, useEffect, useRef, useState } from "react";
22import type { ButtonHTMLAttributes, 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 vec4 uRip; // px x, px y, age, amp
197uniform float uHover;
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 height(vec2 px){
211 vec2 p = px / uRes.y * 1.6;
212 float t = uTime * (.25 + uHover * .35);
213 vec2 w = vec2(noise(p + vec2(t, 0.)), noise(p + vec2(3.1, -t)));
214 float h = noise(p * 1.2 + w * 1.4) + .5 * sin(p.x * 1.4 - t * 1.6 + w.y * 3.);
215 float d = length(px - uRip.xy) / uRes.y;
216 float x = d - uRip.z * 3.2;
217 h += uRip.w * sin(x * 18.) * exp(-x * x * 30.) * exp(-uRip.z * 2.2);
218 return h;
219}
220vec3 studio(vec3 r){
221 float el = r.y;
222 vec3 sky = mix(vec3(.55, .57, .62), vec3(1.), smoothstep(-.1, .8, el));
223 vec3 fl = mix(vec3(.03), vec3(.18, .17, .16), smoothstep(-1., -.3, el));
224 vec3 c = mix(fl, sky, smoothstep(-.12, -.06, el));
225 c += .6 * exp(-pow((el + .06) / .04, 2.));
226 c += 1.1 * smoothstep(.1, 0., abs(r.x + .5) - .2) * smoothstep(.1, 0., abs(el - .4) - .2);
227 return c;
228}
229void main(){
230 vec2 px = gl_FragCoord.xy;
231 float e = 1.5;
232 float h = height(px);
233 vec2 g = vec2(height(px + vec2(e, 0.)) - h, height(px + vec2(0., e)) - h) / e * uRes.y * .18;
234 vec3 n = normalize(vec3(-g, 1.));
235 // capsule geometry adds a convex bevel so the ring reads as a tube
236 vec2 c = px - .5 * uRes;
237 float R = .5 * uRes.y;
238 vec2 q = vec2(max(abs(c.x) - (.5 * uRes.x - R), 0.), c.y);
239 vec2 bevel = q / R;
240 n = normalize(n + vec3(bevel * .9, 0.));
241 vec3 v = vec3(0., 0., -1.);
242 vec3 r = reflect(v, n);
243 vec3 env = studio(r);
244 vec3 tint = uTint < .5 ? vec3(.97) : uTint < 1.5 ? vec3(1., .8, .46) : uTint < 2.5 ? vec3(.8, .82, .86) : vec3(.5);
245 vec3 col = env * tint;
246 if (uTint > 2.5) col = pow(env, vec3(1.6)) * .6 + .02;
247 col = col / (1. + col * .25) * 1.15;
248 gl_FragColor = vec4(col, 1.);
249}
250`;
251
252/* ───────────────────────── components ───────────────────────── */
253
254type Tint = "silver" | "gold" | "titanium" | "black-chrome";
255const TINTS: Record<Tint, number> = { silver: 0, gold: 1, titanium: 2, "black-chrome": 3 };
256
257export interface LiquidMetalButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
258 variant?: "rim" | "solid";
259 tint?: Tint;
260 size?: "md" | "lg";
261 icon?: ReactNode;
262}
263
264export const LiquidMetalButton = forwardRef<HTMLButtonElement, LiquidMetalButtonProps>(function LiquidMetalButton(
265 { variant = "rim", tint = "silver", size = "lg", icon, className, style, children, type = "button", onPointerDown, onPointerEnter, onPointerLeave, ...rest },
266 ref,
267) {
268 const canvasRef = useRef<HTMLCanvasElement>(null);
269 const st = useRef({ t: 0, hover: 0, thover: 0, rip: { x: 0, y: 0, age: 9, amp: 0 }, tint });
270 st.current.tint = tint;
271
272 const hooks = useRef<StageHooks>({
273 frame(s, _time, dt) {
274 const { gl, u } = s;
275 const S = st.current;
276 S.hover += (S.thover - S.hover) * (1 - Math.exp(-dt * 6));
277 if (!s.reduced) S.t += dt;
278 S.rip.age += dt;
279 gl.uniform2f(u.uRes, s.w, s.h);
280 gl.uniform1f(u.uTime, S.t + 3);
281 gl.uniform4f(u.uRip, S.rip.x * s.dpr, s.h - S.rip.y * s.dpr, S.rip.age, S.rip.age < 2 ? S.rip.amp : 0);
282 gl.uniform1f(u.uHover, S.hover);
283 gl.uniform1f(u.uTint, TINTS[S.tint] ?? 0);
284 return true;
285 },
286 });
287 const { failed, kick } = useStage(canvasRef, FRAG, hooks);
288 useEffect(() => kick.current(), [tint, kick]);
289
290 if (failed) {
291 return (
292 <MetalButtonCSS ref={ref} variant={variant} tint={tint} size={size} icon={icon} className={className} style={style} type={type} onPointerDown={onPointerDown} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave} {...rest}>
293 {children}
294 </MetalButtonCSS>
295 );
296 }
297
298 return (
299 <button
300 ref={ref}
301 type={type}
302 className={`fd-lmbtn${className ? " " + className : ""}`}
303 data-variant={variant}
304 data-size={size}
305 data-tint={tint}
306 style={style}
307 onPointerDown={(e) => {
308 const r = e.currentTarget.getBoundingClientRect();
309 st.current.rip = { x: e.clientX - r.left, y: e.clientY - r.top, age: 0, amp: window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : 0.9 };
310 kick.current();
311 onPointerDown?.(e);
312 }}
313 onPointerEnter={(e) => {
314 st.current.thover = 1;
315 onPointerEnter?.(e);
316 }}
317 onPointerLeave={(e) => {
318 st.current.thover = 0;
319 onPointerLeave?.(e);
320 }}
321 {...rest}
322 >
323 <style>{css}</style>
324 <canvas ref={canvasRef} className="fd-lmbtn-metal" aria-hidden="true" />
325 <span className="fd-lmbtn-face" aria-hidden="true" />
326 <span className="fd-lmbtn-label">
327 {children}
328 {icon ? <span className="fd-lmbtn-icon">{icon}</span> : null}
329 </span>
330 </button>
331 );
332});
333
334/** CSS-only chrome capsule: conic + linear gradients and a specular sweep. No JS, no WebGL. */
335export const MetalButtonCSS = forwardRef<HTMLButtonElement, LiquidMetalButtonProps>(function MetalButtonCSS(
336 { variant = "rim", tint = "silver", size = "lg", icon, className, children, type = "button", ...rest },
337 ref,
338) {
339 return (
340 <button ref={ref} type={type} className={`fd-lmbtn fd-lmbtn-css${className ? " " + className : ""}`} data-variant={variant} data-size={size} data-tint={tint} {...rest}>
341 <style>{css}</style>
342 <span className="fd-lmbtn-chrome" aria-hidden="true" />
343 <span className="fd-lmbtn-face" aria-hidden="true" />
344 <span className="fd-lmbtn-label">
345 {children}
346 {icon ? <span className="fd-lmbtn-icon">{icon}</span> : null}
347 </span>
348 </button>
349 );
350});
351
352const css = `
353.fd-lmbtn, .fd-lmbtn *, .fd-lmbtn *::before, .fd-lmbtn *::after { box-sizing: border-box; }
354.fd-lmbtn {
355 --fd-lmbtn-ring: 3px;
356 --fd-lmbtn-face: #0E0E11;
357 --fd-lmbtn-ink: #F5F5F7;
358 --fd-lmbtn-h: 56px;
359 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
360 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
361 appearance: none; -webkit-appearance: none;
362 position: relative; display: inline-flex; align-items: center; justify-content: center;
363 height: var(--fd-lmbtn-h); margin: 0; padding: 0 32px; border: 0; border-radius: 9999px;
364 background: #5c5f66; color: var(--fd-lmbtn-ink); cursor: pointer; isolation: isolate; overflow: hidden;
365 font: 600 1.0625rem/1 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
366 letter-spacing: -0.01em; text-decoration: none; text-transform: none; white-space: nowrap;
367 -webkit-tap-highlight-color: transparent; touch-action: manipulation;
368 box-shadow: 0 1px 1px rgba(0,0,0,.25), 0 10px 30px -10px rgba(0,0,0,.55);
369 transition: transform 120ms var(--fd-ease-out), box-shadow 240ms var(--fd-ease-out);
370}
371.fd-lmbtn[data-size="md"] { --fd-lmbtn-h: 44px; padding: 0 22px; font-size: 0.9375rem; --fd-lmbtn-ring: 2px; }
372.fd-lmbtn:hover { box-shadow: 0 1px 1px rgba(0,0,0,.25), 0 16px 40px -12px rgba(0,0,0,.6); }
373.fd-lmbtn:active { transform: scale(0.96); }
374.fd-lmbtn:not(:active) { transition-timing-function: var(--fd-spring), var(--fd-ease-out); }
375.fd-lmbtn:focus-visible { outline: 2px solid rgba(10,132,255,.7); outline-offset: 2px; }
376.fd-lmbtn:disabled { cursor: not-allowed; opacity: .5; }
377.fd-lmbtn-metal, .fd-lmbtn-chrome { position: absolute; inset: 0; display: block; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; border-radius: inherit; z-index: 0; }
378.fd-lmbtn-face {
379 position: absolute; inset: var(--fd-lmbtn-ring); display: block; margin: 0; padding: 0; border: 0;
380 border-radius: 9999px; z-index: 1; background: var(--fd-lmbtn-face);
381 box-shadow: inset 0 1px 0 rgba(255,255,255,.08), inset 0 -8px 16px rgba(0,0,0,.35);
382}
383.fd-lmbtn[data-variant="solid"] { --fd-lmbtn-ink: #101114; }
384.fd-lmbtn[data-variant="solid"] .fd-lmbtn-face {
385 inset: 0; background: linear-gradient(180deg, rgba(255,255,255,.62), rgba(236,238,242,.42) 50%, rgba(255,255,255,.55));
386 box-shadow: inset 0 0 0 1px rgba(255,255,255,.4), inset 0 1px 0 rgba(255,255,255,.85), inset 0 -1px 0 rgba(0,0,0,.15);
387}
388.fd-lmbtn[data-variant="solid"][data-tint="gold"] .fd-lmbtn-face { background: linear-gradient(180deg, rgba(255,246,222,.6), rgba(240,214,150,.38) 50%, rgba(255,240,200,.55)); }
389.fd-lmbtn[data-variant="solid"][data-tint="black-chrome"] .fd-lmbtn-face { background: linear-gradient(180deg, rgba(20,21,24,.55), rgba(8,8,10,.4) 50%, rgba(20,21,24,.6)); box-shadow: inset 0 0 0 1px rgba(255,255,255,.12), inset 0 1px 0 rgba(255,255,255,.25); }
390.fd-lmbtn[data-variant="solid"] .fd-lmbtn-label { text-shadow: 0 1px 0 rgba(255,255,255,.55); }
391.fd-lmbtn[data-variant="solid"][data-tint="black-chrome"] { --fd-lmbtn-ink: #F5F5F7; }
392.fd-lmbtn[data-variant="solid"][data-tint="black-chrome"] .fd-lmbtn-label { text-shadow: 0 1px 1px rgba(0,0,0,.6); }
393.fd-lmbtn-label { position: relative; z-index: 2; display: inline-flex; align-items: center; gap: 10px; margin: 0; padding: 0; color: var(--fd-lmbtn-ink); font: inherit; letter-spacing: inherit; }
394.fd-lmbtn-icon { display: inline-flex; margin: 0; padding: 0; transition: transform 240ms var(--fd-ease-out); }
395.fd-lmbtn-icon svg { display: block; }
396.fd-lmbtn:hover .fd-lmbtn-icon { transform: translateX(3px); }
397
398/* CSS chrome */
399.fd-lmbtn-chrome {
400 background:
401 linear-gradient(180deg, rgba(255,255,255,.0) 0 46%, rgba(0,0,0,.55) 50%, rgba(255,255,255,.0) 64%),
402 conic-gradient(from 200deg at 50% 50%, #f9fafb, #8a8e96 12%, #f4f5f7 22%, #2a2b2f 34%, #c9ccd2 46%, #fdfdfd 58%, #61646b 70%, #e7e9ec 82%, #3a3c41 92%, #f9fafb);
403}
404.fd-lmbtn-css[data-tint="gold"] .fd-lmbtn-chrome { background:
405 linear-gradient(180deg, rgba(255,255,255,0) 0 46%, rgba(40,24,0,.5) 50%, rgba(255,255,255,0) 64%),
406 conic-gradient(from 200deg at 50% 50%, #fff4d6, #a87a33 12%, #fff1c9 22%, #3d2a0f 34%, #e2bd72 46%, #fff8e6 58%, #80602a 70%, #f5d996 82%, #4d3712 92%, #fff4d6); }
407.fd-lmbtn-css[data-tint="titanium"] .fd-lmbtn-chrome { background:
408 linear-gradient(180deg, rgba(255,255,255,0) 0 46%, rgba(0,0,0,.5) 50%, rgba(255,255,255,0) 64%),
409 conic-gradient(from 200deg at 50% 50%, #e9ecf0, #737a85 12%, #e1e5ea 22%, #23262c 34%, #aeb5bf 46%, #f3f5f8 58%, #525862 70%, #d6dbe2 82%, #30343a 92%, #e9ecf0); }
410.fd-lmbtn-css[data-tint="black-chrome"] .fd-lmbtn-chrome { background:
411 linear-gradient(180deg, rgba(255,255,255,0) 0 46%, rgba(0,0,0,.6) 50%, rgba(255,255,255,0) 64%),
412 conic-gradient(from 200deg at 50% 50%, #7d8087, #121316 12%, #6a6d74 22%, #040405 34%, #3f4248 46%, #9a9da4 58%, #17181b 70%, #55585e 82%, #08090a 92%, #7d8087); }
413.fd-lmbtn-chrome::after {
414 content: ""; position: absolute; inset: 0; border-radius: inherit;
415 background: linear-gradient(105deg, transparent 0 35%, rgba(255,255,255,.85) 48%, transparent 60%);
416 background-size: 260% 100%; background-position: 120% 0; mix-blend-mode: overlay;
417 animation: fd-lmbtn-sweep 3.6s cubic-bezier(0.65, 0, 0.35, 1) infinite;
418}
419@keyframes fd-lmbtn-sweep { 0%, 30% { background-position: 120% 0; } 70%, 100% { background-position: -20% 0; } }
420@media (prefers-reduced-motion: reduce) {
421 .fd-lmbtn-chrome::after { animation: none; }
422 .fd-lmbtn, .fd-lmbtn-icon { transition: none; }
423}
424@media (prefers-contrast: more) { .fd-lmbtn-face { box-shadow: inset 0 0 0 1px #fff; } }
425`;
426
427/* ───────────────────────── demo ───────────────────────── */
428
429const Arrow = (
430 <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
431 <path d="M3 8h9M8.5 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
432 </svg>
433);
434
435export function Demo() {
436 const [plan, setPlan] = useState<"yearly" | "monthly">("yearly");
437 return (
438 <section className="fd-lmbtn-demo">
439 <style>{demoCss}</style>
440 <div className="fd-lmbtn-demo-card" aria-hidden="true">
441 <div className="fd-lmbtn-demo-card-top">
442 <span className="fd-lmbtn-demo-card-brand">Northvale</span>
443 <span className="fd-lmbtn-demo-card-tier">METAL</span>
444 </div>
445 <div className="fd-lmbtn-demo-chip" />
446 <div className="fd-lmbtn-demo-card-num">•••• •••• •••• 4821</div>
447 <div className="fd-lmbtn-demo-card-bottom">
448 <span>ELIF DEMIR</span>
449 <span>09/31</span>
450 </div>
451 </div>
452 <div className="fd-lmbtn-demo-panel">
453 <p className="fd-lmbtn-demo-kicker">Northvale Metal</p>
454 <h2 className="fd-lmbtn-demo-title">18 grams of stainless steel. 3% back on everything.</h2>
455 <ul className="fd-lmbtn-demo-perks">
456 <li>3% cashback, no category caps</li>
457 <li>Airport lounges in 1,400 cities</li>
458 <li>0% FX on 150+ currencies</li>
459 </ul>
460 <div className="fd-lmbtn-demo-toggle" role="radiogroup" aria-label="Billing">
461 <button type="button" role="radio" aria-checked={plan === "yearly"} onClick={() => setPlan("yearly")}>Yearly · €16.60/mo</button>
462 <button type="button" role="radio" aria-checked={plan === "monthly"} onClick={() => setPlan("monthly")}>Monthly · €19.90</button>
463 </div>
464 <div className="fd-lmbtn-demo-actions">
465 <LiquidMetalButton icon={Arrow}>Upgrade to Metal</LiquidMetalButton>
466 <LiquidMetalButton variant="solid" size="md">Compare plans</LiquidMetalButton>
467 </div>
468 <div className="fd-lmbtn-demo-alt">
469 <span className="fd-lmbtn-demo-alt-label">CSS-only fallback</span>
470 <MetalButtonCSS size="md" icon={Arrow}>Upgrade</MetalButtonCSS>
471 <MetalButtonCSS size="md" variant="solid" tint="gold">Gold</MetalButtonCSS>
472 <MetalButtonCSS size="md" variant="solid" tint="black-chrome">Black</MetalButtonCSS>
473 </div>
474 <p className="fd-lmbtn-demo-fine">Billed €199 yearly. Cancel anytime in the app. Card ships in 5–7 days.</p>
475 </div>
476 </section>
477 );
478}
479
480const demoCss = `
481.fd-lmbtn-demo, .fd-lmbtn-demo *, .fd-lmbtn-demo *::before, .fd-lmbtn-demo *::after { box-sizing: border-box; }
482.fd-lmbtn-demo {
483 min-height: 100vh; margin: 0; padding: clamp(32px, 6vw, 80px) clamp(16px, 4vw, 48px);
484 display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: clamp(32px, 6vw, 96px);
485 background: radial-gradient(900px 600px at 25% 40%, #23242A 0%, #0B0B0F 70%); color: #F5F5F7;
486 font: 400 15px/1.5 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
487}
488.fd-lmbtn-demo-card {
489 justify-self: end; width: min(100%, 420px); aspect-ratio: 1.586; margin: 0; padding: 26px 28px; border-radius: 20px;
490 display: flex; flex-direction: column; justify-content: space-between; color: #1A1B1F;
491 background:
492 repeating-linear-gradient(90deg, rgba(255,255,255,.06) 0 1px, transparent 1px 3px),
493 linear-gradient(125deg, #f2f3f5 0%, #a9adb4 28%, #eef0f2 46%, #6e727a 64%, #d9dce0 82%, #9a9ea6 100%);
494 box-shadow: inset 0 1px 0 rgba(255,255,255,.8), inset 0 -1px 0 rgba(0,0,0,.25), 0 40px 80px -30px rgba(0,0,0,.8), 0 0 0 1px rgba(0,0,0,.4);
495 transform: perspective(1200px) rotateY(14deg) rotateX(6deg) rotateZ(-3deg);
496}
497.fd-lmbtn-demo-card-top { display: flex; justify-content: space-between; align-items: center; margin: 0; padding: 0; }
498.fd-lmbtn-demo-card-brand { margin: 0; padding: 0; font: 700 20px/1 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", sans-serif; letter-spacing: -0.03em; color: #1A1B1F; }
499.fd-lmbtn-demo-card-tier { margin: 0; padding: 0; font: 600 11px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: .2em; color: #3A3C42; }
500.fd-lmbtn-demo-chip { width: 46px; height: 34px; margin: 0; padding: 0; border-radius: 7px; background: linear-gradient(135deg, #e8d29a, #b0904a 50%, #f1e0b0); box-shadow: inset 0 0 0 1px rgba(0,0,0,.2); }
501.fd-lmbtn-demo-card-num { margin: 0; padding: 0; font: 500 19px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: .08em; color: #23252A; text-shadow: 0 1px 0 rgba(255,255,255,.5); }
502.fd-lmbtn-demo-card-bottom { display: flex; justify-content: space-between; margin: 0; padding: 0; font: 600 12px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: .12em; color: #2A2C31; }
503.fd-lmbtn-demo-panel { max-width: 480px; margin: 0; padding: 0; }
504.fd-lmbtn-demo-kicker { margin: 0 0 12px; padding: 0; color: #A1A1A6; font: 600 13px/1.3 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: 0.06em; text-transform: uppercase; }
505.fd-lmbtn-demo-title { margin: 0 0 20px; padding: 0; color: #F5F5F7; font: 700 clamp(2rem, 3.6vw, 2.75rem)/1.05 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.03em; }
506.fd-lmbtn-demo-perks { margin: 0 0 24px; padding: 0; list-style: none; }
507.fd-lmbtn-demo-perks li { position: relative; margin: 0; padding: 7px 0 7px 26px; color: #C7C7CC; font: 400 15px/1.4 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; list-style: none; }
508.fd-lmbtn-demo-perks li::before { content: ""; position: absolute; left: 2px; top: 12px; width: 12px; height: 7px; border-left: 2px solid #D2D4D9; border-bottom: 2px solid #D2D4D9; transform: rotate(-45deg); }
509.fd-lmbtn-demo-toggle { display: inline-flex; margin: 0 0 28px; padding: 4px; border-radius: 9999px; background: #1C1C22; box-shadow: inset 0 0 0 1px rgba(255,255,255,.06); }
510.fd-lmbtn-demo-toggle button {
511 appearance: none; -webkit-appearance: none; margin: 0; padding: 9px 16px; border: 0; border-radius: 9999px; background: transparent; color: #8E8E93; cursor: pointer;
512 font: 500 13px/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: 0;
513 transition: background 240ms cubic-bezier(0.65, 0, 0.35, 1), color 240ms cubic-bezier(0.65, 0, 0.35, 1);
514}
515.fd-lmbtn-demo-toggle button[aria-checked="true"] { background: #3A3A40; color: #F5F5F7; }
516.fd-lmbtn-demo-toggle button:focus-visible { outline: 2px solid rgba(10,132,255,.7); outline-offset: 2px; }
517.fd-lmbtn-demo-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin: 0 0 28px; padding: 0; }
518.fd-lmbtn-demo-alt { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0 0 20px; padding: 18px 0 0; border-top: 1px solid #2A2A2E; }
519.fd-lmbtn-demo-alt-label { width: 100%; margin: 0 0 2px; padding: 0; color: #6B6B76; font: 500 12px/1.2 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: .04em; }
520.fd-lmbtn-demo-fine { margin: 0; padding: 0; color: #6B6B76; font: 400 13px/1.4 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; }
521@media (max-width: 860px) {
522 .fd-lmbtn-demo { grid-template-columns: 1fr; }
523 .fd-lmbtn-demo-card { justify-self: center; transform: none; width: min(100%, 360px); }
524}
525`;

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 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.