Component · 2026 · MIT
Plasma Progress
A progress bar whose fill is a fluid. One fragment shader draws the boundary, the bands behind it, the rim light and the halo thrown past the edge - and the card underneath is lit by its own frame.
A progress bar has one job and everybody already believes it. That is exactly why it is worth spending a shader on: nobody has to learn how to read it.
The fill is not a coloured div with a width. It is a single full bleed triangle running a fragment shader, and the boundary between filled and empty is the value plus two octaves of noise - which is what makes it churn like something molten rather than slide like a rectangle.
Everything else falls out of that one decision. The bands curl because the noise field is domain warped rather than sampled straight. The bar appears to light the surface under it because the light is the bar: each frame is copied into a 128 by 32 canvas, pushed down, and blurred by CSS. There is no drop shadow to keep in sync with the colour, because there is no drop shadow.
Demo
Drag the bar, or drive it from the form. The snippet under it is printed from the same object and omits any prop still at its default, so it is what you would have written by hand.
Title
Subtitle
Palette
Five stops walking inward from the leading edge - the hot rim, the cooling bands, and the wash that fades into the card.
Value
Or drag the bar itself - it writes back here.
Speed
The shader clock is integrated, so a change eases in rather than jumping the animation.
Glow
Turbulence
How far the fluid boundary is allowed to swell away from the value it is reporting.
<PlasmaBar
title="Model training"
value={63}
/>Install
Copy ui/plasma/ into your project. Four files, no runtime dependency beyond React and WebGL 1 - the two packages below are only the class-merging helper used for className.
npm install clsx tailwind-mergeui/plasma/ plasma-bar.tsx the component, the loop, the drag plasma-gl.ts the renderer and the fragment shader glsl.ts noise, compile, hex to vec3 palettes.ts the five ramps
Source
Every file, as it is in this repository - read at build time, not transcribed. Take the open one to the clipboard, or the whole folder as a zip written in your own browser.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../../lib/cn";
import { PALETTES, type Palette, type PaletteId } from "./palettes";
import { createPlasmaRenderer } from "./plasma-gl";
export type PlasmaBarProps = {
title: string;
subtitle?: string;
/** A palette id, or a palette of your own with the same five stops. */
palette?: PaletteId | Palette;
/** Fill percentage, 0-100. */
value: number;
/** Supply this to make the bar draggable. Without it the bar is a readout. */
onValueChange?: (value: number) => void;
/** Animation rate, 1 = default. */
speed?: number;
/** Lighting intensity, 1 = default. */
glow?: number;
/** How violently the fluid boundary churns, 1 = default. */
turbulence?: number;
/** Phase offset so stacked bars do not ripple in lockstep. */
seed?: number;
className?: string;
};
const clamp = (n: number) => Math.max(0, Math.min(100, n));
/** Resolution of the light-spill copy; CSS blur does the rest of the work. */
const GLOW_W = 128;
const GLOW_H = 32;
export function PlasmaBar({
title,
subtitle,
palette = "magma",
value,
onValueChange,
speed = 1,
glow = 1,
turbulence = 1,
seed = 0,
className,
}: PlasmaBarProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const glowRef = useRef<HTMLCanvasElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
const resolved = typeof palette === "string" ? PALETTES[palette] : palette;
// The shader reads live values through refs and eases toward them, so neither
// dragging nor the render loop forces a React render per frame.
const target = useRef(value);
const knobs = useRef({ speed, glow, turbulence });
target.current = value;
knobs.current = { speed, glow, turbulence };
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const renderer = createPlasmaRenderer(canvas, resolved, seed);
if (!renderer) return;
const observer = new ResizeObserver(() => renderer.resize());
observer.observe(canvas);
const glowCtx = glowRef.current?.getContext("2d") ?? null;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let shown = target.current;
// Integrated rather than derived from wall time, so a speed change eases in
// instead of jumping the animation.
let clock = 0;
let last = performance.now();
let frame = requestAnimationFrame(function loop(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
const { speed: s, glow: g, turbulence: turb } = knobs.current;
clock += reduced ? 0 : dt * s;
shown += (target.current - shown) * Math.min(dt * 9, 1);
renderer.draw(shown / 100, clock, g, turb);
// The cast light is the frame itself, downsampled and blurred by CSS, so
// it tracks the fill's position, colour and churn exactly.
if (glowCtx) {
glowCtx.clearRect(0, 0, GLOW_W, GLOW_H);
glowCtx.drawImage(canvas, 0, 0, GLOW_W, GLOW_H);
}
frame = requestAnimationFrame(loop);
});
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
renderer.dispose();
};
}, [resolved, seed]);
const setFromPointer = useCallback(
(clientX: number) => {
const rect = cardRef.current?.getBoundingClientRect();
if (!rect || !onValueChange) return;
onValueChange(clamp(Math.round(((clientX - rect.left) / rect.width) * 100)));
},
[onValueChange],
);
const interactive = Boolean(onValueChange);
return (
<div data-surface="plasma" className={cn("relative isolate w-full", className)}>
{/* Cast light: the live frame, downsampled, pushed down and blurred. */}
<canvas
ref={glowRef}
width={GLOW_W}
height={GLOW_H}
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 size-full translate-y-3 scale-x-[1.05] scale-y-90 blur-2xl saturate-[1.6]"
style={{ opacity: Math.min(1, (0.4 + (value / 100) * 0.5) * glow) }}
/>
<div
ref={cardRef}
className={cn(
"relative isolate h-24 w-full overflow-hidden rounded-[28px] bg-[#141416] select-none",
interactive && "cursor-ew-resize touch-none",
dragging && "cursor-grabbing",
)}
style={{ boxShadow: "0 18px 32px -22px rgba(0, 0, 0, 0.65)" }}
role={interactive ? "slider" : "progressbar"}
tabIndex={interactive ? 0 : -1}
aria-label={title}
aria-valuenow={Math.round(value)}
aria-valuemin={0}
aria-valuemax={100}
onPointerDown={
interactive
? (event) => {
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
setFromPointer(event.clientX);
}
: undefined
}
onPointerMove={
interactive
? (event) => {
if (dragging) setFromPointer(event.clientX);
}
: undefined
}
onPointerUp={interactive ? () => setDragging(false) : undefined}
onPointerCancel={interactive ? () => setDragging(false) : undefined}
onKeyDown={
interactive
? (event) => {
const step = event.shiftKey ? 10 : 1;
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
onValueChange?.(clamp(value + step));
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
onValueChange?.(clamp(value - step));
} else if (event.key === "Home") {
onValueChange?.(0);
} else if (event.key === "End") {
onValueChange?.(100);
}
}
: undefined
}
>
<canvas ref={canvasRef} className="absolute inset-0 size-full" aria-hidden />
{/* Glass: a specular lip along the top edge and a hairline bevel. */}
<div
className="pointer-events-none absolute inset-0 rounded-[28px] bg-gradient-to-b from-white/12 via-transparent to-black/25 ring-1 ring-white/10 ring-inset"
aria-hidden
/>
<div className="pointer-events-none relative flex size-full items-center justify-between gap-4 px-7">
<div className="min-w-0 [text-shadow:0_1px_10px_rgba(0,0,0,0.55)]">
<div className="truncate text-[15px] leading-tight font-semibold tracking-[-0.01em] text-white">
{title}
</div>
{subtitle ? (
<div className="truncate text-[12px] leading-snug font-medium tracking-[0.01em] text-white/55">
{subtitle}
</div>
) : null}
</div>
<div className="shrink-0 text-[34px] leading-none font-extrabold tracking-tight text-white tabular-nums [text-shadow:0_1px_10px_rgba(0,0,0,0.55)]">
{Math.round(value)}
<span className="text-white/60">%</span>
</div>
</div>
</div>
</div>
);
}plasma-bar.tsx - 205 lines. The file as it is in the repository, highlighted during the build.
Usage
The bar sets its own height and fills the width it is given. Without onValueChange it is a read-only progressbar; with it, it becomes a slider you can drag, arrow, and send to either end with Home and End.
import { PlasmaBar } from "@/ui/plasma/plasma-bar";
export default function Page() {
return (
<PlasmaBar
title="Model training"
subtitle="Sha 4.5 + 100 2026 tkn"
palette="magma"
value={63}
/>
);
}// A stack of bars. The seed is what keeps them from
// rippling in lockstep - without it the wall reads as
// one animation rather than five.
{tasks.map((task, i) => (
<PlasmaBar
key={task.id}
title={task.title}
palette={task.palette}
value={task.progress}
seed={i * 3.7}
/>
))}Palettes
A palette is five stops walking inward from the leading edge: the hot rim, two cooling bands, and the dim wash that has to fade into the card without a visible seam. The last stop is also handed to the shader as the background it mixes toward, which is why a ramp that ends too bright shows an edge and one that ends near the card does not.
{
id: "magma",
name: "Magma",
// Hot rim, cooling bands, then the wash that
// fades into the card behind it.
ramp: ["#ffe27a", "#ff8a1f", "#e8306a", "#7b1f6a", "#2a0c26"],
}Shader
Coordinates are divided by height rather than by width, so everything is measured in bar heights and the look does not change when the bar gets wider. The fill boundary lives at the value times the aspect ratio, displaced by noise.
// The boundary is the value plus two octaves of noise:
// one slow swell, one finer ripple riding on it.
float swell = fbm3(vec2(p.y * 1.15 + uSeed, t * 0.22)) - 0.5;
float ripple = fbm3(vec2(p.y * 3.00 - uSeed, t * 0.45)) - 0.5;
float edge = uProgress * aspect + (swell * 0.26 + ripple * 0.07) * uTurb;On top of that go the layers that make it read as lit rather than coloured: a trailing echo of the front, caustic filaments where the warp field creases, a key light from above with a bounce along the bottom, a specular sheen sweeping across, and finally the rim - a tight core, a wide bloom, and the halo that spills past the edge into the empty part of the track.
Motion
The animation loop never sets React state. Value, speed, glow and turbulence are read through refs, so dragging a bar across the screen costs one render per pointer event rather than one per frame, and a wall of bars costs none at all.
// Live values are read through refs and eased toward, so
// neither dragging nor the render loop costs a React render.
const dt = Math.min((now - last) / 1000, 0.05);
clock += reduced ? 0 : dt * knobs.current.speed;
shown += (target.current - shown) * Math.min(dt * 9, 1);
renderer.draw(shown / 100, clock, glow, turbulence);The shader clock is integrated rather than derived from wall time. Deriving it means multiplying elapsed time by speed, and the moment speed changes the whole field jumps; integrating means a change in speed only changes how fast the clock advances from where it already was.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
value | number | - | Fill percentage, 0-100. Required, and the only prop the component insists on besides the title. |
title | string | - | The line on the left, and the accessible name of the bar. |
subtitle | string | - | A second, quieter line. Omit it and the title centres itself. |
palette | "magma" | "ice" | "toxic" | "gold" | "violet" | Palette | "magma" | A named ramp, or an object of your own with the same five stops. |
onValueChange | (value: number) => void | - | Supply it and the bar becomes a slider - drag, arrows, Home and End. Leave it off and it is a readout. |
speed | number | 1 | Animation rate. The shader clock is integrated, so changing this eases in instead of jumping. |
glow | number | 1 | Lighting intensity: rim, bloom, sheen, filaments and the light cast under the card, together. |
turbulence | number | 1 | How far the fluid boundary may swell away from the value it is reporting. Zero draws a straight edge. |
seed | number | 0 | Phase offset. Give stacked bars different seeds or they all churn on the same beat. |
className | string | - | Merged over the component's own classes, so a later utility wins. |
Notes
The things that were not obvious while building it, and would be easy to undo by accident.
- The context is never deliberately lost. A canvas hands back the same WebGL context object forever, so calling
WEBGL_lose_contexton unmount would leave a dead context behind for the next mount. Cleanup deletes the program, the buffer and the shaders, and stops there. preserveDrawingBufferis on for a reason. The cast light is the frame itself, copied out withdrawImageafter the draw call. Without it the buffer is already cleared by the time the copy happens and the glow is blank.- Reduced motion stops the clock, not the bar. The fill still moves to its value and still eases; only the noise field is frozen. A progress bar that refuses to show progress is not an accessibility win.
- The value the shader draws is not the value the prop holds. It eases toward it at a fixed rate, which is what turns a jump from 30 to 90 into a surge instead of a cut - and why the number in the corner, which is the prop, can briefly lead the fluid.