Advanced
Flipbook
Build a 3d flipbook-style carousel with scroll-driven animations.
.carousel {
--card-width: 12rem;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
scroll-snap-type: x mandatory;
width: calc(var(--card-width) * 3);
padding-inline: var(--card-width);
perspective: 1200px;
}
.slide {
width: var(--card-width);
aspect-ratio: 3 / 4;
position: sticky;
left: calc(var(--card-width) * -1);
right: calc(var(--card-width) * -1);
scroll-snap-align: center;
scroll-snap-stop: always;
perspective: 1200px;
view-timeline: --cards inline;
animation: stack-cards linear both;
animation-timeline: --cards;
animation-range: contain;
& .card {
width: 100%;
height: 100%;
transform-style: preserve-3d;
animation: rotate-cards linear both;
animation-timeline: --cards;
animation-range: contain -50% contain 150%;
}
}
@keyframes stack-cards {
0% {
z-index: calc(100 - sibling-index());
}
50% {
z-index: 1000;
}
100% {
z-index: sibling-index();
}
}
@keyframes rotate-cards {
0% {
transform: translateX(-100%) translateZ(-200px) rotateY(-35deg);
}
25% {
transform: translateX(-80%) translateZ(-100px) rotateY(-35deg);
}
37.5% {
transform: translateX(-15%) translateZ(-100px) rotateY(-20deg);
}
50% {
transform: translateX(0%) translateZ(0px) rotateY(0deg);
}
62.5% {
transform: translateX(15%) translateZ(-100px) rotateY(20deg);
}
75% {
transform: translateX(80%) translateZ(-100px) rotateY(35deg);
}
100% {
transform: translateX(100%) translateZ(-200px) rotateY(35deg);
}
}
<BlossomCarousel class="carousel">
<div class="slide">
<div class="card"></div>
</div>
<div class="slide">
<div class="card"></div>
</div>
<div class="slide">
<div class="card"></div>
</div>
...
</BlossomCarousel>
carousel.addEventListener("overscroll", (event: CustomEvent) => {
const overscroll = event.detail.left;
Array.from(event.target.children).forEach((child) => {
child.style.transform = `translateX(${overscroll * 0.5}px) rotateY(${(overscroll / event.target.clientWidth) * -90}deg)`;
});
}
.carousel {
--card-width: 12rem;
}
.slide {
view-timeline: --cards inline;
animation: stack-cards linear both;
animation-timeline: --cards;
animation-range: contain;
& .card {
animation: rotate-cards linear both;
animation-timeline: --cards;
animation-range: contain -50% contain 150%;
}
}
@keyframes stack-cards {
0% {
z-index: calc(100 - sibling-index());
}
50% {
z-index: 1000;
}
100% {
z-index: sibling-index();
}
}
@keyframes rotate-cards {
0% {
transform: translateX(-100%) translateZ(-200px) rotateY(-35deg);
}
25% {
transform: translateX(-80%) translateZ(-100px) rotateY(-35deg);
}
37.5% {
transform: translateX(-15%) translateZ(-100px) rotateY(-20deg);
}
50% {
transform: translateX(0%) translateZ(0px) rotateY(0deg);
}
62.5% {
transform: translateX(15%) translateZ(-100px) rotateY(20deg);
}
75% {
transform: translateX(80%) translateZ(-100px) rotateY(35deg);
}
100% {
transform: translateX(100%) translateZ(-200px) rotateY(35deg);
}
}
<BlossomCarousel
class="carousel perspective-normal grid! snap-x snap-mandatory auto-cols-[100%] grid-flow-col w-[calc(var(--card-width)*3)]! px-(--card-width)"
>
<div
v-for="i in 10"
:key="i"
class="slide perspective-normal sticky -left-(--card-width) -right-(--card-width) w-(--card-width) aspect-[3/4] snap-center snap-always"
>
<div class="card transform-3d size-full">{{ i }}</div>
</div>
</BlossomCarousel>
carousel.addEventListener("overscroll", (event: CustomEvent) => {
const overscroll = event.detail.left;
Array.from(event.target.children).forEach((child) => {
child.style.transform = `translateX(${overscroll * 0.5}px) rotateY(${(overscroll / event.target.clientWidth) * -90}deg)`;
});
}