“Setup took an afternoon and the first report landed in my inbox the next morning. I have not opened the old spreadsheet since.”
A snapping card carousel, drawn by CSS
scroll-snap-type lands every swipe on a card, a CSS scroll-driven animation turns each card by how far it sits from the center, and the library supplies the rest: which card is current, arrows that step one card, dots that jump to one, and a mouse drag that releases onto the closest card.Swipe, drag, or use the arrows and dots — the rail always settles on a card, and the fan follows every pixel of the scroll.
Edit this example live in Storybook
Snap is CSS
scroll-snap-type: x mandatory on the rail, through scrollContainerClassName, and scroll-snap-align: center on every item, through itemClassName, are the whole snap. Touch, wheel and keyboard scrolling stay native, so the browser decelerates onto a card by itself. The rail’s side padding equals the space beside a centered card — that is what lets the first and the last card sit in the middle too.
The fan is a scroll-driven animation
Each card animates on its own view() timeline: its position inside the rail is the clock. The keyframes span two card pitches either side of the rail’s center, so the middle card is flat, each neighbour turns a further --tilt, and the angle updates with every pixel of scroll — nothing is measured and nothing re-renders. The rule sits under @supports (animation-timeline: view()): Chromium and Safari 26 draw it, Firefox still keeps the feature behind a flag and shows flat cards, with the snap and the dots working as before.
What JavaScript is left
onScroll finds the card whose center is closest to the rail’s center — getItemElementById for the node, offsetLeft against scrollLeft — and keeps its index in state for the dots, the card highlight and the arrows. Arrows step to a neighbour and dots jump to a card, both with scrollToItem(el, 'smooth', 'center'), which by construction lands on a snap point.
Drag has to switch snap off
A mandatory snap container snaps every programmatic scrollLeft write, so a mouse drag that moves the rail by hand would stutter from card to card. The drag sets scroll-snap-type: none inline for the gesture. On release the rail glides to the closest card with scrollToItem, and snapping comes back only once that glide has settled: re-enabling it earlier makes the browser jump to the snap point instantly instead of gliding there.
Notes
- Three custom properties are the knobs —
--card,--gapand--tilt— and every other length derives from them, the animation range included. - The infinite loop composes with this: a teleport moves by one loop length, which is a whole number of snap points. So does autoplay, a timer calling the same
scrollToItem. - The drag is the mouse-drag recipe without the click guard: the cards are not clickable, so nothing has to tell a drag from a click.
prefers-reduced-motionswitches the fan off. The snap stays, because it is how the rail scrolls, not decoration.
Full source
Complete and copy-paste ready — this is the exact file behind the live-editable Storybook version.
import 'react-horizontal-scrolling-menu/dist/styles.css';
import React from 'react';
import {
type publicApiType,
ScrollMenu,
VisibilityContext,
} from 'react-horizontal-scrolling-menu';
import { useDebounceCallback, useUnmount } from 'usehooks-ts';
// A testimonial carousel on native scrolling. CSS scroll-snap lands every
// swipe on a card and a CSS scroll-driven animation fans the neighbours
// out, so the browser draws the whole effect. JavaScript only tracks which
// card is centered, drives the arrows and dots through scrollToItem, and
// adds mouse drag — touch needs none, the rail is a real scroll container.
export function ScrollSnap() {
const [activeIndex, setActiveIndex] = React.useState(0);
// NOTE: for drag by mouse
const [dragManager] = React.useState(() => new DragDealer());
// A drag writes scrollLeft directly, and a mandatory snap container snaps
// every such write, so snapping is switched off for the gesture. It comes
// back once the rail has settled: re-enabling it mid-glide jumps instead.
const restoreSnap = useDebounceCallback((api: publicApiType) => {
const rail = api.scrollContainer.current;
if (rail && !dragManager.clicked) {
rail.style.scrollSnapType = '';
}
}, 150);
useUnmount(() => restoreSnap.cancel());
const handleScroll = (api: publicApiType) => {
setActiveIndex(nearestIndex(api));
restoreSnap(api);
};
const handleDragStart = (api: publicApiType) => (ev: React.MouseEvent) => {
const rail = api.scrollContainer.current;
if (rail) {
rail.style.scrollSnapType = 'none';
}
dragManager.dragStart(ev);
};
const handleDrag = (api: publicApiType) => (ev: React.MouseEvent) =>
dragManager.dragMove(ev, (delta) => {
const rail = api.scrollContainer.current;
if (rail) {
rail.scrollLeft += delta;
}
});
// Released between cards: glide to the closest one. The debounce above
// is armed here too, so a release that needs no glide still restores snap.
const handleDragStop = (api: publicApiType) => () => {
if (!dragManager.clicked) {
return;
}
dragManager.dragStop();
scrollToIndex(api, nearestIndex(api));
restoreSnap(api);
};
return (
<div className="reviews">
<style>{styles}</style>
<ScrollMenu
LeftArrow={<Arrow label="Previous review" target={activeIndex - 1} />}
RightArrow={<Arrow label="Next review" target={activeIndex + 1} />}
Footer={<Dots activeIndex={activeIndex} />}
scrollContainerClassName="reviews-rail"
itemClassName="review-slot"
onScroll={handleScroll}
onMouseDown={handleDragStart}
onMouseMove={handleDrag}
onMouseUp={handleDragStop}
onMouseLeave={handleDragStop}
>
{REVIEWS.map((review, index) => (
<Card
itemId={review.id} // NOTE: itemId is required for track items
key={review.id}
review={review}
active={index === activeIndex}
/>
))}
</ScrollMenu>
</div>
);
}
export default ScrollSnap;
interface Review {
id: string;
name: string;
role: string;
rating: number;
quote: string;
}
const REVIEWS: Review[] = [
{
id: 'mia',
name: 'Mia Chen',
role: 'Product designer',
rating: 5,
quote:
'Setup took an afternoon and the first report landed in my inbox the next morning. I have not opened the old spreadsheet since.',
},
{
id: 'tomas',
name: 'Tomás Reyes',
role: 'Engineering manager',
rating: 5,
quote:
'The weekly digest is the only email my team reads end to end. Short, specific, and it names the person who unblocked you.',
},
{
id: 'aisha',
name: 'Aisha Okafor',
role: 'Founder',
rating: 4,
quote:
'I wanted numbers I could defend in a board meeting. Every figure links back to the source, so nobody has to take my word for it.',
},
{
id: 'jonas',
name: 'Jonas Lindqvist',
role: 'Data analyst',
rating: 5,
quote:
'Exports are plain CSV with sane column names. That alone saved me a script I had been maintaining for two years.',
},
{
id: 'priya',
name: 'Priya Natarajan',
role: 'Operations lead',
rating: 5,
quote:
'Support answered a Sunday question in twenty minutes with a fix, not a ticket number. That is what I pay for.',
},
{
id: 'lucas',
name: 'Lucas Moreau',
role: 'Freelance developer',
rating: 4,
quote:
'The API does exactly what the docs say, and the docs are one page. I shipped the integration before lunch.',
},
];
// Index of the card whose center is closest to the rail's center. Item
// wrappers are positioned inside the rail, so offsetLeft and scrollLeft
// share one coordinate space.
function nearestIndex(api: publicApiType): number {
const rail = api.scrollContainer.current;
if (!rail) {
return 0;
}
const middle = rail.scrollLeft + rail.clientWidth / 2;
const distances = REVIEWS.map(({ id }) => {
const slot = api.getItemElementById(id) as HTMLElement | null;
return slot
? Math.abs(slot.offsetLeft + slot.offsetWidth / 2 - middle)
: Infinity;
});
return distances.indexOf(Math.min(...distances));
}
function scrollToIndex(api: publicApiType, index: number) {
const review = REVIEWS[index];
if (!review) {
return;
}
const slot = api.getItemElementById(review.id);
if (slot) {
api.scrollToItem(slot, 'smooth', 'center');
}
}
// Arrows step one card, not one page: the target is the neighbour of the
// centered card, and the arrow is disabled when there is none.
function Arrow({ label, target }: { label: string; target: number }) {
const api = React.useContext<publicApiType>(VisibilityContext);
const direction = label.startsWith('Prev') ? 'left' : 'right';
return (
<button
type="button"
className="review-arrow"
aria-label={label}
disabled={!REVIEWS[target]}
onClick={() => scrollToIndex(api, target)}
data-testid={`${direction}-arrow`}
>
{direction === 'left' ? '‹' : '›'}
</button>
);
}
function Dots({ activeIndex }: { activeIndex: number }) {
const api = React.useContext<publicApiType>(VisibilityContext);
return (
<div className="review-dots" role="group" aria-label="Reviews">
{REVIEWS.map((review, index) => (
<button
type="button"
key={review.id}
className="review-dot"
aria-label={`Review ${index + 1} of ${REVIEWS.length}`}
aria-current={index === activeIndex}
onClick={() => scrollToIndex(api, index)}
/>
))}
</div>
);
}
function Card({
review,
active,
}: {
itemId: string;
review: Review;
active: boolean;
}) {
const stars = '★'.repeat(review.rating) + '☆'.repeat(5 - review.rating);
return (
<article className="review-card" data-active={active}>
<header className="review-head">
<span className="review-avatar" aria-hidden>
{review.name[0]}
</span>
<span>
<strong className="review-name">{review.name}</strong>
<span className="review-role">{review.role}</span>
</span>
</header>
<span
className="review-stars"
role="img"
aria-label={`${review.rating} out of 5 stars`}
>
{stars}
</span>
<p className="review-quote">“{review.quote}”</p>
</article>
);
}
class DragDealer {
clicked = false;
position = 0;
dragStart = (ev: React.MouseEvent) => {
this.position = ev.clientX;
this.clicked = true;
};
dragStop = () => {
this.clicked = false;
};
dragMove = (ev: React.MouseEvent, cb: (delta: number) => void) => {
const delta = this.position - ev.clientX;
// 5px of slack keeps a plain click from nudging the rail.
if (this.clicked && Math.abs(delta) > 5) {
this.position = ev.clientX;
cb(delta);
}
};
}
// Plain CSS on purpose: the snap and the fan are the stylesheet's job, and
// this block pastes into any project unchanged. --card, --gap and --tilt
// are the knobs; every other length derives from them.
const styles = `
.reviews {
--card: min(280px, 80vw);
--gap: 16px;
--tilt: 8deg;
--pitch: calc(var(--card) + var(--gap));
font-family: system-ui, sans-serif;
}
.reviews-rail {
scroll-snap-type: x mandatory;
gap: var(--gap);
/* Side padding equal to the space beside a centered card, so the first
and last card can sit in the middle too. Bottom room is for the fan:
tilted neighbours hang below the flat card. */
padding: 16px calc(50% - var(--card) / 2) 40px;
cursor: grab;
user-select: none;
scrollbar-width: none;
}
.reviews-rail::-webkit-scrollbar {
display: none;
}
.review-slot {
flex: none;
scroll-snap-align: center;
}
.review-card {
box-sizing: border-box;
width: var(--card);
height: 100%;
padding: 20px;
border: 1px solid #d9dee5;
border-radius: 16px;
background: #fff;
color: #1c2430;
box-shadow: 0 8px 24px rgba(20, 30, 50, 0.1);
transform-origin: 50% 100%;
}
.review-card[data-active='true'] {
border-color: #7c8aa5;
}
/* The tilt is a scroll-driven animation: the card's own position in the
rail is the timeline. The keyframes span two card pitches either side of
the rail's center, so the middle card is flat and each neighbour turns
--tilt further, continuously, while you scroll. Without support the
cards simply stay flat; the snap and the dots work regardless. */
@supports (animation-timeline: view()) {
.review-card {
animation: review-tilt linear both;
animation-timeline: view(inline);
animation-range: cover calc(50% - 2 * var(--pitch))
cover calc(50% + 2 * var(--pitch));
}
}
@keyframes review-tilt {
from {
transform: translateY(24px) rotate(calc(2 * var(--tilt))) scale(0.88);
}
50% {
transform: none;
}
to {
transform: translateY(24px) rotate(calc(-2 * var(--tilt))) scale(0.88);
}
}
@media (prefers-reduced-motion: reduce) {
.review-card {
animation: none;
}
}
.review-head {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 12px;
}
.review-avatar {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: #2f6df6;
color: #fff;
font-weight: 700;
}
.review-name,
.review-role {
display: block;
font-size: 14px;
line-height: 1.3;
}
.review-role {
color: #5b6675;
}
.review-stars {
display: block;
margin-top: 12px;
color: #e2a400;
letter-spacing: 1px;
}
.review-quote {
margin: 16px 0 0;
font-size: 15px;
line-height: 1.5;
}
/* Arrows float over the rail rather than sit beside it, so the rail spans
the full width and its 50% padding centers the edge cards exactly. */
.reviews .react-horizontal-scrolling-menu--inner-wrapper {
position: relative;
}
.reviews .react-horizontal-scrolling-menu--arrow-left,
.reviews .react-horizontal-scrolling-menu--arrow-right {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 1;
}
.reviews .react-horizontal-scrolling-menu--arrow-left {
left: 8px;
}
.reviews .react-horizontal-scrolling-menu--arrow-right {
right: 8px;
}
.review-arrow {
width: 36px;
height: 36px;
border: 1px solid #d9dee5;
border-radius: 50%;
background: #fff;
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.review-arrow:disabled {
opacity: 0.3;
cursor: default;
}
.review-dots {
display: flex;
justify-content: center;
padding-top: 8px;
}
/* A 24px hit target (WCAG 2.5.8) drawing a 10px dot. */
.review-dot {
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.review-dot::before {
content: '';
width: 10px;
height: 10px;
border-radius: 50%;
background: #c7ced8;
}
.review-dot[aria-current='true']::before {
background: #2f6df6;
}
`;