Skip to content
← writing

Building a liquid-metal UI kit for React

react
webgl
shaders
ui

After shipping Glacé — the liquid-glass kit — I wanted the opposite material. Glass bends the world behind it. Metal reflects a world that isn't there. So: Argent (npm i argentui), chrome, gold, gunmetal, and obsidian surfaces that flow like mercury. Everything below is the real package running live. Hover the tiles.

silver
gold
gunmetal
obsidian

CSS couldn't fake this one

Glacé works in pure CSS + SVG because glass is refraction — you displace the actual backdrop with an feDisplacementMap and the eye believes it. I tried the same trick for metal: a seven-stop chrome gradient, animated, warped through feTurbulence. It looked like what it was — a gradient with a wobble.

The difference is that metal is reflection. A chrome surface shows you a warped environment — bright sky bands, dark horizon bands — and that environment has to come from somewhere. There's nothing behind the element to borrow. You have to synthesize the world, and that's a fragment shader's job.

The best implementation of this on the web is Paper's liquid-metal shader — it's what the chrome-logo demos you've seen lately are built on. The recipe inside is worth knowing: a hard light→dark stripe ramp (the faux environment), a curvature term that bends stripes as if wrapping a bulged surface, simplex noise scrolling through the coordinate space (the "liquid"), and the stripes sampled three times at small offsets — once per color channel — which gives the prismatic fringing that reads as polished chrome.

A shader is not a component

Paper gives you a <LiquidMetal> canvas. A kit needs buttons. The bridge is one idea: run the shader with shape="none" so it fills the element edge to edge, drop the canvas behind your content, and clip it to the surface's radius. Tones are just tuned parameter sets. Suddenly the shader is a material you can apply to anything:

import { MetalButton, MetalCard } from "argentui";
import "argentui/styles.css";
 
<MetalButton tone="silver">Get started</MetalButton>
<MetalCard tone="gunmetal" revealOnHover>…</MetalCard>

The first version filled every surface with chrome and it was a lot — like a website made entirely of hubcaps. The fix became Argent's defining default: the metal is the edge, not the background. A quiet dark panel with a liquid-metal rim, and revealOnHover floods the surface on interaction. Restraint at rest, spectacle on demand. (Full chrome is still there via variant="fill".)

The browser will eat your canvases

Here's the thing nobody tells you about building a kit where every component is a WebGL canvas: browsers cap concurrent WebGL contexts at around 16. When I added the fifth demo section to the docs site, the page hit 23 canvases and Chrome started silently killing contexts — seven surfaces just went black. No error, no event you'd notice. Your oldest contexts simply stop existing.

The fix is to treat contexts like the scarce resource they are. Each surface mounts its shader only while it's on or near the screen (an IntersectionObserver with a 250px margin) and releases the context the moment it scrolls away, with a static gradient standing in. The same page now peaks at six live canvases while scrolling, zero lost. If you ship anything WebGL-per-component, you need this — and it has a happy side effect: offscreen metal costs nothing.

One more bug for the road: my surfaces had overflow: hidden to clip the canvas, and any metal element inside a flex row collapsed to 2px wide. overflow other than visible zeroes a flex item's automatic minimum size. The canvas was already clipped by its own wrapper, so the outer overflow was doing nothing except destroying layout. Two days of "why is the nav shrunk" for one deleted line.

Mercury as a control surface

The fun part of a metal kit is that liquid metal wants to be interactive. The toggle's thumb is a drop of mercury that squishes (scale(1.12, 0.9)) while you hold it. The progress bar is molten fill rising in a dark channel.

Text gets both treatments. The default is a metallic gradient with background-clip: text — pure CSS, costs nothing, works everywhere. But pass shader and the component measures your string, renders it into an SVG silhouette, and pours the actual shader into the letterforms — the same liquid-logo treatment, for type. There's also variant="outline", the border idea applied to text: the metal runs around the edge of each glyph while the interior takes a dark or gradient fill (the second and third lines below — the gradient stands in until the shader loads):

v0.2.0ProBeta

There's also MetalLogo — pass any image with a transparent background and the metal pours into its silhouette, which is the classic use of this shader (it processes your image into a height field and flows the bands inside it). And the buttons vibrate on press where the platform allows it (navigator.vibrate, so Android; iOS Safari pretends not to hear you).

The license, and writing my own shader

One honest wrinkle. Paper's shaders ship under PolyForm Shield — free to use, but with a noncompete clause, and "a component library built on your shader library" lives uncomfortably close to the line. Argent keeps @paper-design/shaders-react as a peer dependency (you install it; I don't redistribute it), which is the polite reading. But the real way out is to not need it.

So Argent ships its own engine: about 120 lines of clean-room GLSL written from the recipe, not the source — stripe ramp, curvature, two octaves of simplex noise, per-channel dispersion, rim lift. Pass engine="native" to any surface and there's no Paper code in your bundle at all, MIT end to end:

Native engine

This rim is Argent's own ~120-line WebGL2 shader — no Paper dependency. Banding, noise flow, dispersion, rim. MIT all the way down.

The native engine is already indistinguishable on rims and borders; Paper's still wins on full-bleed fills (their dispersion and edge handling have had a lot more love). It stays opt-in until that gap closes, and then it becomes the default and the peer dependency becomes optional.

Caveats, because every shiny thing has them

  • Every metal surface is a WebGL canvas. The viewport gating makes this fine in practice, but a grid of 200 metal buttons is still a bad idea. Use metal where it earns its keep.
  • prefers-reduced-motion freezes the flow (the surfaces still look like metal, just still). Haptics are opt-out via setHaptics(false).
  • SSR renders a static gradient until the canvas mounts — fine for below-fold content, a brief visible swap if a metal hero is your LCP element.

The kit: npm i argentui · argentui docs + lab · GitHub · glass sibling: Glacé.