shadimage
evilbuttons
uichrome-buttonsource: ok · hit

ChromeButton

A button with an animated liquid chrome background.

npx shadcn add "https://evilbuttons.radiumcoders.com/r/chrome-button.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files1 · source cached 10/8/2026, 8:17:28 PM

components/evil-buttons/chrome-button.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export type ChromeTone = "auto" | "dark" | "light";
export type ChromeSize = "default" | "sm" | "icon";

export interface ChromeButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Label used when no children are provided. */
  label?: React.ReactNode;
  /** Follow the theme (dark face under `.dark`), or force a dark or light face. */
  tone?: ChromeTone;
  /** Pill, compact pill, or a round icon-only button. */
  size?: ChromeSize;
  /** Speed multiplier for the liquid metal flowing around the border. */
  speed?: number;
  /** Ripple the metal around the pointer and send a wave through it on press. */
  interactive?: boolean;
}

const VERTEX = `
attribute vec2 aPosition;
void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;

// Liquid metal: bands of polished chrome travel around the ring (by angle
// from the center), wobbled by stacked cosines so they slosh like liquid.
// Shaded as broad silver with dark troughs and thin hot highlights. The canvas
// fills the whole button, but only the border ring shows around the face.
const FRAGMENT = `
precision highp float;
uniform vec2 uRes;
uniform float uTime;
uniform vec2 uPointer;
uniform float uHover;
uniform vec3 uPress;
uniform float uTone;

float metal(vec2 px) {
  vec2 uv = (2.0 * px - uRes) / min(uRes.x, uRes.y);
  for (float i = 1.0; i < 8.0; i++) {
    uv.x += 0.22 / i * cos(i * 2.6 * uv.y + uTime);
    uv.y += 0.22 / i * cos(i * 1.9 * uv.x + uTime * 0.8);
  }

  // Interactions push band phase and contrast directly: nudging the warp, or
  // brightening already-bright metal, barely registers on a thin ring.
  float phase = 0.0;
  float shade = 0.0;

  // Pointer: tight dark and bright ripples swirl out from the cursor.
  vec2 d = (px - uPointer) / uRes.y;
  float dist = length(d);
  float near = exp(-dist * dist * 1.5) * uHover;
  float swirl = sin(dist * 14.0 - uTime * 7.0);
  phase += swirl * 2.2 * near;
  shade += swirl * 0.45 * near;

  // Press: a shock front runs out from the click, bright at its leading edge
  // with a dark trough behind it.
  float age = uTime - uPress.z;
  if (age > 0.0 && age < 2.0) {
    float r = length((px - uPress.xy) / uRes.y);
    float front = r - age * 4.0;
    float fade = exp(-age * 1.8);
    phase += exp(-front * front * 2.0) * fade * 5.0;
    shade += (exp(-front * front * 8.0) - exp(-(front + 0.45) * (front + 0.45) * 8.0)) * fade * 0.95;
  }

  // Normalized so a pill's ends and sides share one angular speed.
  vec2 q = (2.0 * px - uRes) / uRes;
  float angle = atan(q.y, q.x);
  float s = sin(angle * 3.0 - uTime * 2.2 + (uv.x + uv.y) * 1.1 + phase);
  float m = 0.5 + 0.5 * s;
  float c = 0.3 + 0.7 * smoothstep(0.0, 0.75, m);
  c += 0.55 * min(0.035 / abs(s - 0.35), 1.0);
  c -= 0.35 * min(0.02 / abs(s + 0.6), 1.0);
  return clamp(c + shade, 0.0, 1.0);
}

