Focus Carousel
Swipe through a row of cards that snap to the centre, where the focused card grows to full size and the rest recede.
Create a centre-focus card carousel with native scroll snapping and CSS scroll-driven animations. The track is a horizontal flex row with overflow-x: auto, scroll-snap-type: x mandatory, and inline padding of half the track width minus half a card so the first and last cards can reach the centre. Each card has scroll-snap-align: center. Animate each card with animation-timeline: view(inline) and a keyframe that goes from scale(0.8) and opacity 0.45 at 0%, to scale(1) and opacity 1 at 50%, back to scale(0.8) and opacity 0.45 at 100%, so the card at the centre is largest. Wrap the animation in @supports (animation-timeline: view()) so other browsers get a plain snapping row. Hide the scrollbar visually but keep the track keyboard-focusable. Under prefers-reduced-motion, drop the scaling and keep the snapping. The cards read Dawn, Noon, Dusk, Night, and Storm.
Source
'use client'
import s from './demo.module.css'
export default function SnapCarousel({ labels }: { labels: Record<string, string> }) {
return (
<div className={s.wrap}>
<div className={s.track} tabIndex={0} role="region" aria-label={labels.carousel}>
{labels.items.split('|').map((t, i) => (
<div key={t} className={s.card} style={{ '--h': 30 + i * 60 } as React.CSSProperties}>
<span className={s.n}>{String(i + 1).padStart(2, '0')}</span>
<strong className={s.name}>{t}</strong>
</div>
))}
</div>
<span className={s.hint}>{labels.hint}</span>
</div>
)
}
.wrap {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: 10px;
}
.track {
--card: 120px;
display: flex;
gap: 12px;
padding: 8px calc(50% - var(--card) / 2);
overflow-x: auto;
scroll-snap-type: x mandatory;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.track::-webkit-scrollbar { display: none; }
.track:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
.card {
flex: none;
display: grid;
align-content: space-between;
width: var(--card);
height: 150px;
padding: 12px;
border-radius: 14px;
background: linear-gradient(160deg, oklch(64% 0.1 var(--h)), oklch(36% 0.06 var(--h)));
scroll-snap-align: center;
}
.n { font: 500 var(--text-xs) / 1 var(--font-mono); color: oklch(100% 0 0 / 0.7); }
.name { font: 700 var(--text-xl) / 1 var(--font-display); color: oklch(98% 0.01 80); }
.hint {
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
text-align: center;
color: var(--stage-muted);
}
@supports (animation-timeline: view()) {
.card {
animation: focus linear both;
animation-timeline: view(inline);
}
}
@keyframes focus {
0%, 100% { transform: scale(0.8); opacity: 0.45; }
50% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card { animation: none; }
}