CubeLoader
PreviewCode
// preview
Color from
#dcdce4
Color mid
#f2f2f6
Color to
#ffffff
// install
pnpmnpmyarnbun
npx shadcn@latest add "https://designpass.dev/r/CubeLoader-TS-TW.json"Install the CubeLoader component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/CubeLoader-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.// source
tsjs
twcss
/*!
* CubeLoader, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/cube-loader
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useRef, type CSSProperties } from "react";
export interface CubeLoaderProps {
/** Overall footprint of the loader (px). */
size?: number;
/** Seconds per turn, corner to corner. 0 disables rotation. */
speed?: number;
/** Corner/edge rounding as a fraction of the cube's half-extent (0-1). */
rounding?: number;
/** How bouncy the cube is (0-1.5). 0 disables the bounce; higher values hop
* taller with more jelly-like squash on landing and stretch in flight. */
bounce?: number;
/** Shadow tone for surfaces turned away from the light (off-white). */
colorFrom?: string;
/** Base tone for mid-lit surfaces (off-white). */
colorMid?: string;
/** Highlight tone for surfaces facing the light (white). */
colorTo?: string;
className?: string;
style?: CSSProperties;
}
const VERT = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
vUv = aPos;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
// Raymarched rounded-box SDF: the one shape CSS can't fake. Orthographic rays,
// the cube's orientation fed in as one inverse rotation matrix, and a single
// key light grading the surface between three tones.
const FRAG = `
precision highp float;
varying vec2 vUv;
uniform mat3 uInv;
uniform vec3 uFrom;
uniform vec3 uMid;
uniform vec3 uTo;
uniform float uRound;
uniform float uPixel;
uniform float uLift;
uniform float uSquash;
// Rounded-box signed distance: exact, so edges and corners are true arcs.
float sdBox(vec3 p, vec3 b, float r) {
vec3 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r;
}
float map(vec3 p) {
// Bounce: lift the cube, then squash/stretch about a floor-level anchor so
// landings compress downward and flight stretches upward, volume-preserving
// (y scales by uSquash, xz by 1/sqrt(uSquash)).
p.y -= uLift;
float ay = -0.95;
p.y = ay + (p.y - ay) / uSquash;
p.xz *= sqrt(uSquash);
float half_ = 0.72;
float d = sdBox(uInv * p, vec3(half_), half_ * uRound);
// Non-uniform scaling bends the distance metric; shrink steps to stay safe.
return d * min(uSquash, inversesqrt(uSquash));
}
vec3 normalAt(vec3 p) {
vec2 e = vec2(0.0015, 0.0);
return normalize(vec3(
map(p + e.xyy) - map(p - e.xyy),
map(p + e.yxy) - map(p - e.yxy),
map(p + e.yyx) - map(p - e.yyx)
));
}
void main() {
// Orthographic camera looking down +z; the cube fits its corner-spin sweep.
vec3 ro = vec3(vUv * 1.62, -3.0);
vec3 rd = vec3(0.0, 0.0, 1.0);
float t = 0.0;
float closest = 1e9;
bool hit = false;
for (int i = 0; i < 80; i++) {
vec3 p = ro + rd * t;
float d = map(p);
closest = min(closest, d);
if (d < 0.001) { hit = true; break; }
t += d;
if (t > 6.0) break;
}
// Edge anti-aliasing from the ray's closest approach to the surface.
float alpha = hit ? 1.0 : 1.0 - smoothstep(0.0, uPixel * 1.5, closest);
if (alpha <= 0.0) { discard; }
vec3 color = uMid;
if (hit) {
vec3 n = normalAt(ro + rd * t);
// Fixed key light in view space, so the light stays put while the cube
// turns beneath it.
vec3 l = normalize(vec3(-0.4, 0.85, -0.5));
float lit = 0.5 + 0.5 * dot(n, l);
color = lit <= 0.5 ? mix(uFrom, uMid, lit * 2.0) : mix(uMid, uTo, (lit - 0.5) * 2.0);
}
gl_FragColor = vec4(color * alpha, alpha);
}`;
type Vec3 = [number, number, number];
/** Row-major 3x3 rotation matrix. */
type Mat3 = number[];
// The whole trick: spin about a fixed axis that leans toward the viewer by
// the corner angle. A cube corner sits 54.736 degrees from its face axis
// (atan(sqrt(2))), so if the spin axis (the cube's x-axis) is held at that
// same angle from the view direction, the four corners around it ride a cone
// that passes exactly through the camera: every quarter turn, the corner
// crossing the front points straight at the viewer. On screen the axis still
// projects as a vertical line (it leans in depth only).
const SPIN_AXIS: Vec3 = [0, Math.sqrt(2 / 3), -Math.sqrt(1 / 3)];
function rotAxis(axis: Vec3, angle: number): Mat3 {
const [x, y, z] = axis;
const c = Math.cos(angle);
const s = Math.sin(angle);
const t = 1 - c;
return [
t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c,
];
}
function mulMat3(a: Mat3, b: Mat3): Mat3 {
const out = new Array<number>(9);
for (let r = 0; r < 3; r += 1) {
for (let col = 0; col < 3; col += 1) {
out[r * 3 + col] =
a[r * 3] * b[col] + a[r * 3 + 1] * b[3 + col] + a[r * 3 + 2] * b[6 + col];
}
}
return out;
}
// Base alignment: rotate the cube so its x-axis lies on the spin axis and the
// corner (1,1,1) points at the camera (0,0,-1). Built from matched orthonormal
// frames: A maps model frame E onto world frame F, A = F * E^T.
const ALIGN: Mat3 = (() => {
const s = Math.SQRT1_2;
// Model frame: x-axis, then the corner direction orthonormalized against it.
const E: Vec3[] = [
[1, 0, 0],
[0, s, s],
[0, -s, s],
];
// World frame: spin axis, then the view direction orthonormalized against it.
const q = Math.sqrt(1 / 3);
const w = Math.sqrt(2 / 3);
const F: Vec3[] = [
SPIN_AXIS,
[0, -q, -w],
[-1, 0, 0],
];
const out = new Array<number>(9);
for (let r = 0; r < 3; r += 1) {
for (let c = 0; c < 3; c += 1) {
out[r * 3 + c] = F[0][r] * E[0][c] + F[1][r] * E[1][c] + F[2][r] * E[2][c];
}
}
return out;
})();
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "");
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
const n = parseInt(h, 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
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);
return shader;
}
/**
* A solid rounded cube, edges and corners genuinely rounded, turning so that
* every corner that passes in front points straight at the viewer at the
* moment it crosses. The cube spins about a fixed axis leaning toward the
* camera by the corner angle (54.7 degrees), so the four corners around that
* axis sweep a cone passing exactly through the view direction; the cube turns
* at constant speed and every quarter-turn a corner crosses dead-on, while on
* screen the axis reads as a vertical line. Rendered by raymarching a rounded-box signed distance
* field in a tiny WebGL shader: orthographic projection, exact arc edges, and
* a fixed key light grading the near-white surface through three tones. An
* optional bounce hops the cube with volume-preserving squash and stretch:
* it absorbs each landing, crouches with anticipation before leaping, and
* stretches with speed in flight. It is also interactive: it grows slightly
* on hover, press-and-hold squishes it against the floor (flattening
* asymptotically, never fully flat), and releasing launches a jump whose
* height charges with the hold, then settles back into the ambient loop.
* Zero dependencies, honors prefers-reduced-motion (renders one static
* frame).
*/
export default function CubeLoader({
size = 48,
speed = 1,
rounding = 0.42,
bounce = 0.5,
colorFrom = "#dcdce4",
colorMid = "#f2f2f6",
colorTo = "#ffffff",
className = "",
style,
}: CubeLoaderProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl", { alpha: true, premultipliedAlpha: true });
if (!gl) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const pixels = Math.max(1, Math.round(size * dpr));
canvas.width = pixels;
canvas.height = pixels;
gl.viewport(0, 0, pixels, pixels);
const program = gl.createProgram();
const vert = compile(gl, gl.VERTEX_SHADER, VERT);
const frag = compile(gl, gl.FRAGMENT_SHADER, FRAG);
if (!program || !vert || !frag) return;
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;
gl.useProgram(program);
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, -1, 1, 1, -1, 1]),
gl.STATIC_DRAW,
);
const aPos = gl.getAttribLocation(program, "aPos");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.uniform3fv(gl.getUniformLocation(program, "uFrom"), hexToRgb(colorFrom));
gl.uniform3fv(gl.getUniformLocation(program, "uMid"), hexToRgb(colorMid));
gl.uniform3fv(gl.getUniformLocation(program, "uTo"), hexToRgb(colorTo));
gl.uniform1f(
gl.getUniformLocation(program, "uRound"),
Math.min(Math.max(rounding, 0.01), 1),
);
// One shader pixel in ray space: 2 * 1.62 units across `pixels` pixels.
gl.uniform1f(gl.getUniformLocation(program, "uPixel"), 3.24 / pixels);
const uInv = gl.getUniformLocation(program, "uInv");
const uLift = gl.getUniformLocation(program, "uLift");
const uSquash = gl.getUniformLocation(program, "uSquash");
gl.clearColor(0, 0, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
const draw = (angle: number, lift: number, squash: number) => {
// Model rotation M = spin about the leaning axis, applied to the base
// corner-forward alignment. The shader needs view -> model, i.e. M
// inverse; a rotation's inverse is its transpose, and uploading the
// row-major M without transposing hands GLSL exactly that.
const m = mulMat3(rotAxis(SPIN_AXIS, angle), ALIGN);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniformMatrix3fv(uInv, false, new Float32Array(m));
gl.uniform1f(uLift, lift);
gl.uniform1f(uSquash, squash);
gl.drawArrays(gl.TRIANGLES, 0, 6);
};
const bounciness = Math.min(Math.max(bounce, 0), 1.5);
const still =
window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
(speed <= 0 && bounciness <= 0);
if (still) {
draw(0, 0, 1);
return () => {
gl.deleteProgram(program);
gl.deleteBuffer(quad);
};
}
// The bounce keeps time with the rotation; when rotation is off, it runs
// on its own default clock.
const bouncePeriod = speed > 0 ? speed : 1.1;
const GROUND = 0.32;
const loopFloor = bounciness * -0.3;
const smooth = (t: number) => {
const s = Math.min(Math.max(t, 0), 1);
return s * s * (3 - 2 * s);
};
// Ambient bounce targets for a loop phase bf in [0,1). Each cycle splits
// into a ground phase and a flight phase. On the ground the cube absorbs
// the landing, settling ever deeper into the anticipation crouch (one
// continuous ease-out, no recover-pause); in the air it stretches with
// vertical speed, relaxing smoothly (cos^2) at the apex.
const loopTargets = (bf: number): [number, number] => {
if (bounciness <= 0) return [0, 1];
const crouch = 1 - 0.38 * bounciness;
const stretch = 1 + 0.18 * bounciness;
if (bf < GROUND) {
const u = bf / GROUND;
const settle = 1 - (1 - u) * (1 - u);
return [loopFloor, stretch + (crouch - stretch) * settle];
}
const v = (bf - GROUND) / (1 - GROUND);
const wave = Math.cos(Math.PI * v);
const base = 1 + 0.18 * bounciness * wave * wave;
return [
bounciness * (0.62 * Math.sin(Math.PI * v) - 0.3),
crouch + (base - crouch) * smooth(v / 0.14),
];
};
// Interaction state: pressing pins the cube to the floor and flattens it
// asymptotically; releasing launches a jump charged by the hold time.
let mode: "loop" | "press" | "launch" = "loop";
let loopOrigin = performance.now();
let pressStart = 0;
let releaseSquash = 1;
let launchStart = 0;
let launchDuration = 1;
let jumpPower = 0;
let currentLift = 0;
let currentSquash = 1;
const onPointerDown = (event: PointerEvent) => {
event.preventDefault();
mode = "press";
pressStart = performance.now();
};
const onPointerUp = () => {
if (mode !== "press") return;
const held = (performance.now() - pressStart) / 1000;
// Charge saturates with hold time, so long presses approach (but never
// exceed) the maximum jump.
jumpPower = 1 - Math.exp(-held / 0.9);
releaseSquash = currentSquash;
launchDuration = bouncePeriod * (1 - GROUND) * 1000 * (0.85 + 0.75 * jumpPower);
launchStart = performance.now();
mode = "launch";
};
const onPointerEnter = () => {
canvas.style.transform = "scale(1.06)";
};
const onPointerLeave = () => {
canvas.style.transform = "scale(1)";
};
// Long-press must squish, not select text or open a context menu.
const onContextMenu = (event: Event) => event.preventDefault();
canvas.style.transition = "transform 0.2s ease";
canvas.style.touchAction = "none";
canvas.style.userSelect = "none";
canvas.style.webkitUserSelect = "none";
canvas.addEventListener("contextmenu", onContextMenu);
canvas.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointerenter", onPointerEnter);
canvas.addEventListener("pointerleave", onPointerLeave);
let frame = 0;
const start = performance.now();
let lastNow = start;
const loop = (now: number) => {
// Constant angular velocity: one quarter-turn per cycle, and a corner
// points straight at the viewer exactly as it passes the front.
const angle = speed > 0 ? ((now - start) / (speed * 1000)) * (Math.PI / 2) : 0;
let targetLift = 0;
let targetSquash = 1;
if (mode === "press") {
// Pinned against the floor, flattening asymptotically toward (but
// never reaching) the minimum the longer the hold.
const held = (now - pressStart) / 1000;
targetLift = -0.35;
targetSquash = 0.45 + (0.82 - 0.45) * Math.exp(-held / 0.55);
} else if (mode === "launch") {
const v = (now - launchStart) / launchDuration;
if (v >= 1) {
// Land straight into the ambient loop's absorb phase.
mode = "loop";
loopOrigin = now;
[targetLift, targetSquash] = loopTargets(0);
} else {
const amp = 0.62 * (0.7 + 0.75 * jumpPower);
const wave = Math.cos(Math.PI * v);
// Floor eases from the press floor back to the loop floor while the
// jump arcs above it, so touchdown lines up with the ambient cycle.
targetLift = -0.35 + (loopFloor + 0.35) * v + amp * Math.sin(Math.PI * v);
// Stretch scales with jump power (independent of the ambient
// bounce, so a charged jump stretches even with bounce off).
const base = 1 + 0.18 * (1 + jumpPower) * wave * wave;
targetSquash = releaseSquash + (base - releaseSquash) * smooth(v / 0.14);
}
} else {
const bf = ((now - loopOrigin) / (bouncePeriod * 1000)) % 1;
[targetLift, targetSquash] = loopTargets(bf);
}
// Light exponential smoothing bridges every mode hand-off (press in,
// launch out, landing) without a visible snap.
const dt = Math.min((now - lastNow) / 1000, 0.1);
lastNow = now;
const blend = 1 - Math.exp(-dt / 0.05);
currentLift += (targetLift - currentLift) * blend;
currentSquash += (targetSquash - currentSquash) * blend;
draw(angle, currentLift, currentSquash);
frame = requestAnimationFrame(loop);
};
frame = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(frame);
canvas.removeEventListener("contextmenu", onContextMenu);
canvas.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointerenter", onPointerEnter);
canvas.removeEventListener("pointerleave", onPointerLeave);
gl.deleteProgram(program);
gl.deleteBuffer(quad);
};
}, [size, speed, rounding, bounce, colorFrom, colorMid, colorTo]);
return (
<span
role="status"
aria-label="Loading"
className={`inline-flex shrink-0 cursor-pointer select-none items-center justify-center ${className}`}
style={{ width: size, height: size, WebkitTouchCallout: "none", ...style }}
>
<canvas ref={canvasRef} aria-hidden="true" className="block h-full w-full" />
</span>
);
}
// props
Need the license details? Read the component license.