How to make a flipbook-style carousel with Blossom
A flipbook carousel looks like it needs a custom animation engine. It doesn't. The effect in our flipbook example is built from three web platform features: scroll snap, position: sticky and scroll-driven animations. Blossom provides the native scrolling and a small overscroll hook for the page-turn at the edges.
What we're building
As you scroll (or drag) through the carousel, cards fan out in 3D space, settle face-on in the center, then peel away behind the stack. Pull past the first or last card and the whole stack bends like you're forcing a page turn.
Blossom's role here is small but important: it gives you a real horizontal scroll container with optional drag on desktop, and an overscroll event when the user pulls beyond the scroll range. Everything else is CSS.
Let's build it up step by step. Each step below has a live demo, so you can see exactly what each layer adds.
Step 1: Set up the scroll container
Start with a horizontal grid where each slide takes the full viewport width of the carousel, and scroll snap locks to each page:
.carousel {
--card-width: 12rem;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
scroll-snap-type: x mandatory;
/* Show one card with neighbors peeking on each side */
width: calc(var(--card-width) * 3);
padding-inline: var(--card-width);
box-sizing: border-box;
}
.slide {
width: var(--card-width);
aspect-ratio: 3 / 4;
}
<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>
Nothing fancy yet, just a snapping horizontal scroller. Each column is as wide as the carousel itself, which is why you see so much space between the cards. That will make sense in the next step.
Step 2: Sticky stacking
Now make each slide position: sticky with negative inset offsets. This keeps every card pinned to the same visual slot as you scroll, so cards stack on top of each other instead of sliding side by side like a normal carousel:
.slide {
position: sticky;
left: calc(var(--card-width) * -1);
right: calc(var(--card-width) * -1);
}
The grid still advances one full column per snap point, but sticky positioning pulls each card back into the center frame. Every card now passes through the same region as you scroll, which is exactly the setup we need to animate them in 3D.
Step 3: Scroll-driven animations
Attach a view timeline to each slide so its animation progress tracks how far that slide has traveled through the scrollport:
.slide {
view-timeline: --cards inline;
animation: stack-cards linear both;
animation-timeline: --cards;
animation-range: contain;
}
The named timeline --cards is scoped to each slide's journey through the carousel, and animation-range: contain runs the animation for the full time the slide is visible in the scrollport.
This first animation only manages stacking order, in other words which card sits on top as it moves through the center:
@keyframes stack-cards {
0% {
z-index: calc(sibling-count() - sibling-index());
}
50% {
z-index: 1000;
}
100% {
z-index: sibling-index();
}
}
sibling-index() is a CSS function that returns each slide's index among its siblings. Cards entering from the left start on top, the active card peaks at z-index: 1000 in the middle, and cards exiting sink behind the stack.
It still looks static, but scroll slowly and watch the cards swap places. The stacking order is now correct, we just need the cards to actually move.
Step 4: 3D card rotation
The visible flip happens on an inner .card element. Separating the sticky wrapper from the rotating card keeps the scroll and transform concerns isolated:
.slide {
perspective: 1200px;
& .card {
width: 100%;
height: 100%;
transform-style: preserve-3d;
animation: rotate-cards linear both;
animation-timeline: --cards;
animation-range: contain -50% contain 150%;
}
}
The wider animation range (contain -50% contain 150%) starts the rotation slightly before the slide enters and finishes slightly after it leaves, so the motion feels continuous rather than clipped.
The keyframes move each card through a 3D arc: offset to the side, rotated on the Y axis, flat in the center, then mirrored on the way out:
@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);
}
}
There's the flipbook. Feel free to tweak the translate and rotate values to change how dramatic the fan feels. The symmetry around the 50% keyframe keeps the motion balanced in both scroll directions.
Step 5: Overscroll page turn
Scroll-driven animations handle everything within the scroll range. For the rubber-band page turn when the user drags past the first or last card, listen to Blossom's overscroll event:
function handleOverscroll(event: CustomEvent) {
event.preventDefault();
const overscroll = event.detail.left;
Array.from(event.target.children).forEach((child) => {
const translate = `translateX(${overscroll * 0.5}px)`;
const rotate = `rotateY(${(overscroll / event.target.clientWidth) * -90}deg)`;
child.style.transform = `${translate} ${rotate}`;
});
}
carousel.addEventListener('overscroll', handleOverscroll);
event.detail.left is the horizontal overscroll distance in pixels, positive when pulling right and negative when pulling left. We apply it directly as a transform on each slide. The .prevent modifier tells Blossom to skip its default rubber-band effect so our custom one takes over.
Drag past the first or last card to see it. Note that this only runs when the drag engine is active (desktop with a fine pointer). Touch users still get native scroll at the edges, which is usually enough.
Putting it together
<script setup>
function handleOverscroll(event: CustomEvent) {
const overscroll = event.detail.left;
Array.from(event.target.children).forEach((child) => {
const translate = `translateX(${overscroll * 0.5}px)`;
const rotate = `rotateY(${(overscroll / event.target.clientWidth) * -90}deg)`;
child.style.transform = `${translate} ${rotate}`;
});
}
</script>
<template>
<BlossomCarousel class="carousel" @overscroll.prevent="handleOverscroll">
<div v-for="i in 10" :key="i" class="slide">
<div class="card">{{ i }}</div>
</div>
</BlossomCarousel>
</template>
<style scoped>
.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(sibling-count() - 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);
}
}
</style>
function handleOverscroll(event: CustomEvent<{ left: number }>) {
const overscroll = event.detail.left;
Array.from(event.target.children).forEach((child) => {
const translate = `translateX(${overscroll * 0.5}px)`;
const rotate = `rotateY(${(overscroll / event.target.clientWidth) * -90}deg)`;
child.style.transform = `${translate} ${rotate}`;
});
}
return (
<BlossomCarousel
ref={carousel}
className="carousel"
onOverscroll={(event) =>
handleOverscroll(event as CustomEvent<{ left: number }>)
}
>
{Array.from({ length: 10 }, (_, index) => (
<div key={index} className="slide">
<div className="card">{index + 1}</div>
</div>
))}
</BlossomCarousel>
);
<style>
.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(sibling-count() - 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);
}
}
</style>
<script>
function handleOverscroll(event) {
event.preventDefault();
const overscroll = event.detail.left;
Array.from(event.target.children).forEach((child) => {
const translate = `translateX(${overscroll * 0.5}px)`;
const rotate = `rotateY(${(overscroll / event.target.clientWidth) * -90}deg)`;
child.style.transform = `${translate} ${rotate}`;
});
}
const slides = Array.from({ length: 10 }, (_, index) => index + 1);
</script>
<BlossomCarousel class="carousel" onoverscroll={handleOverscroll}>
{#each slides as slide (slide)}
<div class="slide">
<div class="card">{slide}</div>
</div>
{/each}
</BlossomCarousel>
<style>
.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(sibling-count() - 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);
}
}
</style>
<blossom-carousel class="carousel">
<div class="slide">
<div class="card">1</div>
</div>
<div class="slide">
<div class="card">2</div>
</div>
<div class="slide">
<div class="card">3</div>
</div>
<!-- ... -->
</blossom-carousel>
<script type="module">
import "@blossom-carousel/web";
const carousel = document.querySelector(".carousel");
carousel.addEventListener("overscroll", (event) => {
event.preventDefault();
const overscroll = event.detail.left;
Array.from(carousel.children).forEach((child) => {
const translate = `translateX(${overscroll * 0.5}px)`;
const rotate = `rotateY(${(overscroll / carousel.clientWidth) * -90}deg)`;
child.style.transform = `${translate} ${rotate}`;
});
});
</script>
<style>
.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(sibling-count() - 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);
}
}
</style>
All of the layout and animation logic lives in CSS. Blossom doesn't know it's a flipbook, it just scrolls and allows it to be dragged.
Browser support
Scroll-driven animations with view-timeline require a modern browser. Check caniuse for current support.
Safari's view-timeline implementation does not yet account for sticky offsets when calculating animation-range: contain, so the animation timing may look off in Safari until WebKit bug 288913 is resolved.
Explore further
- Flipbook example has the full source with CSS and Tailwind variants
- Cover flow example is another scroll-driven 3D pattern
- Overscroll API documents what
event.detailexposes







