Cube
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardCube(el) on the mounted element instead.
<div class="card-cube" role="group" aria-roledescription="carousel" aria-label="Weekly stats">
<div class="card-cube__scene">
<div class="card-cube__body" style="transform:translateZ(-100px) rotateY(0deg)">
<div class="card-cube__face" aria-hidden="false" style="transform:rotateY(0deg) translateZ(100px)">
<small>Visitors</small>
<b>24.8k</b>
<span>+12% this week</span>
</div>
<div class="card-cube__face" aria-hidden="true" style="transform:rotateY(90deg) translateZ(100px)">
<small>Sessions</small>
<b>3m 12s</b>
<span>Average length</span>
</div>
<div class="card-cube__face" aria-hidden="true" style="transform:rotateY(180deg) translateZ(100px)">
<small>Bounce</small>
<b>31%</b>
<span>Down 4 points</span>
</div>
<div class="card-cube__face" aria-hidden="true" style="transform:rotateY(270deg) translateZ(100px)">
<small>Signups</small>
<b>1,204</b>
<span>Best day: Tuesday</span>
</div>
</div>
</div>
<div class="card-cube__nav">
<button type="button" aria-label="Previous">
<svg viewBox="0 0 24 24" fill="none">
<path d="m15 6-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<span class="card-cube__dots" aria-hidden="true">
<i data-on="true"></i>
<i></i>
<i></i>
<i></i>
</span>
<button type="button" aria-label="Next">
<svg viewBox="0 0 24 24" fill="none">
<path d="m9 6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</div>
</div>.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; } }function initCardCube(root) {
var body = root.querySelector('.card-cube__body');
var faces = root.querySelectorAll('.card-cube__face');
var dots = root.querySelectorAll('.card-cube__dots i');
var buttons = root.querySelectorAll('.card-cube__nav button');
if (!body || faces.length !== 4 || buttons.length !== 2) return;
// Turning by a running count, not the face index, keeps every turn going the short way.
var turn = 0;
function render() {
var face = ((turn % 4) + 4) % 4;
body.style.transform = 'translateZ(-100px) rotateY(' + turn * -90 + 'deg)';
faces.forEach(function (f, i) { f.setAttribute('aria-hidden', String(i !== face)); });
dots.forEach(function (d, i) { d.toggleAttribute('data-on', i === face); });
}
function prev() { turn -= 1; render(); }
function next() { turn += 1; render(); }
buttons[0].addEventListener('click', prev);
buttons[1].addEventListener('click', next);
return function () {
buttons[0].removeEventListener('click', prev);
buttons[1].removeEventListener('click', next);
};
}
document.querySelectorAll('.card-cube').forEach(initCardCube);Goal: Create a "Cube" card.
Visual behavior: 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.
Technique:
- Four faces are rotateY(i x 90deg) translateZ(100px), half the face width, so they meet as a cube; backface-visibility hides the rear ones.
- The body is translateZ(-100px) rotateY(turn x -90deg). Turning by a running count, not the face index, keeps every turn going the short way, including from the last face back to the first.
- The current face is turn mod 4; it drives the active dot (which stretches into a pill) and aria-hidden on the other faces.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Cube" card.
Visual behavior: 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.
Technique:
- Four faces are rotateY(i x 90deg) translateZ(100px), half the face width, so they meet as a cube; backface-visibility hides the rear ones.
- The body is translateZ(-100px) rotateY(turn x -90deg). Turning by a running count, not the face index, keeps every turn going the short way, including from the last face back to the first.
- The current face is turn mod 4; it drives the active dot (which stretches into a pill) and aria-hidden on the other faces.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.