Cube

Visitors24.8k+12% this week
No dependencies
Code format
React + Tailwind
import { useState } from 'react';

const css = `
  .card-cube { display: grid; justify-items: center; gap: 12px; }
  .card-cube__scene { width: 200px; height: 128px; perspective: 700px; }
  .card-cube__body {
    position: relative;
    width: 100%;
    height: 100%;
    transform-style: preserve-3d;
    transition: transform 0.7s cubic-bezier(0.34, 1.2, 0.64, 1);
  }
  .card-cube__face {
    position: absolute;
    inset: 0;
    display: grid;
    align-content: space-between;
    padding: 14px;
    border-radius: 14px;
    background: light-dark(#f3f4f7, #1a1c1f);
    box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
    backface-visibility: hidden;
  }
  .card-cube__face small { color: light-dark(#5f626a, #a7abb2); font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; }
  .card-cube__face b { color: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
  .card-cube__face span { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
  .card-cube__nav { display: flex; align-items: center; gap: 10px; }
  .card-cube__nav button {
    display: grid;
    place-items: center;
    width: 28px;
    height: 28px;
    padding: 0;
    border: 1px solid light-dark(#dbdde1, #2e3035);
    border-radius: 50%;
    background: light-dark(#f3f4f7, #1a1c1f);
    color: light-dark(#373a40, #f1f2f3);
    cursor: pointer;
  }
  .card-cube__nav button:hover { border-color: light-dark(#6d7179, #7d8087); }
  .card-cube__nav button:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 2px; }
  .card-cube__nav svg { width: 14px; height: 14px; }
  .card-cube__dots { display: flex; gap: 5px; }
  .card-cube__dots i { width: 5px; height: 5px; border-radius: 50%; background: light-dark(#dbdde1, #2e3035); transition: background-color 0.3s ease, width 0.3s ease; }
  .card-cube__dots i[data-on] { width: 14px; border-radius: 3px; background: light-dark(#373a40, #f1f2f3); }
  @media (prefers-reduced-motion: reduce) { .card-cube__body { transition-duration: 0.01s; } }
`;

const FACES = [
  { k: 'Visitors', v: '24.8k', s: '+12% this week' },
  { k: 'Sessions', v: '3m 12s', s: 'Average length' },
  { k: 'Bounce', v: '31%', s: 'Down 4 points' },
  { k: 'Signups', v: '1,204', s: 'Best day: Tuesday' },
];
const DEPTH = 100; // half the face width: the cube's radius

export function CubeCard() {
  const [turn, setTurn] = useState(0);
  const face = ((turn % 4) + 4) % 4;
  return (
    <>
      <style>
        {css}
      </style>
      <div className="card-cube" role="group" aria-roledescription="carousel" aria-label="Weekly stats">
        <div className="card-cube__scene">
          {/* Turning by the running count (not the face index) keeps every turn going the short way. */}
          <div className="card-cube__body" style={{ transform: `translateZ(-${DEPTH}px) rotateY(${turn * -90}deg)` }}>
            {FACES.map((f, i) => (
              <div
                key={f.k}
                className="card-cube__face"
                aria-hidden={i !== face}
                style={{ transform: `rotateY(${i * 90}deg) translateZ(${DEPTH}px)` }}
              >
                <small>{f.k}</small>
                <b>{f.v}</b>
                <span>{f.s}</span>
              </div>
            ))}
          </div>
        </div>
        <div className="card-cube__nav">
          <button type="button" aria-label="Previous" onClick={() => setTurn((t) => t - 1)}>
            <svg viewBox="0 0 24 24" fill="none"><path d="m15 6-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
          </button>
          <span className="card-cube__dots" aria-hidden="true">
            {FACES.map((f, i) => (
              <i key={f.k} data-on={i === face || undefined} />
            ))}
          </span>
          <button type="button" aria-label="Next" onClick={() => setTurn((t) => t + 1)}>
            <svg viewBox="0 0 24 24" fill="none"><path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
          </button>
        </div>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/card-cube
Source on GitHub

About this component

A stats card with four faces on a 3D cube: the arrow buttons turn it to the next face with a springy rotation, and the dots below track which face is showing.

Dependencies
None
Difficulty
Intermediate
Triggers
click, 3d, spring, card
Techniques
preserve 3d, 3d transform, css transition, state machine
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A group with aria-roledescription="carousel". Previous and Next are labelled buttons; faces not showing are aria-hidden. Under prefers-reduced-motion the turn is instant.

More cards

See all
Living room
64% · 2700K
Dimmer
Low tide
Enhance
Focus
Q4 proposals12 files · edited today
Folder