Tab field
Forty drifting browser tabs that settle into a calm grid of ten as progress rises.
npx shadcn@latest add https://ui.goodtabs.co/r/tab-field.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/tab-field
A ScrollTrigger scrub drives progress: 40 tabs drift, 30 fall away, 10 land with an indigo mark.
Keep six
A button tweens a ref from 0 to 1, with keep at 6 for a grid of 3 by 2.
Usage
import { TabField } from "@/components/ui/tab-field"
const progress = React.useRef(0)
// gsap.to(progress, { current: 1, scrollTrigger: { scrub: true } })
<TabField progress={progress} className="h-dvh pt-40" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| progress | number | { current: number } | 0 | 0 is the drifting field, 1 the finished grid. A ref is read every frame with no re-render. |
| keep | number | 10 | How many of the 40 tabs stay. Half as many columns as tabs, or two columns in a narrow box. |
| speed | number | 1 | Multiplies the drift. |
| maxDpr | number | 2 | Upper limit on device pixels per CSS pixel. |
| settleAfter | number | Seconds until the drift eases to rest. Set it at 5 or less where nothing else can pause the motion. |
Source
tab-field/tab-field.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/** A plain number, or anything holding one in `current`, such as a ref a tween mutates every frame. */
type TabFieldProgress = number | { current: number };
type TabFieldProps = React.ComponentProps<"div"> & {
/** 0 is the drifting field, 1 the finished grid. A ref is read every frame, so a tween never re-renders. */
progress?: TabFieldProgress;
/** How many of the 40 tabs stay. 10 gives a grid of 5 by 2, or 2 by 5 in a narrow box. */
keep?: number;
/** Multiplies the drift. */
speed?: number;
/** Seconds until the drift comes to rest. Motion that starts by itself and lasts over 5s needs a pause control. */
settleAfter?: number;
/** Upper limit on device pixels per CSS pixel. */
maxDpr?: number;
};
const TOTAL = 40;
const STRIDE = 11;
/** Full height over full width of one tab: a real browser tab is about three to one. */
const RATIO = 1 / 3;
const MAX_WIDTH = 280;
const NARROW = 640;
const VERTEX = `attribute vec2 aCorner;
attribute vec2 aHome;
attribute vec2 aSlot;
attribute vec4 aSeed;
attribute float aKept;
uniform float uTime;
uniform float uProgress;
uniform float uFull;
uniform float uLoose;
uniform vec2 uResolution;
uniform vec2 uCenter;
uniform vec2 uPitch;
varying vec2 vLocal;
varying float vPixel;
varying float vAlpha;
varying float vSettled;
varying float vTitle;
// Quintic ease in and out, flat at both ends, so a tab leaves and lands with no visible start or stop.
float settle(float t) {
t = clamp(t, 0.0, 1.0);
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}
void main() {
float depth = aSeed.x;
float phase = aSeed.y * 6.2832;
float lag = aSeed.w;
// Sines at unrelated rates, so the drift never visibly repeats and never needs wrapping.
vec2 wander = vec2(
sin(uTime * 0.13 + phase) + 0.5 * sin(uTime * 0.07 + phase * 2.3),
cos(uTime * 0.11 + phase * 1.4) + 0.5 * cos(uTime * 0.05 + phase * 3.1)
);
vec2 loose = aHome * uResolution + wander * uLoose * mix(0.4, 1.0, depth);
float looseSize = uLoose * mix(0.72, 1.25, depth);
float turn = (aSeed.z - 0.5) * 0.5 + 0.08 * sin(uTime * 0.09 + phase);
float fog = mix(0.62, 1.0, depth);
// Every kept tab lands exactly at progress 1: 0.08 + 0.22 + 0.7.
float arrive = settle((uProgress - 0.08 - lag * 0.22) / 0.7);
vec2 keptCenter = mix(loose, uCenter + aSlot * uPitch, arrive);
float keptSize = mix(looseSize, uFull, arrive);
float keptTurn = turn * (1.0 - arrive);
float keptAlpha = mix(fog, 1.0, arrive);
// The rest go first, falling on a squared curve, nearer ones faster.
float drop = clamp((uProgress - lag * 0.3) / 0.55, 0.0, 1.0);
vec2 fallCenter = loose + vec2(0.0, uResolution.y * mix(0.6, 1.1, depth) * drop * drop);
float fallSize = looseSize * (1.0 - 0.2 * drop);
float fallTurn = turn + (aSeed.z - 0.5) * 0.9 * drop;
float fallAlpha = fog * (1.0 - smoothstep(0.05, 0.8, drop));
vec2 center = mix(fallCenter, keptCenter, aKept);
float size = mix(fallSize, keptSize, aKept);
float angle = mix(fallTurn, keptTurn, aKept);
vAlpha = mix(fallAlpha, keptAlpha, aKept);
vSettled = arrive * aKept;
vTitle = aSeed.y;
// Shape space has a half width of 1. Two spare device pixels keep the soft edge inside the quad.
vec2 reach = vec2(1.0, ${RATIO.toFixed(4)}) * size + 2.0;
vec2 offset = aCorner * reach;
vLocal = offset / size;
vPixel = 1.0 / size;
float c = cos(angle);
float s = sin(angle);
offset = vec2(c * offset.x - s * offset.y, s * offset.x + c * offset.y);
vec2 clip = (center + vec2(offset.x, -offset.y)) / uResolution * 2.0 - 1.0;
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
}`;
const FRAGMENT = `precision mediump float;
uniform vec3 uInk;
uniform vec3 uSurface;
uniform vec3 uMark;
uniform float uScale;
varying vec2 vLocal;
varying float vPixel;
varying float vAlpha;
varying float vSettled;
varying float vTitle;
const float HEIGHT = ${RATIO.toFixed(4)};
const float FOOT = 0.12;
const float WAIST = 1.0 - FOOT;
const float CROWN = 0.14;
float rounded(vec2 p, vec2 size, float radius) {
vec2 q = abs(p) - size + radius;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
}
// The brand tab: a rounded top, a flat base, and two feet that flare out into the baseline.
float tab(vec2 p) {
// The box runs past the baseline, so only its top corners round, and the baseline cuts it flat.
float body = max(rounded(p + vec2(0.0, CROWN), vec2(WAIST, HEIGHT + CROWN), CROWN), -HEIGHT - p.y);
// A foot is a square with a circle taken out of it. The bite is the concave flare, tangent to the
// side of the tab and to the baseline. The square reaches into the body so no seam shows.
vec2 q = vec2(abs(p.x), p.y);
vec2 square = q - vec2(WAIST + FOOT * 0.5 - 0.05, -HEIGHT + FOOT * 0.5);
float box = rounded(square, vec2(FOOT * 0.5 + 0.05, FOOT * 0.5), 0.0);
float foot = max(box, FOOT - length(q - vec2(1.0, FOOT - HEIGHT)));
return min(body, foot);
}
float dotAt(vec2 p, vec2 center, float radius) {
return 1.0 - smoothstep(-vPixel, vPixel, length(p - center) - radius);
}
void main() {
vec2 p = vLocal;
float d = tab(p);
float inside = 1.0 - smoothstep(-vPixel, vPixel, d);
// One CSS pixel wide at every size, with one device pixel of soft edge.
float hair = 0.5 * uScale * vPixel;
float line = 1.0 - smoothstep(hair - vPixel * 0.5, hair + vPixel * 0.5, abs(d));
// One light from above: the crown catches it, the base falls away.
float lit = smoothstep(-HEIGHT, HEIGHT, p.y);
float lineAlpha = line * mix(0.3, 0.8, lit);
float fillAlpha = inside * 0.94;
// A tint of ink lifts the tab off the page in both themes. A kept tab steps up, like the active one.
vec3 surface = mix(uSurface, uInk, mix(0.085, 0.12, vSettled) + 0.03 * lit);
// One favicon and one title in every tab, with titles of different lengths.
float reach = mix(0.24, 0.42, vTitle);
float icon = dotAt(p, vec2(-0.6, 0.0), 0.078);
float title = 1.0 - smoothstep(-vPixel, vPixel, rounded(p - vec2(-0.42 + reach, 0.0), vec2(reach, 0.03), 0.03));
float detail = max(icon * mix(0.6, 0.8, vSettled), title * mix(0.3, 0.42, vSettled)) * inside;
vec3 color = surface * fillAlpha * (1.0 - lineAlpha) + uInk * lineAlpha;
float alpha = lineAlpha + fillAlpha * (1.0 - lineAlpha);
color = mix(color, uInk * alpha, detail);
// Indigo says kept: a small mark that only a tab someone chose ever shows.
float chosen = dotAt(p, vec2(0.66, 0.0), 0.04) * smoothstep(0.75, 1.0, vSettled);
color = mix(color, uMark * alpha, chosen);
gl_FragColor = vec4(color, alpha) * vAlpha;
}`;
const TOKENS = { uInk: "--foreground", uSurface: "--card", uMark: "--brand" };
/** Resolves a token through the cascade, so a scoped .light or .dark wrapper is respected. */
function readToken(host: HTMLElement, token: string): [number, number, number] {
const probe = document.createElement("span");
probe.style.color = `var(${token})`;
probe.style.display = "none";
host.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
const paint = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!paint) return [0, 0, 0];
paint.fillStyle = color;
paint.fillRect(0, 0, 1, 1);
const [r, g, b] = paint.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255];
}
function compile(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
const fract = (n: number) => n - Math.floor(n);
/** Two triangles per tab in one buffer, so 40 tabs are one draw call and need no extension. */
function buildField(keep: number, columns: number) {
const tabs = Array.from({ length: TOTAL }, (_, i) => ({
// The R2 sequence scatters any prefix evenly, so the first `keep` tabs are spread over the field.
home: [fract(0.5 + i * 0.7548776662) * 0.94 + 0.03, fract(0.5 + i * 0.569840291) * 0.94 + 0.03],
depth: fract(0.31 + i * 0.6180339887),
phase: fract(0.17 + i * 0.7320508076),
tilt: fract(0.43 + i * 0.4142135624),
lag: fract(0.61 + i * 0.2360679775),
slot: [0, 0],
kept: i < keep ? 1 : 0,
}));
// Rows by height, then each row by x, so the tabs that stay do not cross on their way in.
const kept = tabs.filter((tab) => tab.kept).sort((a, b) => a.home[1] - b.home[1]);
const rows = Math.ceil(keep / columns);
for (let row = 0; row < rows; row += 1) {
const line = kept.slice(row * columns, (row + 1) * columns).sort((a, b) => a.home[0] - b.home[0]);
line.forEach((tab, column) => {
tab.slot = [column - (line.length - 1) / 2, row - (rows - 1) / 2];
// They land in reading order.
tab.lag = (row * columns + column) / Math.max(keep - 1, 1);
});
}
// No depth buffer: far tabs paint first, and the tabs that stay paint over everything.
tabs.sort((a, b) => a.kept - b.kept || a.depth - b.depth);
const corners = [-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1];
const data = new Float32Array(TOTAL * 6 * STRIDE);
tabs.forEach((tab, t) => {
for (let v = 0; v < 6; v += 1) {
data.set(
[
corners[v * 2],
corners[v * 2 + 1],
tab.home[0],
tab.home[1],
tab.slot[0],
tab.slot[1],
tab.depth,
tab.phase,
tab.tilt,
tab.lag,
tab.kept,
],
(t * 6 + v) * STRIDE,
);
}
});
return data;
}
/** The finished grid in CSS, shown without WebGL and with no script at all. */
function TabFieldStill({ keep }: { keep: number }) {
return (
<div
style={{ "--tab-field-columns": Math.ceil(keep / 2) } as React.CSSProperties}
className="mx-auto grid size-full max-w-[1464px] grid-cols-2 content-center gap-3 motion-safe:animate-in motion-safe:delay-500 motion-safe:fill-mode-both motion-safe:fade-in sm:grid-cols-[repeat(var(--tab-field-columns),minmax(0,1fr))] sm:gap-4"
>
{Array.from({ length: keep }, (_, i) => (
<svg key={i} viewBox="0 0 120 40" className="aspect-3/1 w-full overflow-visible">
<path
d="M0 40c4.8 0 7.2-2.4 7.2-7.2V12C7.2 5.4 12.6 0 19.2 0h81.6c6.6 0 12 5.4 12 12v20.8c0 4.8 2.4 7.2 7.2 7.2Z"
vectorEffect="non-scaling-stroke"
className="fill-card stroke-border"
/>
<circle cx="24" cy="20" r="4.7" className="fill-muted-foreground" />
<rect x="34.8" y="18.2" width="48" height="3.6" rx="1.8" className="fill-muted-foreground/50" />
<circle cx="99.6" cy="20" r="2.4" className="fill-brand" />
</svg>
))}
</div>
);
}
/**
* Forty small browser tabs drift in a field, drawn in raw WebGL. Raising progress pulls `keep` of them
* into a calm grid while the rest fall away and fade. The grid sits inside the element's padding, so pad
* it to leave room for a heading. Under reduced motion, and without WebGL, the finished grid shows.
*/
function TabField({
progress = 0,
keep = 10,
speed = 1,
settleAfter,
maxDpr = 2,
className,
children,
...props
}: TabFieldProps) {
const root = React.useRef<HTMLDivElement>(null);
const canvas = React.useRef<HTMLCanvasElement>(null);
const latest = React.useRef({ progress, speed, settleAfter });
const redraw = React.useRef<() => void>(() => {});
const [ready, setReady] = React.useState(false);
const count = Math.max(1, Math.min(TOTAL, Math.round(keep)));
React.useEffect(() => {
latest.current = { progress, speed, settleAfter };
redraw.current();
});
React.useEffect(() => {
const host = root.current;
const el = canvas.current;
if (!host || !el) return;
// One still frame has to outlive the next composite, so reduced motion keeps the buffer.
const gl = el.getContext("webgl", {
preserveDrawingBuffer: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
alpha: true,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: true,
powerPreference: "low-power",
});
if (!gl) return;
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
const frag = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
const program = gl.createProgram();
if (!vertex || !frag || !program) return;
gl.attachShader(program, vertex);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
return;
}
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
const attributes: [string, number, number][] = [
["aCorner", 2, 0],
["aHome", 2, 2],
["aSlot", 2, 4],
["aSeed", 4, 6],
["aKept", 1, 10],
];
for (const [name, size, offset] of attributes) {
const location = gl.getAttribLocation(program, name);
gl.enableVertexAttribArray(location);
gl.vertexAttribPointer(location, size, gl.FLOAT, false, STRIDE * 4, offset * 4);
}
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
const at = (name: string) => gl.getUniformLocation(program, name);
const uniforms = {
time: at("uTime"),
progress: at("uProgress"),
scale: at("uScale"),
full: at("uFull"),
loose: at("uLoose"),
resolution: at("uResolution"),
center: at("uCenter"),
pitch: at("uPitch"),
};
const readTheme = () => {
for (const [name, token] of Object.entries(TOKENS)) {
gl.uniform3f(at(name), ...readToken(host, token));
}
};
readTheme();
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
const stillAt = 2;
let time = stillAt;
let elapsed = 0;
let visible = false;
let frame = 0;
let last = 0;
let shown = -1;
let columns = 0;
const readProgress = () => {
if (motion.matches) return 1;
const value = latest.current.progress;
return Math.max(0, Math.min(1, typeof value === "number" ? value : value.current));
};
const draw = () => {
shown = readProgress();
gl.viewport(0, 0, el.width, el.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(uniforms.time, time);
gl.uniform1f(uniforms.progress, shown);
gl.drawArrays(gl.TRIANGLES, 0, TOTAL * 6);
};
redraw.current = () => {
if (motion.matches || readProgress() !== shown) draw();
};
const loop = (now: number) => {
frame = 0;
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
elapsed += dt;
// Speed decays as e^(-3t / settleAfter), down to 5 percent at settleAfter, so it eases to rest.
const rest = latest.current.settleAfter;
const settled = rest !== undefined && elapsed >= rest;
if (!settled) time += dt * latest.current.speed * (rest === undefined ? 1 : Math.exp((-3 * elapsed) / rest));
// A finished grid or a settled field does not move, so it costs nothing until progress changes.
const idle = (shown === 1 || settled) && readProgress() === shown;
if (!idle) draw();
schedule();
};
const schedule = () => {
if (frame || !visible || motion.matches || document.hidden) return;
frame = requestAnimationFrame(loop);
};
const stop = () => {
if (frame) cancelAnimationFrame(frame);
frame = 0;
last = 0;
};
const resize = () => {
const scale = Math.min(window.devicePixelRatio || 1, maxDpr);
const width = Math.max(1, Math.round(el.clientWidth * scale));
const height = Math.max(1, Math.round(el.clientHeight * scale));
if (el.width !== width || el.height !== height) {
el.width = width;
el.height = height;
}
// The grid takes the element's content box, so responsive padding classes move it.
const style = getComputedStyle(host);
const left = parseFloat(style.paddingLeft) || 0;
const top = parseFloat(style.paddingTop) || 0;
const innerWidth = Math.max(1, el.clientWidth - left - (parseFloat(style.paddingRight) || 0));
const innerHeight = Math.max(1, el.clientHeight - top - (parseFloat(style.paddingBottom) || 0));
const narrow = innerWidth < NARROW;
const nextColumns = Math.min(count, narrow ? 2 : Math.ceil(count / 2));
if (nextColumns !== columns) {
columns = nextColumns;
gl.bufferData(gl.ARRAY_BUFFER, buildField(count, columns), gl.STATIC_DRAW);
}
const rows = Math.ceil(count / columns);
const gap = narrow ? 12 : 16;
const tabWidth = Math.min(
(innerWidth - (columns - 1) * gap) / columns,
(innerHeight - (rows - 1) * gap) / rows / RATIO,
MAX_WIDTH,
);
// Forty loose tabs cover about a quarter of the field, whatever its size, and stay smaller than the grid.
const covered = Math.sqrt((0.24 * el.clientWidth * el.clientHeight) / TOTAL / RATIO) / 2;
gl.uniform1f(uniforms.scale, scale);
gl.uniform1f(uniforms.full, (tabWidth / 2) * scale);
gl.uniform1f(uniforms.loose, Math.min(covered, tabWidth * 0.3) * scale);
gl.uniform2f(uniforms.resolution, width, height);
gl.uniform2f(uniforms.center, (left + innerWidth / 2) * scale, (top + innerHeight / 2) * scale);
gl.uniform2f(uniforms.pitch, (tabWidth + gap) * scale, (tabWidth * RATIO + gap) * scale);
draw();
};
const resizer = new ResizeObserver(resize);
resizer.observe(el);
const viewport = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) schedule();
else stop();
});
viewport.observe(el);
// Smooth scrolling libraries flip classes on html while scrolling, so react only to the theme itself.
const themeKey = () => {
const html = document.documentElement;
return `${html.classList.contains("dark")} ${html.classList.contains("light")} ${html.style.colorScheme}`;
};
let lastTheme = themeKey();
const theme = new MutationObserver(() => {
if (themeKey() === lastTheme) return;
lastTheme = themeKey();
readTheme();
draw();
});
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
const onVisibility = () => (document.hidden ? stop() : schedule());
const onMotion = () => {
stop();
if (motion.matches) {
time = stillAt;
draw();
} else schedule();
};
document.addEventListener("visibilitychange", onVisibility);
motion.addEventListener("change", onMotion);
const onLost = (e: Event) => {
e.preventDefault();
stop();
setReady(false);
};
el.addEventListener("webglcontextlost", onLost);
resize();
setReady(true);
return () => {
stop();
redraw.current = () => {};
resizer.disconnect();
viewport.disconnect();
theme.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
motion.removeEventListener("change", onMotion);
el.removeEventListener("webglcontextlost", onLost);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(frag);
};
}, [count, maxDpr]);
return (
<div
ref={root}
data-slot="tab-field"
className={cn("relative isolate overflow-hidden bg-background p-6 md:p-12", className)}
{...props}
>
<div aria-hidden="true" className="absolute inset-0 -z-10 p-[inherit]">
{ready ? null : <TabFieldStill keep={count} />}
<canvas ref={canvas} className="absolute inset-0 size-full" />
</div>
{children}
</div>
);
}
export { TabField };
export type { TabFieldProgress, TabFieldProps };
For AI agents
Hand your agent this page, the markdown at /components/tab-field/prompt.md, or the Copy AI prompt button above.
When to use
- A hero that tells the story of too many tabs becoming the few that matter
- A scroll section where progress follows a scrub and ends on a calm grid
- A background with one action that resolves it, such as a tidy up button
When not to use
- Behind long text: the loose tabs cross the whole field
- For real content: the tabs are drawn, so nothing in them can be read or clicked
- More than once or twice on a page: browsers cap live WebGL contexts near 16
Agent notes
- Pass a ref as progress and tween its current value. Passing a number re-renders the parent on every change
- The element needs a height. The grid sits inside its padding, so use padding classes to leave room for a heading
- Under reduced motion it always draws the finished grid, whatever progress says, so never rely on progress 0 being visible
- The component imports no GSAP and declares none. Only the demos do, so copying them means installing gsap and @gsap/react from npm under GSAP's own licence
- It has its own small WebGL setup rather than ShaderCanvas, because it draws 40 quads, and keeps the same behaviour: aria-hidden canvas, pauses off screen, follows the theme, CSS grid fallback
The maths
- Forty tabs are 40 × 6 = 240 vertices in one buffer and one draw call, so a pixel only runs the signed distance for the tab that covers it.
- Each foot is a square minus a circle, max(square, r - |p - c|), tangent to the side and the baseline, which is the concave flare of the logo.
- Home positions follow the R2 sequence, frac(0.5 + n × 0.7549) and frac(0.5 + n × 0.5698), so the first ten tabs are spread evenly over the field.
- A loose tab's half width is √(0.24 × area / 40 × 3) / 2 for a 3:1 tab, so forty tabs cover a quarter of the box at any size.