3D Cube

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

User Avatar

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.