I am trying to implement CSS/Tailwind scroll snapping for three expandable, vertically stacked cards within a scrollable flex container, positioned below a fixed sticky header. My goal is for the scroll to snap to the top of each card.
However, scroll snapping is not working despite applying overflow-y-auto, snap-y, and snap-mandatory classes to the parent container, and snap-center or snap-start to the child cards.
What is the correct approach to achieve scroll snapping in this specific layout, or what am I missing in my current implementation?
Here is the relevant code:
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<div class="min-h-screen">
<!-- Header -->
<div class="p-4 flex gap-2 bg-black h-[64px] sticky top-0 z-10">
</div>
<!-- Flex container -->
<div class="group flex flex-col gap-4 py-4 overflow-y-auto snap-y snap-mandatory">
<div
class="card overflow-hidden transition-all duration-300 bg-red-300 snap-center
h-[calc((100vh-64px-4*1rem)/3)] [&[opened]]:h-[calc(100vh-64px-2*1rem)] overflow-hidden">
<div class="p-4 flex flex-col h-full">
<div class="font-bold">Card 1</div>
<div class="mt-2 flex-1 overflow-auto">
<table class="w-full border-collapse">
<tbody>
<tr>
<td class="border p-2">Row 1</td>
</tr>
<tr>
<td class="border p-2">Row 2</td>
</tr>
<tr>
<td class="border p-2">Row 3</td>
</tr>
<tr>
<td class="border p-2">Row 4</td>
</tr>
<tr>
<td class="border p-2">Row 5</td>
</tr>
<tr>
<td class="border p-2">Row 6</td>
</tr>
<tr>
<td class="border p-2">Row 7</td>
</tr>
<tr>
<td class="border p-2">Row 8</td>
</tr>
<tr>
<td class="border p-2">Row 9</td>
</tr>
<tr>
<td class="border p-2">Row 10</td>
</tr>
<tr>
<td class="border p-2">Row 11</td>
</tr>
<tr>
<td class="border p-2">Row 12</td>
</tr>
<tr>
<td class="border p-2">Row 13</td>
</tr>
<tr>
<td class="border p-2">Row 14</td>
</tr>
<tr>
<td class="border p-2">Row 15</td>
</tr>
</tbody>
</table>
</div>
<button class="mt-2 px-3 py-1 bg-gray-200 rounded self-end"
onclick="toggleCard(0, this)">
View more
</button>
</div>
</div>
<div
class="card overflow-hidden transition-all duration-300 bg-green-300 snap-center
h-[calc((100vh-64px-4*1rem)/3)] [&[opened]]:h-[calc(100vh-64px-2*1rem)] overflow-hidden">
<div class="p-4 flex flex-col h-full">
<div class="font-bold">Card 2</div>
<div class="mt-2 flex-1 overflow-auto">
<table class="w-full border-collapse">
<tbody>
<tr>
<td class="border p-2">Row 1</td>
</tr>
<tr>
<td class="border p-2">Row 2</td>
</tr>
<tr>
<td class="border p-2">Row 3</td>
</tr>
<tr>
<td class="border p-2">Row 4</td>
</tr>
<tr>
<td class="border p-2">Row 5</td>
</tr>
<tr>
<td class="border p-2">Row 6</td>
</tr>
<tr>
<td class="border p-2">Row 7</td>
</tr>
<tr>
<td class="border p-2">Row 8</td>
</tr>
<tr>
<td class="border p-2">Row 9</td>
</tr>
<tr>
<td class="border p-2">Row 10</td>
</tr>
<tr>
<td class="border p-2">Row 11</td>
</tr>
<tr>
<td class="border p-2">Row 12</td>
</tr>
<tr>
<td class="border p-2">Row 13</td>
</tr>
<tr>
<td class="border p-2">Row 14</td>
</tr>
<tr>
<td class="border p-2">Row 15</td>
</tr>
</tbody>
</table>
</div>
<button class="mt-2 px-3 py-1 bg-gray-200 rounded self-end"
onclick="toggleCard(1, this)">
View more
</button>
</div>
</div>
<div
class="card overflow-hidden transition-all duration-300 bg-blue-300 snap-center
h-[calc((100vh-64px-4*1rem)/3)] [&[opened]]:h-[calc(100vh-64px-2*1rem)] overflow-hidden">
<div class="p-4 flex flex-col h-full">
<div class="font-bold">Card 3</div>
<div class="mt-2 flex-1 overflow-auto">
<table class="w-full border-collapse">
<tbody>
<tr>
<td class="border p-2">Row 1</td>
</tr>
<tr>
<td class="border p-2">Row 2</td>
</tr>
<tr>
<td class="border p-2">Row 3</td>
</tr>
<tr>
<td class="border p-2">Row 4</td>
</tr>
<tr>
<td class="border p-2">Row 5</td>
</tr>
<tr>
<td class="border p-2">Row 6</td>
</tr>
<tr>
<td class="border p-2">Row 7</td>
</tr>
<tr>
<td class="border p-2">Row 8</td>
</tr>
<tr>
<td class="border p-2">Row 9</td>
</tr>
<tr>
<td class="border p-2">Row 10</td>
</tr>
<tr>
<td class="border p-2">Row 11</td>
</tr>
<tr>
<td class="border p-2">Row 12</td>
</tr>
<tr>
<td class="border p-2">Row 13</td>
</tr>
<tr>
<td class="border p-2">Row 14</td>
</tr>
<tr>
<td class="border p-2">Row 15</td>
</tr>
</tbody>
</table>
</div>
<button class="mt-2 px-3 py-1 bg-gray-200 rounded self-end"
onclick="toggleCard(2, this)">
View more
</button>
</div>
</div>
</div>
</div>
const cards = document.querySelectorAll('.card');
function toggleCard(index, btn) {
const card = cards[index];
const isOpen = card.hasAttribute('opened');
if (isOpen) {
card.removeAttribute('opened');
btn.textContent = 'View more';
} else {
card.setAttribute('opened', '');
btn.textContent = 'View less';
}
}
CSS scroll snap fails in your Tailwind layout because the flex container lacks an explicit height, preventing actual scrolling despite overflow-y-auto. With a sticky header, snap points align to the viewport top instead of below the header—fix this using scroll-padding-top: 64px on the container. Switch child cards to snap-start for top alignment and snap-proximity over snap-mandatory to handle expandable heights gracefully, as detailed in the Tailwind CSS docs.
Contents
- Why CSS Scroll Snap Isn’t Working
- Fix 1: Give Your Scroll Container an Explicit Height
- Fix 2: Offset Snap Points with Scroll Padding for Sticky Headers
- Fix 3: Use the Right Snap Alignment and Type on Cards
- Fix 4: Handle Expandable Cards Without Breaking Snaps
- Complete Working Code Example
- Common Pitfalls and Testing Tips
Why CSS Scroll Snap Isn’t Working
You’ve got the basics down—snap-y snap-mandatory on the parent and snap-center on kids—but scroll snapping demands a scrollable container first. Your outer min-h-screen div lets the inner flex container expand to fit all three cards, so no overflow happens. No scroll, no snap.
The MDN scroll snap guide nails this: enable scrolling with a fixed-size container and overflow. Your cards’ calc heights look smart (h-[calc((100vh-64px-4*1rem)/3)]), but without capping the parent, it just grows. Sticky header adds insult: snaps hit under the black bar at top-0.
Real-world snag? Expandable cards. When you click “View more,” heights jump via [&[opened]]:h-[...], but mandatory forces awkward snaps if a card overflows viewport height. Users flick-scroll past content stuck behind the header.
Fix 1: Give Your Scroll Container an Explicit Height
Start here. Replace min-h-screen wrapper behavior by setting the flex container to viewport height minus header: h-[calc(100vh-64px)]. Drop the py-4 padding or fold it into calcs for precision.
Why? Flexbox kids with gap-4 need a bounded parent to trigger overflow-y-auto. The Andromeda Galactic blog debugged this exact flex-column fail—no height meant no snap points calculated.
Update your container:
<div class="flex flex-col gap-4 h-[calc(100vh-64px)] overflow-y-auto snap-y snap-proximity scroll-py-16">
h-[calc(100vh-64px)] ensures it scrolls. Tailwind’s snap-proximity (we’ll tweak snap type next) previews nicely.
Test it: Without this, mousewheel or touch-flick does nothing snap-like. With it, momentum-scroll hints at snapping.
Fix 2: Offset Snap Points with Scroll Padding for Sticky Headers
Sticky headers hijack viewport top. Browsers snap child tops to 0, burying them under your 64px black bar. Solution: scroll-padding-top: 64px (Tailwind: scroll-pt-16 since 1rem=16px).
The CSS-Tricks practical guide calls this essential for fixed headers: it pads snap calculations, so first card aligns 64px down.
Your updated container becomes:
scroll-pt-16
Full class: h-[calc(100vh-64px)] overflow-y-auto snap-y snap-proximity scroll-pt-16
Pro tip: Match exactly—h-16 header? scroll-pt-16. Dynamic headers? JS to update scroll-padding-top.
Now scrolls snap below the header. Without it, first card’s title vanishes under black.
Fix 3: Use the Right Snap Alignment and Type on Cards
Snap-center centers cards—great for galleries, wrong for top-aligned reads. Switch to snap-start: aligns element’s top to container’s snap port (post-padding).
Container: snap-y snap-proximity
Cards: snap-start
Proximity > mandatory. Mandatory forces snaps always—even mid-tall card. Proximity snaps on momentum end, natural for reading. Tailwind docs warn: mandatory breaks tall kids.
Your cards:
<div class="... snap-start h-[calc((100vh-80px)/3)] [&[opened]]:h-[calc(100vh-64px)]">
Tweaked calc: subtract header + padding. gap-4 (1rem each) influences, but viewport math keeps thirds tidy closed, full-height open.
Stack Overflow threads like this flex-scroll fix echo: flex needs flex-shrink-0 on kids if needed, but your fixed heights handle it.
Fix 4: Handle Expandable Cards Without Breaking Snaps
Your toggle adds rows via attribute, ballooning height. Snap-mandatory fights this—snaps to top or next, hiding expanded content.
Stick with proximity: user scrolls freely inside open card, snaps to next on fling.
Refine heights:
- Closed: Roughly 1/3 viewport minus header/padding:
h-[calc((100vh-80px)/3)](64px header + 16px py-4). - Open: Near-full:
h-[calc(100vh-64px)](minus header, no padding bleed).
Add scroll-snap-stop: always (Tailwind arbitrary: snap-stop-always) on cards if you want forced stops on short flings, but proximity usually wins.
Edge case: Open card taller than container? Proximity lets overscroll; mandatory traps users. MDN forbids mandatory on overflow-prone kids.
Your tables already overflow-auto internally—perfect, content scrolls within card.
Complete Working Code Example
Here’s your code fixed. Copy-paste ready (add your toggle JS):
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<div class="min-h-screen bg-gray-100">
<!-- Sticky Header -->
<div class="h-16 p-4 flex gap-2 bg-black sticky top-0 z-10 flex-shrink-0">
<span class="text-white font-bold">Sticky Header</span>
</div>
<!-- Scroll Snap Container -->
<div class="flex flex-col gap-4 px-4 pb-4 h-[calc(100vh-64px)] overflow-y-auto snap-y snap-proximity scroll-pt-16">
<!-- Card 1 -->
<div class="card overflow-hidden transition-all duration-300 bg-red-300 snap-start
h-[calc((100vh-80px)/3)] [&[opened]]:h-[calc(100vh-64px)]">
<div class="p-4 flex flex-col h-full">
<div class="font-bold text-lg mb-2">Card 1</div>
<div class="flex-1 overflow-auto">
<table class="w-full border-collapse">
<!-- Your 15 rows here -->
<tbody>
<tr><td class="border p-2">Row 1</td></tr>
<!-- ... abbreviated for brevity ... -->
</tbody>
</table>
</div>
<button class="mt-4 px-4 py-2 bg-gray-200 rounded self-end hover:bg-gray-300 transition"
onclick="toggleCard(0, this)">View more</button>
</div>
</div>
<!-- Repeat for Card 2 (green-300) and Card 3 (blue-300) with same structure -->
<!-- Card 2 -->
<div class="card overflow-hidden transition-all duration-300 bg-green-300 snap-start
h-[calc((100vh-80px)/3)] [&[opened]]:h-[calc(100vh-64px)]">
<!-- Identical inner structure -->
</div>
<!-- Card 3 -->
<div class="card overflow-hidden transition-all duration-300 bg-blue-300 snap-start
h-[calc((100vh-80px)/3)] [&[opened]]:h-[calc(100vh-64px)]">
<!-- Identical inner structure -->
</div>
</div>
</div>
<script>
const cards = document.querySelectorAll('.card');
function toggleCard(index, btn) {
const card = cards[index];
const isOpen = card.hasAttribute('opened');
if (isOpen) {
card.removeAttribute('opened');
btn.textContent = 'View more';
} else {
card.setAttribute('opened', '');
btn.textContent = 'View less';
}
}
</script>
Scrolls snap to each card top, below header. Expand one—snaps gracefully to siblings.
Common Pitfalls and Testing Tips
- No mobile snap? Add
-webkit-overflow-scrolling: toucharbitrary class. - Calc precision: Use devtools to measure—
100vhincludes bars sometimes. - Flex gaps: They add to total height; test with
gap-0. - Browser quirks: Chrome/Safari solid; Firefox tweak
scroll-snap-type: y proximity. - Test: Trackpad fling, mobile swipe. Stack Overflow flex scroll flags
flex-shrinkneeds.
Tweak heights for your padding. Works on latest browsers (2025).
Sources
- https://css-tricks.com/practical-css-scroll-snapping/
- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll_snap/Basic_concepts
- https://andromedagalactic.com/blog/scroll-snap-sticky-header
- https://tailwindcss.com/docs/scroll-snap-type
- https://stackoverflow.com/questions/61759029/css-scroll-snap-not-snapping-on-to-sections
- https://stackoverflow.com/questions/62483752/scrolling-when-contained-in-a-flex-box
Conclusion
Nail CSS scroll snap with a height-bounded container, scroll-pt-16 for your sticky header, snap-start on cards, and snap-proximity for expandables. This setup delivers smooth, top-aligned snaps that respect your layout—try the code and fling-scroll to feel it click. Scale it to more cards or dynamic heights with the same principles.