A scroll-driven 3D carousel: the track rotates while cards brighten near the front.
- ID:
3d-small-carousel - Name:
3D Small Carousel - Description: Cards are arranged in a 3D ring and orbit on scroll while cards brighten as they face the viewer.
- Best for:
4-12similarly sized image/card siblings that can be placed as absolute items around apreserve-3dcarousel.
<section class="scroll-section">
<div id="carousel-stage" class="sticky-stage">
<div data-testid="internal-container-content" id="carousel" class="carousel">
<div id="card-1" class="card">1</div>
<div id="card-2" class="card">2</div>
<div id="card-3" class="card">3</div>
<div id="card-4" class="card">4</div>
</div>
</div>
</section>- Role ownership is strict:
scrollSectionowns runway,stickyStageowns sticky/clipping/perspective,carouselowns the stable centered 3D stage, andrepeatedCardowns orbit transform plus brightness. - In Wix,
stickyStageis the internal-container-root andcarouselis#<stickyStageCompId> > [data-testid="internal-container-content"]. Those selectors must stay distinct. - Do not animate
carouselwithrotateYin Wix. Orbit each card root with combined transform/filter keyframes instead of rotating the wrapper. - Keep 3D placement on repeated card roots, not raw
imgdescendants. Cards must become absolute centered items in apreserve-3dstage. - Compute angle step from item count and radius from card size; four cards are the minimum useful ring.
| Role | Guidance |
|---|---|
scrollSource |
The tall section whose viewProgress drives carousel rotation and card brightness. |
stickyStage |
The non-rotating sticky viewport-height stage that centers, clips, and provides perspective for the 3D carousel. |
perspectiveFrame |
Same non-rotating stage when no extra wrapper exists; never the rotating carousel. |
carousel |
The internal content child / stable preserve-3d stage containing the orbiting cards. |
repeatedCard |
Absolute card roots arranged around the carousel with static rotateY/translateZ placement. |
- The source layout does not need to already be a carousel; repeated siblings can be reorganized into an absolute 3D ring with CSS.
- Preserve the section root outer layout and keep card size close to the source composition instead of introducing viewport-height cards.
- Make the carousel a stable centered anchor inside the sticky viewport, then reset grid/flex placement on cards and center them on that anchor.
- Initial per-card placement is formula-driven: repeated card
istarts atrotateY(i * 360 / N) translateZ(radius). - Generate sampled per-card orbit keyframes that combine transform and filter. Do not add a separate wrapper spin effect.
- If the orbit looks off-center, move the carousel anchor, not the cards one by one.
| Key | Role | Demo Selector | Purpose |
|---|---|---|---|
scrollSection |
scrollSource |
.scroll-section |
The viewProgress source for the full 3D carousel scene. |
stickyStage |
stickyStage |
#carousel-stage |
The shared sticky viewport stage that pins, clips, centers, and provides perspective. Wix: internal-container-root #comp id. |
perspectiveFrame |
perspectiveFrame |
#carousel-stage |
The non-rotating perspective owner. Usually the same selector as stickyStage; never carousel. |
carousel |
carousel |
#carousel-stage > [data-testid="internal-container-content"] |
The stable preserve-3d stage that directly contains card roots. Wix: MUST be #<stickyStageCompId> > [data-testid="internal-container-content"], never the same selector as stickyStage. |
card1 |
repeatedCard |
.scroll-section #card-1 |
Minimum repeated ring card; extend for card5..cardN. |
card2 |
repeatedCard |
.scroll-section #card-2 |
Repeated ring card. |
card3 |
repeatedCard |
.scroll-section #card-3 |
Repeated ring card. |
card4 |
repeatedCard |
.scroll-section #card-4 |
Repeated ring card. |
Selector: .scroll-section
.scroll-section {
position: relative;
min-height: 400vh;
}Reason: creates enough scroll distance for the carousel to rotate smoothly.
Selector: #carousel-stage
#carousel-stage {
position: sticky;
top: 0;
height: 100vh;
width: 100%;
overflow: clip;
}Reason: pins the scene and prevents horizontal overflow while the carousel rotates. This selector must never receive the carousel rotateY effect.
Selector: #carousel-stage
#carousel-stage {
perspective: 1200px;
perspective-origin: 50% 45%;
display: flex;
justify-content: center;
align-items: center;
transform-style: preserve-3d;
}Reason: provides depth and centering for the carousel without rotating with it.
Selector: #carousel-stage > [data-testid="internal-container-content"]
#carousel-stage > [data-testid="internal-container-content"] {
grid-area: auto;
justify-self: auto;
align-self: auto;
position: absolute;
top: 50%;
left: 50%;
display: block;
width: 0;
height: 0;
margin: 0;
padding: 0;
overflow: visible;
transform-style: preserve-3d;
transform-origin: center center;
}Reason: creates a stable viewport-centered 3D anchor for the absolute cards; do not animate this selector in Wix.
Selector: #carousel-stage > [data-testid="internal-container-content"] > .card
#carousel-stage > [data-testid="internal-container-content"] > .card {
grid-area: auto;
justify-self: auto;
align-self: auto;
position: absolute;
top: 0;
left: 0;
width: 280px;
height: 420px;
margin-left: -140px;
margin-top: -210px;
backface-visibility: hidden;
transform-origin: center center;
will-change: transform, filter;
}Reason: centers repeated card roots in the carousel stage before per-card orbit transforms are applied. Preserve the source card proportions here with measured px or stage-relative percentages; do not convert cards to viewport-height blocks.
const RANGE = {
rangeStart: { name: 'cover', offset: { unit: 'percentage', value: 0 } },
rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: 100 } },
fill: 'both' as const,
easing: 'linear',
};Per-card keyframes are generated by sampling the ring rotation (turns * samplesPerTurn + 1 steps) and combining a rotateY(...) translateZ(radius) transform with a proximity-based brightness(...) filter. Each card i is offset by its static angle i * 360 / cardCount.
const orbitKeyframes = (
cardIndex: number,
cardCount = 4,
turns = 2,
samplesPerTurn = 8,
radius = '380px',
) => {
const steps = turns * samplesPerTurn + 1;
const stepDeg = 360 / samplesPerTurn;
const cardAngle = cardIndex * (360 / cardCount);
return Array.from({ length: steps }, (_, step) => {
const rotation = step * stepDeg;
const worldAngle = (rotation + cardAngle) % 360;
const diff = Math.min(worldAngle, 360 - worldAngle);
const proximity = (Math.cos((diff * Math.PI) / 180) + 1) / 2;
const brightness = 0.3 + 0.8 * proximity;
return {
offset: step / (steps - 1),
transform: `rotateY(${rotation + cardAngle}deg) translateZ(${radius})`,
filter: `brightness(${brightness.toFixed(2)})`,
};
});
};const cardOrbitEffect = (key: string, cardIndex: number) => ({
key,
keyframeEffect: {
name: `${key}-orbit`,
keyframes: orbitKeyframes(cardIndex),
},
...RANGE,
});{
key: 'scrollSection',
trigger: 'viewProgress',
effects: RING_CARD_NUMBERS.map((cardNumber, index) =>
cardOrbitEffect(`card${cardNumber}`, index),
),
}