Shader Transition
betaTransitions a frame between two states under a full-frame WebGL reveal — one raw-WebGL engine and sixteen presets (aperture, chroma, prism, SDF blobs and circles, noise, wipe, stripes, spiral…).
Quarterly report, ready to review.
Installation
pnpm dlx @dowel-ui/cli add shader-transitionNo npm packages are needed beyond what Dowel already requires.
Accessibility
Both states are real DOM; the shader only paints a decorative, aria-hidden cover over the frame. While a run is in progress the outgoing state is aria-hidden and inert, the incoming state is in the accessibility tree from the first frame, and focus inside the outgoing state moves to the incoming one. The root is aria-busy while running. Reduced motion (OS setting or --motion-scale 0) swaps instantly; without WebGL, or if the context is lost mid-run, the states cross-fade instead.
Props
ShaderTransition
| Prop | Type | Default |
|---|---|---|
accentColour token of the glow on the moving front. Default "primary". | string | "primary" |
accentSecondaryColour token the preset's sheen tints the cover with. Default: accent mixed into surface. | string | — |
activeTwo-state API: which state is shown. Changing it runs the transition. | boolean | false |
durationRun length in ms at --motion-scale 1. Defaults to the preset's. | number | — |
fromTwo-state API: shown while | ReactNode | — |
onRestCalled when a run settles, however it ran. | () => void | — |
onStartCalled when a run starts. | () => void | — |
onSwapCalled when the incoming state becomes visible (the midpoint, or at once without motion). | () => void | — |
presetThe reveal pattern. Default "noise". | ShaderTransitionPresetName | "noise" |
surfaceColour token of the cover — the surface behind the content. Default "background". | string | "background" |
toTwo-state API: shown while | ReactNode | — |
transitionKeyKeyed API: changing the key runs the transition to the new children. | string | number | — |
Plus every attribute of <div> except children.
Quality
8/9 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — fails
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Source
This is exactly what dowel add shader-transition writes into your project, with imports rewritten to your own path alias.
"use client";
// Ported from SmoothUI "Shader Reveal Transition" (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ComponentPropsWithRef,
type CSSProperties,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import {
createShaderRenderer,
easeRun,
markWebGLUnavailable,
motionScale,
prefersReducedMotion,
resolveRgb,
supportsWebGL,
type Rgb,
type ShaderRenderer,
} from "./shader-transition-engine";
import {
getShaderTransitionPreset,
type ShaderTransitionPresetName,
} from "./shader-transition-presets";
/*
* A frame-level transition between two states, both of them real DOM.
*
* State machine (data-state on the root): idle → running → done, and running
* again whenever the shown state changes. While running:
*
* - shader mode: a canvas over the frame grows a cover in the surface colour
* over the outgoing state (data-phase="cover"), the states swap under the
* full cover at the midpoint (data-phase="reveal") and the cover recedes over
* the incoming state. One WebGL context per run, created when the run starts
* on a fresh canvas and released when it ends — an idle instance holds none.
* - fade mode: no WebGL (or the context was lost mid-run) — a CSS cross-fade.
* - reduced motion (OS setting or --motion-scale 0), or the frame off-screen:
* the states swap instantly.
*
* Throughout, the outgoing layer is aria-hidden and inert, so assistive
* technology and the keyboard only ever meet the incoming state; if focus was
* inside the outgoing layer it moves to the incoming one.
*/
export type ShaderTransitionStatus = "idle" | "running" | "done";
export interface ShaderTransitionProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
/** The reveal pattern. Default "noise". */
preset?: ShaderTransitionPresetName;
/** Two-state API: shown while `active` is false. */
from?: ReactNode;
/** Two-state API: shown while `active` is true. */
to?: ReactNode;
/** Two-state API: which state is shown. Changing it runs the transition. */
active?: boolean;
/** Keyed API: changing the key runs the transition to the new children. */
transitionKey?: string | number;
children?: ReactNode;
/** Run length in ms at --motion-scale 1. Defaults to the preset's. */
duration?: number;
/** Colour token of the cover — the surface behind the content. Default "background". */
surface?: string;
/** Colour token of the glow on the moving front. Default "primary". */
accent?: string;
/** Colour token the preset's sheen tints the cover with. Default: accent mixed into surface. */
accentSecondary?: string;
/** Called when a run starts. */
onStart?: () => void;
/** Called when the incoming state becomes visible (the midpoint, or at once without motion). */
onSwap?: () => void;
/** Called when a run settles, however it ran. */
onRest?: () => void;
}
interface Slot {
key: string;
node: ReactNode;
}
interface MachineState {
shown: Slot;
leaving: Slot | null;
status: ShaderTransitionStatus;
phase: "cover" | "reveal";
mode: "shader" | "fade";
/** Increments per run; keys the run effect. */
run: number;
}
const FADE_SHARE = 0.5;
const STYLES = `
@keyframes dowel-shader-transition-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes dowel-shader-transition-out { from { opacity: 1; } to { opacity: 0; } }
[data-slot="shader-transition"][data-mode="shader"][data-phase="cover"] > [data-slot="shader-transition-layer"][data-state="entering"] {
opacity: 0;
pointer-events: none;
}
[data-slot="shader-transition"][data-phase="reveal"] > [data-slot="shader-transition-layer"][data-state="leaving"] {
visibility: hidden;
}
[data-slot="shader-transition"][data-mode="fade"] > [data-slot="shader-transition-layer"][data-state="leaving"] {
animation: dowel-shader-transition-out var(--shader-transition-fade) var(--ease-in-out-quint) both;
pointer-events: none;
}
[data-slot="shader-transition"][data-mode="fade"] > [data-slot="shader-transition-layer"][data-state="entering"] {
animation: dowel-shader-transition-in var(--shader-transition-fade) var(--ease-in-out-quint) both;
}
`;
function mix(a: Rgb, b: Rgb, t: number): Rgb {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
export function ShaderTransition({
preset = "noise",
from,
to,
active = false,
transitionKey,
children,
duration,
surface = "background",
accent = "primary",
accentSecondary,
onStart,
onSwap,
onRest,
className,
style,
ref,
...props
}: ShaderTransitionProps) {
const pair = transitionKey === undefined && (from !== undefined || to !== undefined);
const incoming: Slot = pair
? { key: active ? "to" : "from", node: active ? to : from }
: { key: `key:${String(transitionKey ?? "")}`, node: children };
const [state, setState] = useState<MachineState>(() => ({
shown: incoming,
leaving: null,
status: "idle",
phase: "cover",
mode: "shader",
run: 0,
}));
// Derive the machine from props during render (React's "adjust state on prop
// change" pattern), so the outgoing state is never missing for a frame.
if (incoming.key !== state.shown.key) {
if (state.leaving && incoming.key === state.leaving.key) {
// Back to where the run started: cancel it.
setState({
...state,
shown: incoming,
leaving: null,
status: "done",
run: state.run + 1,
});
} else if (
state.status === "running" &&
state.mode === "shader" &&
state.phase === "cover"
) {
// Retarget under the cover: the same run carries on to the newer state.
setState({ ...state, shown: incoming });
} else {
setState({
shown: incoming,
leaving: state.shown,
status: "running",
phase: "cover",
mode: "shader",
run: state.run + 1,
});
}
} else if (incoming.node !== state.shown.node) {
setState({ ...state, shown: incoming });
}
const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const settledRun = useRef(0);
const enteringRef = useRef<HTMLDivElement | null>(null);
const onScreen = useRef(true);
const latest = useRef({
preset,
duration,
surface,
accent,
accentSecondary,
onStart,
onSwap,
onRest,
});
// A layout effect, declared before the run's, so a run starting in this
// commit already sees this render's preset and callbacks.
useLayoutEffect(() => {
latest.current = {
preset,
duration,
surface,
accent,
accentSecondary,
onStart,
onSwap,
onRest,
};
});
// Know whether the frame is visible, so a run nobody can see is skipped.
useEffect(() => {
const root = rootRef.current;
if (!root || typeof IntersectionObserver !== "function") return;
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) onScreen.current = entry.isIntersecting;
});
observer.observe(root);
return () => {
observer.disconnect();
};
}, []);
const { run, status } = state;
useLayoutEffect(() => {
const root = rootRef.current;
const options = latest.current;
if (!root) return;
if (status !== "running") {
// A run that ended in the render phase (a cancel) still settles.
if (run > 0 && settledRun.current !== run) {
settledRun.current = run;
options.onSwap?.();
options.onRest?.();
}
return;
}
let raf = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let renderer: ShaderRenderer | null = null;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
renderer?.resize(entry.contentRect.width, entry.contentRect.height);
}
});
let swapped = false;
let settled = false;
let canvas: HTMLCanvasElement | null = null;
const swap = () => {
if (swapped) return;
swapped = true;
latest.current.onSwap?.();
};
const teardown = () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
if (timer) clearTimeout(timer);
timer = undefined;
resizeObserver.disconnect();
canvas?.removeEventListener("webglcontextlost", onLost);
renderer?.destroy();
renderer = null;
canvas?.remove();
canvas = null;
};
const finish = () => {
if (settled) return;
settled = true;
settledRun.current = run;
teardown();
swap();
setState((current) =>
current.run === run
? { ...current, leaving: null, status: "done", phase: "cover", mode: "shader" }
: current,
);
latest.current.onRest?.();
};
const fade = (ms: number) => {
teardown();
setState((current) => (current.run === run ? { ...current, mode: "fade" } : current));
swap();
timer = setTimeout(finish, ms);
};
function onLost(event: Event) {
// Let the browser restore the context; this run finishes as a fade and
// the next run builds a fresh context on a fresh canvas.
event.preventDefault();
fade(remaining * FADE_SHARE);
}
options.onStart?.();
// Keep keyboard focus out of the outgoing layer once it goes inert.
const leavingLayer = root.querySelector(
'[data-slot="shader-transition-layer"][data-state="leaving"]',
);
if (leavingLayer?.contains(document.activeElement)) {
enteringRef.current?.focus({ preventScroll: true });
}
const scale = motionScale(root);
const presetData = getShaderTransitionPreset(options.preset);
const total = Math.max(0, (options.duration ?? presetData.duration) * scale);
let remaining = total;
if (prefersReducedMotion(root) || total <= 0 || !onScreen.current) {
finish();
return teardown;
}
// A fresh canvas per run (and per effect pass, so StrictMode's double
// invocation never meets a context the first pass already released).
const host = hostRef.current;
if (host && supportsWebGL()) {
canvas = document.createElement("canvas");
canvas.setAttribute("data-slot", "shader-transition-canvas");
canvas.style.cssText = "display:block;width:100%;height:100%";
host.appendChild(canvas);
const surfaceRgb = resolveRgb(root, options.surface, [1, 1, 1]);
const accentRgb = resolveRgb(root, options.accent);
renderer = createShaderRenderer(canvas, presetData, {
colors: {
surface: surfaceRgb,
accent: accentRgb,
accentSecondary: options.accentSecondary
? resolveRgb(root, options.accentSecondary)
: mix(surfaceRgb, accentRgb, 0.4),
},
flip: window.getComputedStyle(root).direction === "rtl",
});
if (!renderer && !canvas.getContext("webgl")) markWebGLUnavailable();
}
if (!renderer || !canvas) {
fade(total * FADE_SHARE);
return teardown;
}
const active = renderer;
const box = root.getBoundingClientRect();
active.resize(box.width, box.height);
resizeObserver.observe(root);
canvas.addEventListener("webglcontextlost", onLost);
let elapsed = 0;
let last: number | null = null;
const tick = (now: number) => {
raf = 0;
if (!onScreen.current) {
finish();
return;
}
// Deltas are clamped, so a hidden tab (no frames) resumes where it left off.
const delta = last === null ? 0 : Math.min(now - last, 1000 / 15);
last = now;
elapsed += delta;
remaining = Math.max(0, total - elapsed);
const t = elapsed / total;
let progress = easeRun(t);
if (progress >= 0.5 && !swapped) {
// Draw this frame fully covered; the states swap underneath it.
progress = 0.5;
swap();
setState((current) =>
current.run === run ? { ...current, phase: "reveal" } : current,
);
}
active.draw(progress, elapsed / 1000);
if (t >= 1) {
finish();
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const onVisibility = () => {
last = null;
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
teardown();
};
}, [run, status]);
const setRootRef = useCallback(
(node: HTMLDivElement | null) => {
rootRef.current = node;
if (typeof ref === "function") return ref(node);
if (ref) ref.current = node;
return undefined;
},
[ref],
);
const running = state.status === "running";
const baseDuration = duration ?? getShaderTransitionPreset(preset).duration;
const rootStyle = {
"--shader-transition-fade": `calc(${String(Math.round(baseDuration * FADE_SHARE))}ms * var(--motion-scale, 1))`,
...style,
} as CSSProperties;
return (
<>
<style href="dowel-shader-transition" precedence="dowel">
{STYLES}
</style>
<div
data-slot="shader-transition"
data-state={state.status}
data-preset={preset}
data-mode={running ? state.mode : undefined}
data-phase={running && state.mode === "shader" ? state.phase : undefined}
aria-busy={running || undefined}
{...props}
ref={setRootRef}
style={rootStyle}
className={cn("relative isolate grid overflow-hidden", className)}
>
{state.leaving ? (
<div
key={`layer-${state.leaving.key}`}
data-slot="shader-transition-layer"
data-state="leaving"
aria-hidden="true"
inert
className="col-start-1 row-start-1 min-w-0"
>
{state.leaving.node}
</div>
) : null}
<div
key={`layer-${state.shown.key}`}
ref={enteringRef}
data-slot="shader-transition-layer"
data-state={running ? "entering" : "current"}
tabIndex={running ? -1 : undefined}
className="col-start-1 row-start-1 min-w-0 outline-none"
>
{state.shown.node}
</div>
{running && state.mode === "shader" ? (
<div
ref={hostRef}
aria-hidden="true"
data-slot="shader-transition-overlay"
className="pointer-events-none absolute inset-0 z-10"
/>
) : null}
</div>
</>
);
}
// Ported from SmoothUI "Shader Reveal Transition" engine (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import type { ShaderTransitionPreset } from "./shader-transition-presets";
/*
* The GL half of ShaderTransition: no React. What is taken from SmoothUI is the
* engine's shape — one full-frame quad, a fragment shader driven by a single
* `progress` uniform, premultiplied-alpha blending over the DOM, and a content
* swap at the midpoint. The fragment program itself is written here: SmoothUI's
* own fragment code for the shader-reveal and SDF families is not used (see
* shader-transition-presets.ts for why).
*
* The model. The canvas never shows content — it paints a *cover* in the
* surface colour. For the first half of the run the cover grows over the
* outgoing state along the preset's arrival field; at the midpoint the whole
* frame is covered and the states swap underneath; for the second half the
* cover recedes along the same field (or its reverse) and uncovers the incoming
* state. A preset is therefore just a scalar field `field(uv, p, t) → [0, 1]`,
* "when is this pixel covered", plus a `sheen` term that tints the cover with
* the secondary accent and a glow on the moving front in the primary accent.
*
* Trade-off, documented because it is the whole design: a true per-pixel
* cross-reveal (old and new state both visible through a mask) needs the canvas
* to mask DOM, and the only portable route is `mask-image` fed from
* `canvas.toDataURL()` every frame — a PNG encode per frame, far too slow at
* frame size. Painting a cover keeps both states as live, accessible DOM at the
* cost of never showing old and new pixels side by side.
*/
export type Rgb = readonly [number, number, number];
export interface ShaderColors {
/** The cover: the colour behind the content. */
surface: Rgb;
/** Glow on the moving front. */
accent: Rgb;
/** Tint mixed into the cover by the preset's sheen. */
accentSecondary: Rgb;
}
export interface ShaderRenderer {
/** Draws one frame. `progress` runs 0 → 1 across the whole run. */
draw: (progress: number, time: number) => void;
/** Resizes the backing store to a CSS box, DPR capped at 2. */
resize: (width: number, height: number) => void;
/** Frees the program, buffer and — where the browser allows — the context. */
destroy: () => void;
}
export const MAX_DPR = 2;
/** Shared by every preset. Varyings are unnecessary: the fragment reads gl_FragCoord. */
export const VERTEX_SHADER =
"attribute vec2 aPos;\nvoid main(){ gl_Position = vec4(aPos, 0.0, 1.0); }\n";
/** Helpers every preset's GLSL may call: value noise, fbm, a soft minimum. */
const PRELUDE = `
precision mediump float;
uniform vec2 uRes;
uniform float uTime;
uniform float uProgress;
uniform float uSoft;
uniform float uGlow;
uniform float uSheen;
uniform float uReverse;
uniform float uFlip;
uniform vec3 uSurface;
uniform vec3 uAccent;
uniform vec3 uAccent2;
#define PI 3.14159265359
#define TAU 6.28318530718
float hash21(vec2 q){ q = fract(q * vec2(233.34, 851.73)); q += dot(q, q + 23.45); return fract(q.x * q.y); }
float vnoise(vec2 q){
vec2 i = floor(q); vec2 f = fract(q);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x),
mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), u.x), u.y);
}
float fbm(vec2 q){
float v = 0.0; float a = 0.5;
for (int i = 0; i < 4; i++) { v += a * vnoise(q); q = q * 2.07 + vec2(11.3, 7.9); a *= 0.5; }
return v;
}
float smin(float a, float b, float k){ float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); }
float rmax(){ return length(vec2(uRes.x / uRes.y, 1.0) * 0.5); }
float tri(float x){ return abs(fract(x) * 2.0 - 1.0); }
`;
const MAIN = `
void main(){
vec2 uv = gl_FragCoord.xy / uRes;
uv.x = mix(uv.x, 1.0 - uv.x, uFlip);
vec2 p = (uv - 0.5) * vec2(uRes.x / uRes.y, 1.0);
float f = clamp(field(uv, p, uTime), 0.0, 1.0);
bool covering = uProgress < 0.5;
float q = covering ? uProgress * 2.0 : uProgress * 2.0 - 1.0;
float x = q * (1.0 + uSoft);
float g = covering ? f : mix(f, 1.0 - f, uReverse);
float c = covering ? 1.0 - smoothstep(x - uSoft, x, g) : smoothstep(x - uSoft, x, g);
float d = g - x;
float w = uSoft * 0.5 + 0.004;
float edge = exp(-(d * d) / (w * w)) * smoothstep(0.0, 0.06, q) * smoothstep(1.0, 0.94, q);
float glow = clamp(edge * uGlow, 0.0, 0.92);
float tint = clamp(sheen(uv, p, uTime, d) * uSheen, 0.0, 1.0);
vec3 base = mix(uSurface, uAccent2, tint);
float cover = c * (1.0 - glow);
gl_FragColor = vec4(base * cover + uAccent * glow, cover + glow);
}
`;
/** The complete fragment program for one preset. */
export function buildFragmentShader(preset: Pick<ShaderTransitionPreset, "glsl">): string {
return `${PRELUDE}\n${preset.glsl}\n${MAIN}`;
}
function compile(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
if (process.env.NODE_ENV !== "production") {
console.warn("[ShaderTransition] shader failed to compile:", gl.getShaderInfoLog(shader));
}
gl.deleteShader(shader);
return null;
}
return shader;
}
/** Compiles and links the shared vertex shader with a fragment source. */
export function createProgram(
gl: WebGLRenderingContext,
fragmentSource: string,
): WebGLProgram | null {
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const fragment = vertex ? compile(gl, gl.FRAGMENT_SHADER, fragmentSource) : null;
const program = vertex && fragment ? gl.createProgram() : null;
if (!vertex || !fragment || !program) {
if (vertex) gl.deleteShader(vertex);
if (fragment) gl.deleteShader(fragment);
return null;
}
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
return null;
}
return program;
}
const QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
const UNIFORMS = [
"uRes",
"uTime",
"uProgress",
"uSoft",
"uGlow",
"uSheen",
"uReverse",
"uFlip",
"uSurface",
"uAccent",
"uAccent2",
] as const;
export interface RendererOptions {
colors: ShaderColors;
/** Mirror the field horizontally, so sweeps travel toward inline-end in RTL. */
flip?: boolean;
}
/**
* Creates a context and program on `canvas` for one preset. Returns null when
* WebGL is unavailable or the program does not build — the caller falls back
* to a CSS cross-fade.
*/
export function createShaderRenderer(
canvas: HTMLCanvasElement,
preset: ShaderTransitionPreset,
{ colors, flip = false }: RendererOptions,
): ShaderRenderer | null {
const context = getWebGL(canvas);
if (!context) return null;
const program = createProgram(context, buildFragmentShader(preset));
if (!program) {
loseContext(context);
return null;
}
const buffer = context.createBuffer();
context.useProgram(program);
context.bindBuffer(context.ARRAY_BUFFER, buffer);
context.bufferData(context.ARRAY_BUFFER, QUAD, context.STATIC_DRAW);
const position = context.getAttribLocation(program, "aPos");
context.enableVertexAttribArray(position);
context.vertexAttribPointer(position, 2, context.FLOAT, false, 0, 0);
context.enable(context.BLEND);
context.blendFunc(context.ONE, context.ONE_MINUS_SRC_ALPHA);
context.clearColor(0, 0, 0, 0);
const loc = Object.fromEntries(
UNIFORMS.map((name) => [name, context.getUniformLocation(program, name)]),
) as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;
context.uniform1f(loc.uSoft, preset.softness);
context.uniform1f(loc.uGlow, preset.glow);
context.uniform1f(loc.uSheen, preset.sheen);
context.uniform1f(loc.uReverse, preset.reverse ? 1 : 0);
context.uniform1f(loc.uFlip, flip ? 1 : 0);
context.uniform3f(loc.uSurface, ...colors.surface);
context.uniform3f(loc.uAccent, ...colors.accent);
context.uniform3f(loc.uAccent2, ...colors.accentSecondary);
let destroyed = false;
return {
resize(width, height) {
if (destroyed) return;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const w = Math.max(1, Math.round(width * dpr));
const h = Math.max(1, Math.round(height * dpr));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
context.viewport(0, 0, w, h);
context.uniform2f(loc.uRes, w, h);
},
draw(progress, time) {
if (destroyed || context.isContextLost()) return;
context.clear(context.COLOR_BUFFER_BIT);
context.uniform1f(loc.uProgress, clamp01(progress));
context.uniform1f(loc.uTime, time);
context.drawArrays(context.TRIANGLE_STRIP, 0, 4);
},
destroy() {
if (destroyed) return;
destroyed = true;
if (!context.isContextLost()) {
context.deleteBuffer(buffer);
context.deleteProgram(program);
}
loseContext(context);
},
};
}
function getWebGL(canvas: HTMLCanvasElement): WebGLRenderingContext | null {
try {
return canvas.getContext("webgl", {
alpha: true,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: true,
preserveDrawingBuffer: false,
});
} catch {
return null;
}
}
/** Releases the context now rather than whenever the canvas is collected. */
function loseContext(gl: WebGLRenderingContext) {
gl.getExtension("WEBGL_lose_context")?.loseContext();
}
export function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
/** Symmetric ease for the whole run: slow in, fast through the swap, slow out. */
export function easeRun(t: number): number {
const x = clamp01(t);
return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2;
}
/* Colour ------------------------------------------------------------------ */
/** `"primary"` → `var(--color-primary)`; `--x` → `var(--x)`; anything else as is. */
export function tokenToCss(token: string): string {
if (token.startsWith("--")) return `var(${token})`;
if (/^[a-z][a-z0-9-]*$/.test(token) && token !== "transparent" && token !== "currentcolor") {
return `var(--color-${token})`;
}
return token;
}
let scratch: CanvasRenderingContext2D | null | undefined;
/**
* Resolves a colour token, as seen from `element`, to linear 0–1 RGB for a
* uniform. The browser resolves the token (so a scoped theme, nested var() and
* color-mix() all work) and a 1×1 2D canvas converts whatever colour space it
* came back in — oklch included — to sRGB bytes.
*/
export function resolveRgb(
element: Element,
token: string,
fallback: Rgb = [0.5, 0.5, 0.5],
): Rgb {
const doc = element.ownerDocument;
const view = doc.defaultView;
if (!view) return fallback;
const probe = doc.createElement("span");
probe.style.display = "none";
probe.style.color = tokenToCss(token);
(element.parentElement ?? doc.body).appendChild(probe);
const computed = view.getComputedStyle(probe).color;
probe.remove();
if (!computed) return fallback;
if (scratch === undefined) {
try {
const canvas = doc.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
scratch = canvas.getContext("2d", { willReadFrequently: true });
} catch {
scratch = null;
}
}
if (scratch) {
scratch.clearRect(0, 0, 1, 1);
scratch.fillStyle = computed;
scratch.fillRect(0, 0, 1, 1);
const [r = 0, g = 0, b = 0, a = 0] = scratch.getImageData(0, 0, 1, 1).data;
if (a > 0) return [r / 255, g / 255, b / 255];
}
// No 2D canvas: read the channels from an sRGB serialisation, if that is what came back.
if (computed.startsWith("rgb")) {
const [r, g, b] = (computed.match(/[\d.]+/g) ?? []).map(Number);
if (r !== undefined && g !== undefined && b !== undefined)
return [r / 255, g / 255, b / 255];
}
return fallback;
}
/** Test hook: forget the cached 2D context. */
export function resetColorCache() {
scratch = undefined;
}
/* Motion preferences ------------------------------------------------------ */
export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
/** The theme's `--motion-scale` (1 when unset). 0 means motion is off. */
export function motionScale(element?: Element): number {
if (typeof window === "undefined") return 1;
const raw = window
.getComputedStyle(element ?? document.documentElement)
.getPropertyValue("--motion-scale");
const value = Number.parseFloat(raw);
return Number.isFinite(value) ? Math.max(0, value) : 1;
}
/** OS reduced-motion preference, or the theme's scale turned (nearly) to zero. */
export function prefersReducedMotion(element?: Element): boolean {
if (typeof window === "undefined") return false;
if (
typeof window.matchMedia === "function" &&
window.matchMedia(REDUCED_MOTION_QUERY).matches
) {
return true;
}
return motionScale(element) < 0.01;
}
/** Whether a WebGL context can be created here at all. Cached per page. */
let webglSupport: boolean | undefined;
export function supportsWebGL(): boolean {
if (webglSupport !== undefined) return webglSupport;
if (typeof document === "undefined") return false;
try {
webglSupport = typeof WebGLRenderingContext !== "undefined";
} catch {
webglSupport = false;
}
return webglSupport;
}
/** Records that creating a context failed, so later runs skip straight to the fallback. */
export function markWebGLUnavailable() {
webglSupport = false;
}
/** Test hook: forget the cached support check. */
export function resetWebGLSupport() {
webglSupport = undefined;
}
// Ported from SmoothUI "Aperture Blur", "Chroma Blur" and "Prism Sweep" transitions (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
/*
* The preset table. A preset is data: GLSL defining two functions, and the
* uniforms the shared program reads.
*
* float field(vec2 uv, vec2 p, float t) when this pixel is covered, 0 → 1
* float sheen(vec2 uv, vec2 p, float t, float d) secondary-accent tint of the cover;
* d is the signed distance to the front
*
* `uv` is 0–1 across the frame (x mirrored in RTL), `p` is centred and
* aspect-corrected (x spans ±aspect/2, y ±1/2), `t` is seconds. Every preset
* links against one vertex shader and one prelude (hash21, vnoise, fbm, smin,
* rmax, tri) in shader-transition-engine.ts.
*
* Provenance. Only three presets port SmoothUI shader code — aperture-blur,
* chroma-blur and prism-sweep, SmoothUI's own designs — re-expressed as a field
* and a sheen, with its literal palette replaced by theme tokens. SmoothUI's
* other thirteen transitions come from Codrops material, whose licence forbids
* redistribution: radial-circles, sdf-circle, warped-circle and organic-merge
* are stages of the Codrops SDF shader progression (SmoothUI's docs say so),
* sdf-blob is the engine that hosts those stages, and the eight shader-reveal
* variants are SmoothUI's adaptations of Yuri Artiukh's "Demo 1–8" WebGL image
* transitions published on Codrops (SmoothUI's changelog calls them the "Akella
* transitions"). For those thirteen the shaders below are original, written
* from each effect's description without reading the source shader, and each is
* marked "original shader (effect inspired by SmoothUI <name>; no code
* referenced)".
*/
export interface ShaderTransitionPreset {
/** The SmoothUI component this preset reproduces. */
source: string;
/** Whether the shader is ported from SmoothUI or written from scratch. */
origin: "ported" | "original";
/** One line for a gallery caption. */
description: string;
/** Default run length in ms at `--motion-scale: 1`. */
duration: number;
/** Width of the front in field units (0–1). */
softness: number;
/** Strength of the primary-accent glow on the front. */
glow: number;
/** Strength of the preset's secondary-accent sheen on the cover. */
sheen: number;
/** Uncover along the reversed field (the cover retreats) instead of continuing. */
reverse: boolean;
/** Defines `field` and `sheen`. */
glsl: string;
}
/** Max over the frame's four corners of a centred-space shape function. */
const corners = (fn: string) =>
`vec2 hh = vec2(uRes.x / uRes.y, 1.0) * 0.5; float far = max(max(${fn}(hh), ${fn}(-hh)), max(${fn}(vec2(hh.x, -hh.y)), ${fn}(vec2(-hh.x, hh.y))));`;
export const SHADER_TRANSITION_PRESETS = {
/* SmoothUI's own designs, ported --------------------------------------- */
/** Ported: SmoothUI Aperture Blur — a ring opening from the centre, angular ripple, grain. */
"aperture-blur": {
source: "ApertureBlurTransition",
origin: "ported",
description: "An aperture opens from the centre, its rim rippling and glowing.",
duration: 900,
softness: 0.1,
glow: 1,
sheen: 0.55,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
float a = atan(p.y, p.x);
float n = sin(a * 8.0 + t * 0.9) * 0.018 + sin(a * 17.0 - t * 0.6) * 0.006;
return (length(p) - n) / (rmax() + 0.02);
}
float sheen(vec2 uv, vec2 p, float t, float d){
float swirl = smoothstep(-0.4, 0.4, sin(atan(p.y, p.x) * 2.0 + t * 0.7));
float grain = hash21(floor(uv * uRes / 3.0) + floor(t * 18.0)) * 0.3;
return swirl * exp(-d * d * 18.0) + grain * exp(-d * d * 40.0);
}`,
},
/** Ported: SmoothUI Chroma Blur — a diagonal curtain with rippling edge and chromatic fringes. */
"chroma-blur": {
source: "ChromaBlurTransition",
origin: "ported",
description: "A soft diagonal curtain with a rippling edge and chromatic fringes.",
duration: 1040,
softness: 0.16,
glow: 0.45,
sheen: 0.7,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
float n = vnoise(uv * 10.0 + vec2(t * 0.28, -t * 0.18));
float wave = sin((uv.y + n * 0.12) * 28.0 + t * 2.2) * 0.018;
return (uv.x * 0.62 + uv.y * 0.38 + wave + 0.02) / 1.04;
}
float sheen(vec2 uv, vec2 p, float t, float d){
float a = d + 0.045;
float b = d - 0.045;
return exp(-a * a * 52.0) + exp(-b * b * 52.0) + vnoise(uv * 34.0 + t * 0.18) * 0.1;
}`,
},
/** Ported: SmoothUI Prism Sweep — a faceted glass band sweeping across, caustics behind the crest. */
"prism-sweep": {
source: "PrismSweepTransition",
origin: "ported",
description: "A faceted prism band sweeps across with caustics behind its crest.",
duration: 1240,
softness: 0.08,
glow: 0.9,
sheen: 0.9,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
float c = t * 0.42;
float drift = sin(uv.y * 7.0 + c * 1.8) * 0.024 + sin(uv.y * 17.0 - c * 1.1 + 1.6) * 0.010
+ sin((uv.x + uv.y) * 11.0 + c * 0.7) * 0.006;
return (uv.x + drift * 0.76 + 0.03) / 1.06;
}
float sheen(vec2 uv, vec2 p, float t, float d){
float c = t * 0.42;
float facets = smoothstep(0.18, 0.92, tri(uv.y * 12.0 + uv.x * 2.1 + c * 0.35));
float diagonal = tri((uv.y - uv.x * 0.36) * 5.3 - c * 0.2);
float caustic = pow(1.0 - diagonal, 4.0) * 0.42 + pow(facets, 2.2) * 0.22;
float ridge = exp(-(d + 0.05) * (d + 0.05) * 600.0);
return (caustic + ridge * 0.5) * exp(-d * d * 8.0);
}`,
},
/* SDF family: original shaders ----------------------------------------- */
/** original shader (effect inspired by SmoothUI SdfBlobTransition; no code referenced) */
"sdf-blob": {
source: "SdfBlobTransition",
origin: "original",
description: "Three soft blobs swell from different points and melt into one.",
duration: 1120,
softness: 0.06,
glow: 0.7,
sheen: 0.4,
reverse: false,
glsl: `
float blobs(vec2 p){
float a = length(p - vec2(-0.32, 0.18));
float b = length(p - vec2(0.28, -0.16));
float c = length(p - vec2(0.06, 0.34));
return smin(smin(a, b, 0.22), c, 0.22);
}
float field(vec2 uv, vec2 p, float t){
${corners("blobs")}
return (blobs(p) + (fbm(p * 3.0 + t * 0.2) - 0.5) * 0.12) / far;
}
float sheen(vec2 uv, vec2 p, float t, float d){
return exp(-d * d * 30.0) * fbm(p * 6.0 - t * 0.3);
}`,
},
/** original shader (effect inspired by SmoothUI OrganicMergeTransition; no code referenced) */
"organic-merge": {
source: "OrganicMergeTransition",
origin: "original",
description: "Two organic shapes grow from opposite corners and fuse where they meet.",
duration: 1120,
softness: 0.06,
glow: 0.65,
sheen: 0.6,
reverse: false,
glsl: `
float pair(vec2 p){
vec2 hh = vec2(uRes.x / uRes.y, 1.0) * 0.5;
float a = length(p + hh * 0.55);
float b = length((p - hh * 0.55) * vec2(0.8, 1.25));
return smin(a, b, 0.3);
}
float field(vec2 uv, vec2 p, float t){
${corners("pair")}
return (pair(p) + (vnoise(p * 4.0 + t * 0.3) - 0.5) * 0.08) / far;
}
float sheen(vec2 uv, vec2 p, float t, float d){
vec2 hh = vec2(uRes.x / uRes.y, 1.0) * 0.5;
float seam = abs(length(p + hh * 0.55) - length((p - hh * 0.55) * vec2(0.8, 1.25)));
return exp(-seam * 8.0) * exp(-d * d * 20.0);
}`,
},
/** original shader (effect inspired by SmoothUI SdfCircleTransition; no code referenced) */
"sdf-circle": {
source: "SdfCircleTransition",
origin: "original",
description: "A clean circle grows from the centre with a crisp edge.",
duration: 1000,
softness: 0.025,
glow: 0.55,
sheen: 0,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){ return length(p) / rmax(); }
float sheen(vec2 uv, vec2 p, float t, float d){ return 0.0; }`,
},
/** original shader (effect inspired by SmoothUI WarpedCircleTransition; no code referenced) */
"warped-circle": {
source: "WarpedCircleTransition",
origin: "original",
description: "A circle whose perimeter waves as it grows.",
duration: 1120,
softness: 0.04,
glow: 0.7,
sheen: 0.35,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
float a = atan(p.y, p.x);
float warp = sin(a * 6.0 + t * 1.6) * 0.035 + sin(a * 11.0 - t * 2.1) * 0.015;
return (length(p) + warp) / (rmax() + 0.05) + 0.02;
}
float sheen(vec2 uv, vec2 p, float t, float d){
return (0.5 + 0.5 * sin(atan(p.y, p.x) * 6.0 + t * 1.6)) * exp(-d * d * 24.0);
}`,
},
/** original shader (effect inspired by SmoothUI RadialCirclesTransition; no code referenced) */
"radial-circles": {
source: "RadialCirclesTransition",
origin: "original",
description: "A grid of dots swells into circles, radiating out from the centre.",
duration: 1120,
softness: 0.03,
glow: 0.3,
sheen: 0.5,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
float s = 0.11;
vec2 ctr = (floor(p / s) + 0.5) * s;
return length(ctr) / rmax() * 0.62 + length(p - ctr) / (s * 0.7072) * 0.38;
}
float sheen(vec2 uv, vec2 p, float t, float d){
vec2 cell = floor(p / 0.11);
return step(0.5, fract((cell.x + cell.y) * 0.5)) * exp(-d * d * 25.0);
}`,
},
/* Shader-reveal family: original shaders ------------------------------- */
/** original shader (effect inspired by SmoothUI ShaderRevealNoiseTransition; no code referenced) */
noise: {
source: "ShaderRevealNoiseTransition",
origin: "original",
description: "A turbulent noise threshold: the cover arrives in organic patches.",
duration: 1080,
softness: 0.08,
glow: 0.55,
sheen: 0.45,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
return (fbm(p * 3.2 + vec2(t * 0.12, -t * 0.08)) - 0.22) / 0.56;
}
float sheen(vec2 uv, vec2 p, float t, float d){
return fbm(p * 9.0 + t * 0.4) * exp(-d * d * 30.0);
}`,
},
/** original shader (effect inspired by SmoothUI ShaderRevealZoomTransition; no code referenced) */
zoom: {
source: "ShaderRevealZoomTransition",
origin: "original",
description: "A lens swells from the centre, then the frame zooms back through it.",
duration: 1080,
softness: 0.07,
glow: 0.6,
sheen: 0.5,
reverse: true,
glsl: `
float lens(vec2 p){ return length((p - vec2(0.0, -0.08)) * vec2(1.0, 1.25)); }
float field(vec2 uv, vec2 p, float t){
${corners("lens")}
return lens(p) / far;
}
float sheen(vec2 uv, vec2 p, float t, float d){
return (0.5 + 0.5 * sin(length(p) * 48.0 - t * 3.0)) * exp(-d * d * 14.0);
}`,
},
/** original shader (effect inspired by SmoothUI ShaderRevealCircleTransition; no code referenced) */
circle: {
source: "ShaderRevealCircleTransition",
origin: "original",
description: "A circle with a noisy, smoky rim spreads from the centre.",
duration: 1080,
softness: 0.09,
glow: 0.6,
sheen: 0.4,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
return (length(p) + (fbm(p * 4.0 + t * 0.25) - 0.5) * 0.22) / (rmax() + 0.06) + 0.03;
}
float sheen(vec2 uv, vec2 p, float t, float d){
return fbm(p * 7.0 - t * 0.3) * exp(-d * d * 22.0);
}`,
},
/** original shader (effect inspired by SmoothUI ShaderRevealWipeTransition; no code referenced) */
wipe: {
source: "ShaderRevealWipeTransition",
origin: "original",
description: "A narrow, noise-displaced blade wipes across toward the inline end.",
duration: 1000,
softness: 0.025,
glow: 1,
sheen: 0.3,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
return uv.x * 0.9 + (fbm(vec2(uv.y * 6.0, t * 0.35)) - 0.5) * 0.1 + 0.05;
}
float sheen(vec2 uv, vec2 p, float t, float d){
return vnoise(vec2(uv.y * 40.0, t)) * exp(-d * d * 60.0);
}`,
},
/** original shader (effect inspired by SmoothUI ShaderRevealLumaTransition; no code referenced) */
luma: {
source: "ShaderRevealLumaTransition",
origin: "original",
description: "Horizontal ribbons pour down from the top, each on its own delay.",
duration: 1080,
softness: 0.05,
glow: 0.4,
sheen: 0.45,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
float row = floor(uv.y * 12.0);
return hash21(vec2(row, 7.0)) * 0.4 + (1.0 - uv.y) * 0.35 + (1.0 - fract(uv.y * 12.0)) * 0.25;
}
float sheen(vec2 uv, vec2 p, float t, float d){
return mod(floor(uv.y * 12.0), 2.0) * exp(-d * d * 12.0);
}`,
},
/** original shader (effect inspired by SmoothUI ShaderRevealPlanetaryTransition; no code referenced) */
planetary: {
source: "ShaderRevealPlanetaryTransition",
origin: "original",
description: "Spiral arms wind outward from the centre like a vortex.",
duration: 1180,
softness: 0.06,
glow: 0.6,
sheen: 0.5,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
float r = length(p) / rmax();
float arm = fract((atan(p.y, p.x) / TAU + 0.5) * 2.0 + r * 1.1 - t * 0.04);
return r * 0.55 + arm * 0.45;
}
float sheen(vec2 uv, vec2 p, float t, float d){
return (0.5 + 0.5 * sin(atan(p.y, p.x) * 4.0 + length(p) * 18.0 - t * 2.0)) * exp(-d * d * 18.0);
}`,
},
/** original shader (effect inspired by SmoothUI ShaderRevealStripesTransition; no code referenced) */
stripes: {
source: "ShaderRevealStripesTransition",
origin: "original",
description: "Slanted bars of random width snap shut like a barcode shutter.",
duration: 1000,
softness: 0.015,
glow: 0.35,
sheen: 0.4,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
float s = (uv.x + uv.y * 0.35) / 1.35;
return s * 0.55 + hash21(vec2(floor(s * 26.0), 3.1)) * 0.45;
}
float sheen(vec2 uv, vec2 p, float t, float d){
float s = (uv.x + uv.y * 0.35) / 1.35;
return step(0.5, hash21(vec2(floor(s * 26.0), 9.7))) * exp(-d * d * 40.0);
}`,
},
/** original shader (effect inspired by SmoothUI ShaderRevealPushTransition; no code referenced) */
push: {
source: "ShaderRevealPushTransition",
origin: "original",
description: "A noisy front pushes up from the bottom, pulled along by flowing noise.",
duration: 1080,
softness: 0.07,
glow: 0.55,
sheen: 0.45,
reverse: false,
glsl: `
float field(vec2 uv, vec2 p, float t){
float n = fbm(vec2(uv.x * 3.0, uv.y * 2.0 - t * 0.5));
return uv.y * 0.8 + (n - 0.5) * 0.36 + 0.1;
}
float sheen(vec2 uv, vec2 p, float t, float d){
return fbm(vec2(uv.x * 8.0, uv.y * 4.0 - t * 0.8)) * exp(-d * d * 20.0);
}`,
},
} satisfies Record<string, ShaderTransitionPreset>;
export type ShaderTransitionPresetName = keyof typeof SHADER_TRANSITION_PRESETS;
export const SHADER_TRANSITION_PRESET_NAMES = Object.keys(
SHADER_TRANSITION_PRESETS,
) as ShaderTransitionPresetName[];
/** The preset for a name; unknown names fall back to `noise` (and warn in development). */
export function getShaderTransitionPreset(name: string): ShaderTransitionPreset {
const table: Record<string, ShaderTransitionPreset> = SHADER_TRANSITION_PRESETS;
const preset = table[name];
if (preset) return preset;
if (process.env.NODE_ENV !== "production") {
console.warn(`[ShaderTransition] unknown preset "${name}"; using "noise".`);
}
return SHADER_TRANSITION_PRESETS.noise;
}