Aduok Code

3D Card Carousel with Pure CSS & HTML5

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.

What you will learn

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.

01
Part One
The Polygon Radius Formula

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.

CSS
.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.

02
Part Two
The Transform Chain

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).

CSS
.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));
}
PropertyValuePurpose
grid-area: 1/1All cards in the same cellStacks all cards at the origin before 3D transform is applied
backface-visibility: hiddenhiddenPrevents rear-facing cards from bleeding through front cards
rotateY(idx × angle)Per card, 0–330 degreesDistributes cards around the Y axis at equal angular intervals
translateZ(−r)Negative inradiusPushes card outward from the axis to the polygon surface
transform-style: preserve-3dOn parent .carouselKeeps children in shared 3D space instead of flattening them
perspective: 40emOn .sceneSets viewer distance; smaller = more dramatic foreshortening
03
Part Three
Scene & Perspective Setup

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.

CSS
.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 */
}
04
Part Four
Animating the Ring

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.

Why use the rotate property instead of transform: rotateY()?

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.

05
Part Five
Lateral Fade Mask

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.

CSS
.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
  );
}
06
Part Six
Image Cards

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).

HTML
<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.

07
Part Seven
Accessibility & Reduced Motion

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.

CSS
@media (prefers-reduced-motion: reduce) {
  .carousel {
    /* Slow to ~2.5rpm instead of stopping —
     * preserves the effect while reducing vestibular risk */
    animation-duration: 144s;
  }
}
Should I pause instead of slow down?

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

ParameterDefaultEffect
--w (card width)16emWider cards require a larger polygon radius; the formula auto-adjusts via tan()
--count (card count)12Changing this recalculates --angle and --radius automatically — add/remove cards freely
0.5em gap in radius formula0.5emControls breathing room between cards. Increase to spread the ring, decrease to pack tightly
perspective on .scene40emSmaller = more dramatic 3D; larger = flatter, more orthographic look
animation-duration36sFaster spin = shorter value. 36s is one full rotation every 36 seconds (~1.67 rpm)
aspect-ratio on .card2 / 3Portrait cards. Use 1/1 for square, 4/3 for landscape, or 16/9 for widescreen
border-radius on .card1.2emRounds card corners. Set to 0 for sharp edges
mask fade stops18% 82%Widens or narrows the visible band. 10% 90% = wider visible area; 30% 70% = narrower
backface-visibilityhiddenSet to visible to see card backs — useful for debugging ring geometry
?w= on Unsplash URL280Image pixel width fetched from CDN. Increase for retina displays, decrease for faster load
reduced-motion duration144sSlowdown 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+.

HTML — carousel-3d.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/carousel-3d.html

Twelve cards, one polygon, zero JavaScript — the entire ring geometry lives in a single CSS tan() expression.

Want to see it in action?Watch the full build on YouTube