Introduction
In this article we build a spinning 3D card carousel — 12 portrait photo cards arranged in a perfect polygon ring that rotates continuously on the Y axis. The cards are plain <img> elements. The ring geometry is computed entirely in CSS using custom properties, the tan() math function, and a two-step transform chain. No JavaScript drives the layout or animation; JS is not needed at all. The output is a single self-contained HTML file that opens in any modern browser.
The effect relies on three CSS concepts working together: transform-style: preserve-3d to prevent the browser from flattening children of a 3D-transformed parent, a perspective rule on the outermost scene element to produce the vanishing-point foreshortening, and a rotateY + translateZ transform chain on each card to place it on the surface of an imaginary cylinder whose radius is derived from the card width and card count.
How CSS perspective and preserve-3d interact — why transform order matters for 3D placement — how to derive the polygon inradius using the CSS tan() function — how to assign per-element index values via inline custom properties — how to create a lateral fade using a CSS mask gradient — how to respect prefers-reduced-motion without breaking the animation — and how to host the entire effect in a zero-dependency HTML file.
How CSS 3D Transforms Work
The browser maintains a 3D rendering context for any element that has transform-style: preserve-3d. Children of that element are positioned in the same 3D space as the parent instead of being projected into a flat texture. A perspective value on an ancestor element sets the distance from the viewer to the z=0 plane — smaller values make the 3D effect more extreme, larger values flatten it toward an orthographic projection.
CSS 3D transforms operate in a right-handed coordinate system: positive X goes right, positive Y goes down, and positive Z comes toward the viewer. translateZ(-N) pushes an element away from the viewer.
The critical rule is that transforms in a CSS transform property are applied right-to-left (or equivalently: innermost-first in the matrix multiplication). So rotateY(θ) translateZ(-r) first moves the element −r units along its own Z axis, then rotates that displaced position around the world Y axis — placing the element on the surface of a cylinder of radius r at angle θ. Reversing the order would rotate the element in place and then translate it along the world Z axis, which is not what we want.
Step 1 — Computing the Cylinder Radius
For N cards of width W placed side by side with a gap G, the inradius of the regular polygon that fits them exactly is: r = (W/2 + G) / tan(π/N). In CSS, this translates directly using the tan() function and the --count and --w custom properties. The --angle property stores 1turn / N, which is the angular slice each card occupies. The CSS tan() function accepts the same value as the rotate function — no unit conversion needed.
.card {
--w: 16em; /* card width */
--count: 12; /* total cards */
--angle: calc(1turn / var(--count)); /* 30deg per card */
/*
* Polygon inradius:
* r = (w/2 + gap) / tan(half-angle)
*
* We negate r because translateZ(-r) pushes the card
* away from the viewer (behind z=0), which is correct
* for a ring that the camera looks into.
*/
--radius: calc(
-1 * (0.5 * var(--w) + 0.5em) / tan(0.5 * var(--angle))
);
}The 0.5em gap term adds a small breathing room between cards. Increasing it widens the ring. Decreasing it — or setting it to zero — packs the cards tightly. Note that changing --count automatically recalculates --angle and therefore --radius, so adding or removing cards requires only changing one number.
Step 2 — Placing Cards with a Transform Chain
Each card gets its angular index via an inline --idx custom property set directly on the element. The transform chain uses rotateY with that index times the slice angle, followed by translateZ with the precomputed radius. Because CSS applies transforms right-to-left, the translation along Z happens first (in the card's own rotated space) and the rotation happens second (in world space).
.card {
/* All cards occupy the same grid cell — stacked */
grid-area: 1 / 1;
width: var(--w);
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 1.2em;
/* Hide the back face so rear cards don't show through */
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
transform:
/* 1. Rotate to this card's angular position (applied second) */
rotateY(calc(var(--idx) * var(--angle)))
/* 2. Push outward along the card's local Z axis (applied first) */
translateZ(var(--radius));
}| Property | Value | Purpose |
|---|---|---|
| grid-area: 1/1 | All cards in the same cell | Stacks all cards at the origin before 3D transform is applied |
| backface-visibility: hidden | hidden | Prevents rear-facing cards from bleeding through front cards |
| rotateY(idx × angle) | Per card, 0–330 degrees | Distributes cards around the Y axis at equal angular intervals |
| translateZ(−r) | Negative inradius | Pushes card outward from the axis to the polygon surface |
| transform-style: preserve-3d | On parent .carousel | Keeps children in shared 3D space instead of flattening them |
| perspective: 40em | On .scene | Sets viewer distance; smaller = more dramatic foreshortening |
Step 3 — Scene Container & Perspective
The .scene element is a full-viewport grid that centres the carousel and applies the perspective. It also owns the lateral fade mask. The .carousel element is the actual 3D ring — it is a display: grid so its children can be stacked with grid-area: 1/1, and it carries transform-style: preserve-3d so the children inhabit the same 3D context as the ring rotation applied to it.
.scene {
width: 100%;
height: 100vh;
display: grid;
place-items: center;
overflow: hidden;
/* Viewer distance — 40em is a gentle perspective */
perspective: 40em;
}
.carousel {
display: grid; /* children stack via grid-area: 1/1 */
place-self: center;
transform-style: preserve-3d; /* keep children in 3D space */
animation: spin 36s linear infinite;
}
@keyframes spin {
to { rotate: y 1turn; } /* modern rotate shorthand, Y axis only */
}Step 4 — Continuous Y-Axis Rotation
The animation is a single rotate: y 1turn keyframe with linear timing and infinite repetition. The rotate shorthand property (separate from transform) is used here because it does not interfere with the transform property on the same element — the two compose independently. This means the ring can have its own static transform (for centering adjustments) without conflicting with the animation.
When you animate transform you replace the entire transform list every frame, which forces the browser to recalculate layout if any part of the list changes. The standalone rotate property composes with transform at the compositing stage and is eligible for GPU-accelerated animation without triggering layout or paint — the browser can hand it off to the compositor thread and run it at full frame rate even when the main thread is busy.
Step 5 — CSS Mask for the Edge Fade
Without a fade, cards at the edges of the scene pop in and out of view abruptly. A CSS mask with a horizontal linear gradient creates a smooth alpha fade on both sides. The gradient uses transparent at both ends and an opaque colour in the middle 64% of the width. The colour value itself does not matter for masking — only the alpha channel of the mask image is used. Using a named colour like crimson instead of black makes the intent slightly clearer in source, but red, white, or any other fully opaque colour works identically.
.scene {
/* The mask fades out the left 18% and right 18% of the scene.
* The colour value is irrelevant — only alpha matters.
* transparent = alpha 0 (hidden), crimson = alpha 1 (visible). */
mask: linear-gradient(
90deg,
transparent,
crimson 18% 82%,
transparent
);
-webkit-mask: linear-gradient(
90deg,
transparent,
crimson 18% 82%,
transparent
);
}Step 6 — Sourcing and Sizing the Cards
Each card is a plain <img> element. The --idx custom property is set inline via a style attribute, which is the lightest way to pass a per-element integer into CSS without JavaScript. The Unsplash ?w=280 query parameter requests a 280px-wide image — small enough to load quickly over a mobile connection but large enough to look crisp at the rendered card width of 16em (roughly 256px at standard DPI).
<div class="carousel">
<img class="card" style="--idx: 0"
src="https://images.unsplash.com/photo-1682686580391?w=280"
alt="nature landscape" />
<img class="card" style="--idx: 1"
src="https://images.unsplash.com/photo-1682687220742?w=280"
alt="golden light" />
<!-- ... 10 more cards ... -->
</div>The alt attributes are meaningful descriptions, not empty strings or filenames. Even though the cards are decorative in this demo, meaningful alt text costs nothing and keeps the markup screen-reader friendly by default. If the carousel were purely decorative in a production context, alt="" with role="presentation" on the container would be the correct choice.
Step 7 — Respecting prefers-reduced-motion
Users who have requested reduced motion in their OS settings can experience vestibular discomfort from large rotating animations. The carousel slows its spin to one-quarter speed (144s) under prefers-reduced-motion: reduce — it does not stop entirely, which would break the visual effect, but the motion becomes slow enough to be negligible. This is a one-line media query override.
@media (prefers-reduced-motion: reduce) {
.carousel {
/* Slow to ~2.5rpm instead of stopping —
* preserves the effect while reducing vestibular risk */
animation-duration: 144s;
}
}The WCAG 2.1 Success Criterion 2.2.2 (Pause, Stop, Hide) requires that any auto-playing motion lasting more than 5 seconds can be paused or stopped. A slow-spinning carousel technically still moves, so a fully accessible implementation would also offer a pause button. For a personal demo or portfolio piece the slowdown is a reasonable compromise; for a production site aimed at a broad audience, adding a pause toggle is worth the extra few lines of JavaScript.
Tuning Reference
| Parameter | Default | Effect |
|---|---|---|
| --w (card width) | 16em | Wider cards require a larger polygon radius; the formula auto-adjusts via tan() |
| --count (card count) | 12 | Changing this recalculates --angle and --radius automatically — add/remove cards freely |
| 0.5em gap in radius formula | 0.5em | Controls breathing room between cards. Increase to spread the ring, decrease to pack tightly |
| perspective on .scene | 40em | Smaller = more dramatic 3D; larger = flatter, more orthographic look |
| animation-duration | 36s | Faster spin = shorter value. 36s is one full rotation every 36 seconds (~1.67 rpm) |
| aspect-ratio on .card | 2 / 3 | Portrait cards. Use 1/1 for square, 4/3 for landscape, or 16/9 for widescreen |
| border-radius on .card | 1.2em | Rounds card corners. Set to 0 for sharp edges |
| mask fade stops | 18% 82% | Widens or narrows the visible band. 10% 90% = wider visible area; 30% 70% = narrower |
| backface-visibility | hidden | Set to visible to see card backs — useful for debugging ring geometry |
| ?w= on Unsplash URL | 280 | Image pixel width fetched from CDN. Increase for retina displays, decrease for faster load |
| reduced-motion duration | 144s | Slowdown factor for vestibular accessibility. Increase further or set to paused to stop entirely |
Full Source Code
Save the following as carousel-3d.html and open in any modern browser. Zero dependencies, zero build step. The CSS tan() function requires Chrome 111+, Firefox 108+, or Safari 15.4+.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/carousel-3d.htmlTwelve cards, one polygon, zero JavaScript — the entire ring geometry lives in a single CSS tan() expression.