3D & Experimental
CubeMorph
One solid melts from cube to sphere to diamond to ring as it turns. A click, Enter or Space moves on at once. Clear background, so it sits on anything. WebGL, no three.js.
Download
- React
- Tailwind CSS v4
- motion
components/ui/cube-morph.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
type CubeMorphShape = "cube" | "sphere" | "diamond" | "ring";
type CubeMorphProps = {
/** Shapes in order. It loops back to the first. */
shapes?: CubeMorphShape[];
/** Seconds it rests on each shape. */
hold?: number;
/** Two six-digit hex colours. */
colors?: [string, string];
/** 1 is the default turning pace; 2 is twice as fast. */
speed?: number;
/** What the button says to screen readers. */
label?: string;
/** Set the size here. */
className?: string;
};
const ALL_SHAPES: CubeMorphShape[] = ["cube", "sphere", "diamond", "ring"];
const MAX_PIXELS = 900000;
const MORPH = 1.3;
const VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`;
const FRAGMENT = `precision highp float;
uniform vec2 res;
uniform vec4 mixer;
uniform mat3 turn;
uniform vec3 c1,c2;
const vec3 sun=vec3(.45,.68,.58);
float cube(vec3 p){vec3 q=abs(p)-.72;return length(max(q,0.))+min(max(q.x,max(q.y,q.z)),0.)-.11;}
float diamond(vec3 p){p=abs(p);return (p.x+p.y+p.z-1.42)*.577;}
float ring(vec3 p){return length(vec2(length(p.xz)-.74,p.y))-.3;}
float map(vec3 p){
p=turn*p;
return mixer.x*cube(p)+mixer.y*(length(p)-1.05)+mixer.z*diamond(p)+mixer.w*ring(p);
}
vec3 normal(vec3 p){
vec2 e=vec2(.004,-.004);
return normalize(e.xyy*map(p+e.xyy)+e.yyx*map(p+e.yyx)+e.yxy*map(p+e.yxy)+e.xxx*map(p+e.xxx));
}
void main(){
float side=min(res.x,res.y);
vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*side);
vec3 o=vec3(0.,0.,4.),d=normalize(vec3(uv,-2.45));
float t=2.1,best=1.,bestT=t;
for(int i=0;i<64;i++){
float m=map(o+d*t);
if(m<best){best=m;bestT=t;}
if(m<.0002||t>6.)break;
t+=m*.9;
}
if(best<.0002)bestT=t;
float a=1.-smoothstep(0.,3.5/side,best);
if(a<=0.){gl_FragColor=vec4(0.);return;}
vec3 p=o+d*bestT,n=normal(p),r=reflect(d,n);
float f=pow(1.+dot(d,n),3.);
vec3 col=mix(c1,c2,clamp(.34+.55*n.y+.25*n.x,0.,1.))*(.2+.9*max(dot(n,sun),0.));
col+=mix(c2,c1,.5+.5*n.x)*f*1.1;
col+=pow(max(dot(r,sun),0.),42.)*.9;
gl_FragColor=vec4(col*a,a);
}`;
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
function program(gl: WebGLRenderingContext) {
const made = gl.createProgram();
for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!made || !shader) return null;
gl.shaderSource(shader, text);
gl.compileShader(shader);
gl.attachShader(made, shader);
}
if (!made) return null;
gl.linkProgram(made);
return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null;
}
/* CubeMorph: one solid melts from shape to shape as it turns. A click, Enter or Space moves on to the next shape at once. Runs only while on screen. */
export function CubeMorph({
shapes = ALL_SHAPES,
hold = 2,
colors = ["#7c3aed", "#22d3ee"],
speed = 1,
label = "Change the shape",
className = "aspect-square w-72",
}: CubeMorphProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLButtonElement>(null);
const plainRef = useRef<HTMLSpanElement>(null);
const order = shapes.join();
const hues = colors.join();
useEffect(() => {
const box = boxRef.current;
const plain = plainRef.current;
if (!box || !plain) return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
const gl = canvas.getContext("webgl", { alpha: true, antialias: false });
const made = gl && program(gl);
if (!gl || !made) return;
gl.useProgram(made);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const corner = gl.getAttribLocation(made, "p");
gl.enableVertexAttribArray(corner);
gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0);
const at = (name: string) => gl.getUniformLocation(made, name);
const [first, second] = hues.split(",").map(rgb);
gl.uniform3fv(at("c1"), first);
gl.uniform3fv(at("c2"), second);
const list = order.split(",").map((name) => ALL_SHAPES.indexOf(name as CubeMorphShape));
let raf = 0;
let last = 0;
let inView = false;
let from = 0;
let to = 0;
let blend = 1;
let waited = 0;
let kick = 0;
let angleX = 0.62;
let angleY = 0.72;
const draw = () => {
const e = blend * blend * (3 - 2 * blend);
gl.uniform4fv(at("mixer"), [0, 1, 2, 3].map((i) => (list[from] === i ? 1 - e : 0) + (list[to] === i ? e : 0)));
const c = Math.cos(angleY), s = Math.sin(angleY), ct = Math.cos(angleX), st = Math.sin(angleX);
gl.uniformMatrix3fv(at("turn"), false, [c, 0, s, st * s, ct, -st * c, -ct * s, st, ct * c]);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const next = () => {
if (blend < 1 || list.length < 2) return;
to = (from + 1) % list.length;
blend = 0;
};
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000 || 0);
last = now;
if (blend < 1) {
blend = Math.min(1, blend + dt / MORPH);
if (blend === 1) {
from = to;
waited = 0;
}
} else if ((waited += dt) >= hold) {
next();
}
kick *= 0.95;
angleY += dt * (0.5 + kick) * speed;
angleX += dt * (0.22 + kick * 0.4) * speed;
draw();
raf = inView ? requestAnimationFrame(frame) : 0;
};
const wake = () => {
if (inView && !raf && !reduce) raf = requestAnimationFrame(frame);
};
const fit = () => {
const { width, height } = box.getBoundingClientRect();
if (!width || !height) return;
const scale = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (width * height)));
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(at("res"), canvas.width, canvas.height);
draw();
};
const click = () => {
if (reduce) {
if (list.length > 1) from = to = (from + 1) % list.length;
draw();
return;
}
kick = 3;
next();
};
fit();
box.append(canvas);
plain.hidden = true;
const sized = new ResizeObserver(fit);
sized.observe(box);
const watch = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
wake();
});
watch.observe(box);
const lost = () => {
canvas.remove();
plain.hidden = false;
};
canvas.addEventListener("webglcontextlost", lost);
box.addEventListener("click", click);
return () => {
cancelAnimationFrame(raf);
sized.disconnect();
watch.disconnect();
canvas.removeEventListener("webglcontextlost", lost);
box.removeEventListener("click", click);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
plain.hidden = false;
};
}, [order, hues, hold, speed, reduce]);
return (
<button ref={boxRef} type="button" aria-label={label} className={`relative block cursor-pointer rounded-2xl ${className}`}>
<span
ref={plainRef}
className="absolute left-1/4 top-1/4 size-1/2 rounded-2xl"
style={{ background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})` }}
/>
</button>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/cube-morph.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/cube-morph-demo.tsx
import { CubeMorph } from "@/components/ui/cube-morph"; export default function CubeMorphExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-3"> <CubeMorph className="aspect-square h-full max-h-[360px]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| shapes | ("cube" | "sphere" | "diamond" | "ring")[] | all four | Shapes in order. It loops back to the first. |
| hold | number | 2 | Seconds it rests on each shape. |
| colors | [string, string] | ["#7c3aed", "#22d3ee"] | Six-digit hex. |
| speed | number | 1 | 1 is the default turning pace; 2 is twice as fast. |
| label | string | "Change the shape" | What the button says to screen readers. |
| className | string | "aspect-square w-72" | Set the size here. |