void main() {
  float c = 0.0;
  c += metal(gl_FragCoord.xy + vec2(-0.25, -0.25));
  c += metal(gl_FragCoord.xy + vec2(0.25, -0.25));
  c += metal(gl_FragCoord.xy + vec2(-0.25, 0.25));
  c += metal(gl_FragCoord.xy + vec2(0.25, 0.25));
  c *= 0.25;
  // Slightly cool lights and warm shadows read as steel rather than grey.
  vec3 col = mix(vec3(0.09, 0.085, 0.08), vec3(1.0, 1.0, 1.02), c);
  // On a white face the ring sits a little darker so it still frames the button.
  col *= uTone > 0.5 ? 0.9 : 1.0;
  gl_FragColor = vec4(col, 1.0);
}
`;

type ChromeGL = {
  gl: WebGLRenderingContext;
  uniforms: Record<
    "uRes" | "uTime" | "uPointer" | "uHover" | "uPress" | "uTone",
    WebGLUniformLocation | null
  >;
};

function createChromeGL(canvas: HTMLCanvasElement): ChromeGL | null {
  const gl = canvas.getContext("webgl", {
    alpha: false,
    antialias: false,
    powerPreference: "low-power",
  });
  if (!gl) return null;

  const compile = (type: number, source: string) => {
    const shader = gl.createShader(type)!;
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null;
  };
  const vs = compile(gl.VERTEX_SHADER, VERTEX);
  const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);
  if (!vs || !fs) return null;

  const program = gl.createProgram()!;
  gl.attachShader(program, vs);
  gl.attachShader(program, fs);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
  gl.useProgram(program);

  // One oversized triangle covers the viewport.
  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([-1, -1, 3, -1, -1, 3]),
    gl.STATIC_DRAW,
  );
  const position = gl.getAttribLocation(program, "aPosition");
  gl.enableVertexAttribArray(position);
  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);

  const at = (name: string) => gl.getUniformLocation(program, name);
  return {
    gl,
    uniforms: {
      uRes: at("uRes"),
      uTime: at("uTime"),
      uPointer: at("uPointer"),
      uHover: at("uHover"),
      uPress: at("uPress"),
      uTone: at("uTone"),
    },
  };
}

const DARK = {
  shell:
    "shadow-[0_0_0_1px_rgb(0_0_0/0.7),0_2px_4px_rgb(0_0_0/0.5),0_8px_20px_-8px_rgb(0_0_0/0.7)]",
  face:
    "bg-[linear-gradient(180deg,#1d1d1d_0%,#111111_60%,#0d0d0d_100%)] text-neutral-50 shadow-[inset_0_1px_3px_rgb(0_0_0/0.9),inset_0_-1px_0_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.55)]",
};

const LIGHT = {
  shell:
    "shadow-[0_0_0_1px_rgb(0_0_0/0.18),0_2px_4px_rgb(0_0_0/0.12),0_10px_24px_-8px_rgb(0_0_0/0.3)]",
  face:
    "bg-[linear-gradient(180deg,#ffffff_0%,#fafafa_60%,#f0f0f0_100%)] text-neutral-900 shadow-[inset_0_1px_3px_rgb(0_0_0/0.28),inset_0_-1px_0_rgb(255_255_255/0.9),0_0_0_1px_rgb(0_0_0/0.25)]",
};

const TONES: Record<ChromeTone, { shell: string; face: string }> = {
  // Light by default, dark inside a `.dark` ancestor, like shadcn's theming.
  // Written out in full so Tailwind can see every dark: class.
  auto: {
    shell: `${LIGHT.shell} dark:shadow-[0_0_0_1px_rgb(0_0_0/0.7),0_2px_4px_rgb(0_0_0/0.5),0_8px_20px_-8px_rgb(0_0_0/0.7)]`,
    face: `${LIGHT.face} dark:bg-[linear-gradient(180deg,#1d1d1d_0%,#111111_60%,#0d0d0d_100%)] dark:text-neutral-50 dark:shadow-[inset_0_1px_3px_rgb(0_0_0/0.9),inset_0_-1px_0_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.55)]`,
  },
  dark: DARK,
  light: LIGHT,
};

const SIZES: Record<ChromeSize, { shell: string; face: string }> = {
  default: {
    shell: "h-11 p-[3px]",
    face: "gap-2.5 px-5 text-[15px] [&_svg]:size-4",
  },
  sm: {
    shell: "h-9 p-[2.5px]",
    face: "gap-2 px-3.5 text-[13px] [&_svg]:size-3.5",
  },
  icon: {
    shell: "size-9 p-[2.5px]",
    face: "aspect-square justify-center [&_svg]:size-4",
  },
};

