3D Cube
Build a Rubik cube in Tailwind v4, one face at a time, then drag it.

Faisal Husain
Published
Drag to turn
Tailwind v4 finally has 3D. perspective-*, rotate-x-*, rotate-y-*, translate-z-*, transform-3d, backface-hidden. No more [transform:rotateY(90deg)] for the cube itself.
We will build it in four steps. Full source is at the end.
Step 1 : Give the scene a camera
A square with rotate-x is still flat unless the parent has perspective. That is the camera. Put perspective-[900px] on the stage, then tilt the square.
<div className="flex h-[420px] items-center justify-center perspective-[900px]">
<div className="size-40 rotate-x-12 -rotate-y-16 bg-red-500" />
</div>If you drop the perspective class, the tilt dies. You are looking at a 2D rectangle again.
Step 2 : Push a face into space
A cube face does not sit in the middle. It sits half the cube away from the center. translate-z-[80px] on a 160px face.
The parent must keep 3D children. That class is transform-3d.
<div className="relative size-40 transform-3d">
<div className="absolute inset-0 translate-z-[80px] bg-red-500" />
</div>One face in space. Five to go.
Step 3 : Six faces
Do not put rotate-y-90 and translate-z on the same node. Tailwind v4 applies translate first, then rotate. The face moves on Z, then spins in place, so all six faces cross in the middle like a plus sign.
Rotate a wrapper. Then push the child out on Z.
<div className="relative size-40 rotate-x-[-18deg] rotate-y-[32deg] transform-3d">
<div className="absolute inset-0 rotate-y-90 transform-3d">
<div className="absolute inset-0 translate-z-20 bg-blue-500" />
</div>
</div>translate-z-20 is 80px. That is half of size-40. The number has to match the face, or the cube opens or collapses.
Opposite colors: red / orange, blue / green, white / yellow.
Step 4 : Plastic and stickers
A Rubik face is not one color. It is black plastic and nine stickers. Grid, gap, a little inset light.
<div className="absolute inset-0 rotate-y-90 transform-3d">
<div className="absolute inset-0 grid grid-cols-3 gap-1 bg-[#111214] p-1.5 translate-z-[104px] backface-hidden">
{Array.from({ length: 9 }).map((_, index) => (
<div
key={index}
className="rounded-[3px] shadow-[inset_0_1px_0_rgb(255_255_255/0.22)]"
style={{
background:
"linear-gradient(145deg, rgb(255 255 255 / 0.22), transparent 42%, rgb(0 0 0 / 0.18)), #c41e3a",
}}
/>
))}
</div>
</div>backface-hidden so you do not see the sticker backs through the cube.
Step 5 : Hold it
The faces stay in Tailwind. Only the outer turn is JavaScript, because drag is a live rotateX / rotateY. Idle spin writes the same properties. Reduced motion leaves it still.
That is the cube at the top.
Full code
"use client";
import { useEffect, useRef, useState } from "react";
const faces = [
{ name: "front", sticker: "#c41e3a", rotate: "" },
{ name: "back", sticker: "#ff6a00", rotate: "rotate-y-180" },
{ name: "right", sticker: "#0051ba", rotate: "rotate-y-90" },
{ name: "left", sticker: "#009e60", rotate: "-rotate-y-90" },
{ name: "top", sticker: "#f4f4f5", rotate: "rotate-x-90" },
{ name: "bottom", sticker: "#ffd400", rotate: "-rotate-x-90" },
] as const;
const Final3DCube = () => {
const stageRef = useRef<HTMLDivElement>(null);
const rotation = useRef({ x: -18, y: 32 });
const drag = useRef({
pointerId: -1,
x: 0,
y: 0,
startX: -18,
startY: 32,
});
const [dragging, setDragging] = useState(false);
const [reduceMotion, setReduceMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => setReduceMotion(media.matches);
sync();
media.addEventListener("change", sync);
return () => media.removeEventListener("change", sync);
}, []);
useEffect(() => {
const node = stageRef.current;
if (!node) return;
let frame = 0;
let last = performance.now();
const apply = () => {
node.style.transform = `rotateX(${rotation.current.x}deg) rotateY(${rotation.current.y}deg)`;
};
apply();
if (dragging || reduceMotion) return;
const tick = (now: number) => {
const delta = Math.min(32, now - last);
last = now;
rotation.current.y += delta * 0.012;
apply();
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [dragging, reduceMotion]);
function startDrag(event: React.PointerEvent<HTMLDivElement>) {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
startX: rotation.current.x,
startY: rotation.current.y,
};
setDragging(true);
}
function moveDrag(event: React.PointerEvent<HTMLDivElement>) {
if (drag.current.pointerId !== event.pointerId) return;
rotation.current.x = drag.current.startX - (event.clientY - drag.current.y) * 0.42;
rotation.current.y = drag.current.startY + (event.clientX - drag.current.x) * 0.42;
if (stageRef.current) {
stageRef.current.style.transform = `rotateX(${rotation.current.x}deg) rotateY(${rotation.current.y}deg)`;
}
}
function stopDrag(event: React.PointerEvent<HTMLDivElement>) {
if (drag.current.pointerId !== event.pointerId) return;
drag.current.pointerId = -1;
setDragging(false);
}
return (
<div className="not-prose relative my-6 overflow-hidden rounded-2xl border border-zinc-800 bg-[#09090b]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_42%,rgb(255_255_255/0.05),transparent_58%)] opacity-70" />
<div className="relative flex h-[500px] w-full items-center justify-center perspective-[900px]">
<div
className={`relative size-52 transform-3d ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={stopDrag}
onPointerCancel={stopDrag}
>
<div
ref={stageRef}
className="absolute inset-0 transform-3d"
style={{ transform: "rotateX(-18deg) rotateY(32deg)" }}
>
{faces.map((face) => (
<div
key={face.name}
className={`absolute inset-0 transform-3d ${face.rotate}`}
>
<div className="absolute inset-0 grid grid-cols-3 gap-1 bg-[#111214] p-1.5 translate-z-[104px] backface-hidden">
{Array.from({ length: 9 }).map((_, index) => (
<div
key={index}
className="rounded-[3px] shadow-[inset_0_1px_0_rgb(255_255_255/0.22)]"
style={{
background: `linear-gradient(145deg, rgb(255 255 255 / 0.22), transparent 42%, rgb(0 0 0 / 0.18)), ${face.sticker}`,
}}
/>
))}
</div>
</div>
))}
</div>
</div>
<div
className="pointer-events-none absolute bottom-[18%] left-1/2 h-8 w-40 -translate-x-1/2 rounded-full bg-black/70 blur-xl"
aria-hidden="true"
/>
</div>
<p className="pointer-events-none absolute bottom-4 left-0 right-0 text-center text-[11px] tracking-wide text-zinc-500">
Drag to turn
</p>
</div>
);
};
export default Final3DCube;Let's work together
Have a product problem worth solving?
I help teams build fast, dependable products with thoughtful interfaces and strong engineering foundations.