Scroll-Snap Peek Carousel — Free CSS Snap Carousel With Edge Peeking
Scroll-Snap Peek Carousel · Carousels · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<section class="pc-intro"><h1>Drag or scroll →</h1><p>Each slide is sized so the next and previous slides peek in at the edges, with the active slide scaling up based on real scroll position within the track.</p></section>
<div class="pc-track" id="pcTrack">
<div class="pc-slide"><div class="pc-card"><span class="pc-num">01</span><h3>Northwind</h3><p>Brand identity for a climate-tech studio.</p></div></div>
<div class="pc-slide"><div class="pc-card"><span class="pc-num">02</span><h3>Cadence</h3><p>A scheduling app rebuilt from the ground up.</p></div></div>
<div class="pc-slide"><div class="pc-card"><span class="pc-num">03</span><h3>Lumen</h3><p>Dashboard design for a solar analytics firm.</p></div></div>
<div class="pc-slide"><div class="pc-card"><span class="pc-num">04</span><h3>Harbor</h3><p>E-commerce for an independent boatyard.</p></div></div>
<div class="pc-slide"><div class="pc-card"><span class="pc-num">05</span><h3>Verde</h3><p>Packaging system for a plant-based label.</p></div></div>
<div class="pc-slide"><div class="pc-card"><span class="pc-num">06</span><h3>Ember</h3><p>Motion design for a live events platform.</p></div></div>
</div>
<div class="pc-dots" id="pcDots"></div>
<section class="pc-outro"><p>Notice neighboring slides never fully disappear — they peek at both edges.</p></section>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0b13;color:#fff}
.pc-intro{min-height:60vh;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:10px;padding:24px}
.pc-intro h1{font-size:clamp(30px,6vw,54px);letter-spacing:-.02em}
.pc-intro p{color:#9aa0b8;font-size:16px;max-width:500px}
.pc-track{display:flex;gap:20px;overflow-x:auto;scroll-snap-type:x mandatory;padding:6vh 14vw;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.pc-track::-webkit-scrollbar{display:none}
.pc-slide{flex:0 0 72%;max-width:520px;scroll-snap-align:center}
.pc-card{padding:44px 34px;border-radius:26px;background:linear-gradient(155deg,#1a2138,#11131f);border:1px solid #262d44;box-shadow:0 30px 60px rgba(0,0,0,.4);transform:scale(.88);opacity:.55;transition:transform .1s linear,opacity .1s linear;will-change:transform,opacity}
.pc-card.is-active{transform:scale(1);opacity:1}
.pc-num{font-size:13px;font-weight:800;letter-spacing:.14em;color:#7c8cff}
.pc-card h3{font-size:clamp(24px,4vw,34px);letter-spacing:-.01em;margin:10px 0 8px}
.pc-card p{color:#a7adc4;font-size:15px;line-height:1.55}
.pc-dots{display:flex;justify-content:center;gap:8px;padding:8px 0 4px}
.pc-dots span{width:7px;height:7px;border-radius:50%;background:#2a3148;transition:background .3s,transform .3s}
.pc-dots span.is-active{background:#22d3ee;transform:scale(1.4)}
.pc-outro{min-height:50vh;display:flex;justify-content:center;align-items:center;text-align:center;padding:24px}
.pc-outro p{color:#9aa0b8;font-size:16px;max-width:480px}(function () {
var track = document.getElementById('pcTrack');
var slides = Array.prototype.slice.call(document.querySelectorAll('.pc-slide'));
var cards = slides.map(function (s) { return s.querySelector('.pc-card'); });
var dotsWrap = document.getElementById('pcDots');
slides.forEach(function () {
var dot = document.createElement('span');
dotsWrap.appendChild(dot);
});
var dots = Array.prototype.slice.call(dotsWrap.children);
var ticking = false;
function update() {
ticking = false;
var trackRect = track.getBoundingClientRect();
var trackCenter = trackRect.left + trackRect.width / 2;
var closestIndex = 0;
var closestDist = Infinity;
slides.forEach(function (slide, i) {
var rect = slide.getBoundingClientRect();
var slideCenter = rect.left + rect.width / 2;
var dist = Math.abs(slideCenter - trackCenter);
if (dist < closestDist) {
closestDist = dist;
closestIndex = i;
}
// Scale and fade every card as a real, continuous function of its
// own distance from the track's center — not just a binary
// "active vs inactive" state — so dragging mid-scroll shows
// intermediate scale values on neighboring cards too.
var maxDist = rect.width * 0.9;
var proximity = Math.max(0, 1 - dist / maxDist);
var scale = 0.86 + proximity * 0.14;
var opacity = 0.5 + proximity * 0.5;
cards[i].style.transform = 'scale(' + scale.toFixed(3) + ')';
cards[i].style.opacity = opacity.toFixed(3);
});
dots.forEach(function (dot, i) { dot.classList.toggle('is-active', i === closestIndex); });
}
function onScroll() {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
}
track.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
// The track only has overflow-x, so a plain vertical mouse wheel does
// nothing by default -- only native horizontal gestures (trackpad swipe,
// shift+wheel) scroll it. Translate vertical wheel input into horizontal
// scroll whenever it's the dominant axis, so a normal mouse wheel works;
// leave real horizontal trackpad gestures (deltaX dominant) untouched.
track.addEventListener('wheel', function (e) {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
track.scrollLeft += e.deltaY;
}
}, { passive: false });
update();
dots.forEach(function (dot, i) {
dot.addEventListener('click', function () {
slides[i].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
});
});
})();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Scroll-Snap Peek Carousel — Neighbors Always Peek, Scale Follows Real Position

A one-slide-per-view carousel hides everything except the current slide. This one deliberately doesn't: each slide is sized to 72% of the track's width so the previous and next slides always peek in at both edges, giving a visual hint that there's more to scroll to. It's built with native CSS scroll-snap-type, so dragging, trackpad scrolling, and keyboard navigation all work for free — with a small vanilla JS layer adding scale and opacity driven by real position, not just snap-point membership.
CSS does the snapping, JS does the emphasis
.pc-track sets scroll-snap-type: x mandatory and each .pc-slide sets scroll-snap-align: center, which is enough on its own to make the carousel snap crisply to each slide with zero JavaScript — this works with touch drags, a trackpad, a mouse wheel with shift, or a scrollbar. JavaScript is layered on top purely for the visual polish: scaling the active card up and neighboring cards down as a continuous function of position.
Scale as a continuous function of distance, not a binary state
Rather than a simple "is this the active slide, yes or no" check, the scroll handler measures each slide's actual distance from the track's horizontal center via getBoundingClientRect() and computes a proximity value that falls off smoothly to zero at roughly 90% of a slide's width away. That proximity value drives both scale (0.86 to 1.0) and opacity (0.5 to 1.0) continuously — so mid-drag, before a slide has snapped, you see genuinely intermediate scale values on both the outgoing and incoming cards, not a jump-cut between two fixed states.
72% slides guarantee the peek
Each slide is sized to flex: 0 0 72% with generous 14vw horizontal padding on the track, which mathematically guarantees roughly 14% of both neighbors are visible at the track's edges at all times — an intentional layout choice, not an accident of viewport width, so the peek amount stays consistent across screen sizes.
Dots as both indicator and control
A row of dots below the track is generated to match the slide count; the closest slide to center is highlighted on scroll (computed in the same pass as the scale update), and clicking a dot calls scrollIntoView with inline: 'center', which respects the track's native snap behavior for a smooth scroll to that slide.
Customizing it
Adjust the 72% slide width for a wider or narrower peek, change the proximity falloff curve for a sharper or gentler scale transition, or add autoplay that calls scrollIntoView on an interval. Pair it with scroll-snap gallery or a scroll reveal grid for other layout patterns.
Step by step
How to Use
- 1Paste HTML, CSS, and JSAn intro, a peeking carousel track, dots, and an outro render.
- 2Drag or scroll the trackSlides snap to center; neighbors peek at both edges.
- 3Watch the scaleThe centered card scales up; neighbors shrink and dim.
- 4Drag slowly mid-scrollScale and opacity shift continuously, not in a jump-cut.
- 5Click a dotThe track smooth-scrolls that slide to center.
- 6Adjust the peek amountChange .pc-slide's flex-basis percentage.
Real-world uses
Common Use Cases
scroll-snap-type handling drag, wheel and touch for free.getBoundingClientRect distance rather than the snapped index.Got questions?
Frequently Asked Questions
No — scroll-snap-type: x mandatory on the track and scroll-snap-align: center on each slide are enough on their own for a fully functional snapping carousel that supports drag, trackpad, mouse wheel, and keyboard scrolling. The JavaScript only adds the scale-and-fade visual polish on top of behavior the browser already provides natively.
On scroll, each slide's getBoundingClientRect gives its actual horizontal center position, which is compared against the track's center to get a distance in pixels. That distance is converted to a proximity value between 0 and 1 that falls off smoothly as distance approaches about 90% of a slide's width, and proximity linearly drives both the scale (0.86 to 1.0) and opacity (0.5 to 1.0) — a continuous calculation, not a lookup based on which slide is "currently active."
Each slide is sized to flex: 0 0 72% of the track's content box, with the track itself padded 14vw on each side. Combined, this guarantees that when a slide is centered, the remaining ~28% of track width is split between showing slivers of the previous and next slides — a deliberate layout ratio, not something that depends on viewport width.
Yes — the scroll handler runs on every scroll event (throttled to once per animation frame), and because it measures live getBoundingClientRect positions rather than checking which slide has snapped, dragging the track partway between two slides shows genuinely intermediate scale and opacity values on both the outgoing and incoming cards.
Render the track and slides with refs, generate the dots array from your slide data, and in a mount effect attach the same rAF-throttled scroll listener to the track ref, reading getBoundingClientRect on the slide refs. Return a cleanup that removes the scroll and resize listeners. The CSS scroll-snap properties need no changes.