export const ChromeButton = React.forwardRef<
  HTMLButtonElement,
  ChromeButtonProps
>(
  (
    {
      children,
      label = "Continue",
      tone = "auto",
      size = "default",
      speed = 1,
      interactive = true,
      className,
      disabled,
      type = "button",
      onPointerEnter,
      onPointerMove,
      onPointerLeave,
      onPointerDown,
      onClick,
      ...props
    },
    ref,
  ) => {
    const shellRef = React.useRef<HTMLButtonElement | null>(null);
    const hostRef = React.useRef<HTMLSpanElement | null>(null);
    const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
    const [painted, setPainted] = React.useState(false);

    // Live inputs the render loop reads without re-running the effect.
    const input = React.useRef({
      pointer: [0, 0] as [number, number],
      hoverTarget: 0,
      press: [0, 0, -10] as [number, number, number],
      time: 0,
      speed,
      tone,
      interactive,
    });
    input.current.speed = speed;
    input.current.tone = tone;
    input.current.interactive = interactive;

    const setShellRef = (node: HTMLButtonElement | null) => {
      shellRef.current = node;
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    };

    React.useEffect(() => {
      const host = hostRef.current;
      const shell = shellRef.current;
      if (!host || !shell) return;
      // A fresh canvas per run: once a context is lost (as cleanup does), the
      // same canvas only ever hands back that dead context, which froze the
      // metal after Strict Mode's mount, unmount, mount in development.
      const canvas = document.createElement("canvas");
      canvas.style.cssText = "display:block;width:100%;height:100%";
      const chrome = createChromeGL(canvas);
      if (!chrome) return;
      host.appendChild(canvas);
      canvasRef.current = canvas;
      const { gl, uniforms } = chrome;
      const state = input.current;
      // Reduced motion slows the flow to a gentle drift instead of freezing it.
      const reduceMotion = window.matchMedia(
        "(prefers-reduced-motion: reduce)",
      ).matches;

      let raf = 0;
      let last = 0;
      let hover = 0;
      let visible = true;
      let first = true;

      const draw = () => {
        gl.uniform2f(uniforms.uRes, canvas.width, canvas.height);
        gl.uniform1f(uniforms.uTime, state.time);
        gl.uniform2f(uniforms.uPointer, state.pointer[0], state.pointer[1]);
        gl.uniform1f(uniforms.uHover, hover);
        gl.uniform3f(uniforms.uPress, ...state.press);
        const light =
          state.tone === "auto" ? !shell.closest(".dark") : state.tone === "light";
        gl.uniform1f(uniforms.uTone, light ? 1 : 0);
        gl.drawArrays(gl.TRIANGLES, 0, 3);
        if (first) {
          first = false;
          setPainted(true);
        }
      };

      const frame = (now: number) => {
        const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
        last = now;
        const target = state.interactive ? state.hoverTarget : 0;
        hover += (target - hover) * Math.min(1, dt * 8);
        state.time += dt * state.speed * (reduceMotion ? 0.2 : 1) * (1 + hover * 1.2);
        draw();
        raf = requestAnimationFrame(frame);
      };

      const start = () => {
        if (raf || !visible || document.hidden) return;
        last = 0;
        raf = requestAnimationFrame(frame);
      };
      const stop = () => {
        cancelAnimationFrame(raf);
        raf = 0;
      };

      const resize = () => {
        const dpr = Math.min(window.devicePixelRatio || 1, 2);
        canvas.width = Math.max(1, Math.round(shell.clientWidth * dpr));
        canvas.height = Math.max(1, Math.round(shell.clientHeight * dpr));
        gl.viewport(0, 0, canvas.width, canvas.height);
        draw();
      };

      const resizeObserver = new ResizeObserver(resize);
      resizeObserver.observe(shell);
      // Only animate while on screen.
      const intersection = new IntersectionObserver(([entry]) => {
        visible = !!entry?.isIntersecting;
        if (visible) start();
        else stop();
      });
      intersection.observe(shell);
      const onVisibility = () => (document.hidden ? stop() : start());
      document.addEventListener("visibilitychange", onVisibility);
      const onLost = (event: Event) => {
        event.preventDefault();
        stop();
        setPainted(false);
      };
      canvas.addEventListener("webglcontextlost", onLost);

      resize();
      start();

      return () => {
        stop();
        resizeObserver.disconnect();
        intersection.disconnect();
        document.removeEventListener("visibilitychange", onVisibility);
        canvas.removeEventListener("webglcontextlost", onLost);
        gl.getExtension("WEBGL_lose_context")?.loseContext();
        canvas.remove();
        if (canvasRef.current === canvas) canvasRef.current = null;
        setPainted(false);
      };
    }, []);

    /** Pointer position in canvas pixels, y up, as the shader expects. */
    const toCanvas = (clientX: number, clientY: number) => {
      const canvas = canvasRef.current;
      const shell = shellRef.current;
      if (!canvas || !shell) return [0, 0] as [number, number];
      const rect = shell.getBoundingClientRect();
      return [
        (clientX - rect.left) * (canvas.width / rect.width),
        (rect.bottom - clientY) * (canvas.height / rect.height),
      ] as [number, number];
    };

    const wave = (at: [number, number]) => {
      input.current.press = [at[0], at[1], input.current.time];
    };

    const tones = TONES[tone];
    const sizes = SIZES[size];

    return (
      <button
        ref={setShellRef}
        type={type}
        disabled={disabled}
        onPointerEnter={(event) => {
          onPointerEnter?.(event);
          input.current.pointer = toCanvas(event.clientX, event.clientY);
          input.current.hoverTarget = 1;
        }}
        onPointerMove={(event) => {
          onPointerMove?.(event);
          input.current.pointer = toCanvas(event.clientX, event.clientY);
        }}
        onPointerLeave={(event) => {
          onPointerLeave?.(event);
          input.current.hoverTarget = 0;
        }}
        onPointerDown={(event) => {
          onPointerDown?.(event);
          if (interactive) wave(toCanvas(event.clientX, event.clientY));
        }}
        onClick={(event) => {
          onClick?.(event);
          // Keyboard activation has no pointer: send the wave from the center.
          const canvas = canvasRef.current;
          if (interactive && event.detail === 0 && canvas) {
            wave([canvas.width / 2, canvas.height / 2]);
          }
        }}
        className={cn(
          "group/chrome relative isolate inline-flex shrink-0 cursor-pointer overflow-hidden rounded-full font-medium tracking-tight outline-none select-none",
          // Static silver shows until the liquid metal paints, and stays if WebGL can't.
          "bg-[linear-gradient(135deg,#f4f4f5_0%,#8a8a8f_28%,#fafafa_48%,#4a4a4f_72%,#d9d9dc_100%)]",
          tones.shell,
          sizes.shell,
          "transition-[scale,filter] duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] hover:brightness-110",
          "active:scale-[0.96] active:duration-100 active:ease-out motion-reduce:active:scale-100",
          "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          "disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale",
          className,
        )}
        {...props}
      >
        <span
          ref={hostRef}
          aria-hidden
          className={cn(
            "absolute inset-0 -z-10 transition-opacity duration-500",
            painted ? "opacity-100" : "opacity-0",
          )}
        />
        {/* Tube shading on the ring: a lit top lip and a shadowed underside. */}
        <span
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-full shadow-[inset_0_1px_0_rgb(255_255_255/0.55),inset_0_-1px_1px_rgb(0_0_0/0.45)]"
        />
        <span
          className={cn(
            "relative flex h-full items-center rounded-full whitespace-nowrap",
            tones.face,
            sizes.face,
          )}
        >
          {children ?? label}
        </span>
      </button>
    );
  },
);

ChromeButton.displayName = "ChromeButton";

export default ChromeButton;

More from evilbuttons

Browse all components in this library →