Snap Scroll Headlines
A fixed prefix followed by a scroll-snapped word carousel: an invisible scroller drives a spring-tracked column so the active word snaps to center while its neighbours fade by distance. Font size, row height, and gap all scale from the container width so it reads from a full hero down to a card.
TextReactMotionTailwind CSS
CSSTailwind
Manual
Create a file and paste the following code into it.
snap-scroll-headlines.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useSpring } from "motion/react";
import { cn } from "@/lib/cn";
/* ------------------------------------------------------------------ */
/* SnapScrollHeadlines */
/* ------------------------------------------------------------------ */
interface SnapScrollHeadlinesProps {
items: string[];
/** Fixed word before the snapping list [Optional, default: "We Design"] */
prefix?: string;
/** Row height in px (auto-derived from font size at 80) [Optional] */
itemHeight?: number;
gap?: number;
/** Keep faded neighbours visible above/below the active word [Optional] */
showNeighbors?: boolean;
stiffness?: number;
damping?: number;
className?: string;
}
/**
* A fixed prefix followed by a scroll-snapped word carousel: an invisible
* scroller drives a spring-tracked column so the active word snaps to center
* while its neighbours fade by distance. Font size, row height, and gap all
* scale from the container width so it reads from a hero down to a card.
* @param {string[]} items - Words to cycle through [Required]
* @param {string} prefix - Fixed leading word [Optional, default: "We Design"]
*/
export function SnapScrollHeadlines({
items,
prefix = "We Design",
itemHeight = 80,
gap = 32,
showNeighbors = true,
stiffness = 280,
damping = 30,
className,
}: SnapScrollHeadlinesProps) {
const scrollerRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(0);
const [progress, setProgress] = useState(0);
const [height, setHeight] = useState(0);
const [width, setWidth] = useState(0);
const fontSize = width > 0 ? Math.min(Math.max(width * 0.075, 24), 96) : 48;
const rowHeight = itemHeight === 80 ? Math.round(fontSize * 1.6) : itemHeight;
const rowGap = Math.min(gap, width * 0.04);
const y = useMotionValue(0);
const springY = useSpring(y, { stiffness, damping, mass: 0.8 });
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const measure = () => {
setHeight(el.clientHeight);
setWidth(el.clientWidth);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const onScroll = useCallback(() => {
const el = scrollerRef.current;
if (!el || el.clientHeight === 0) return;
const raw = Math.min(Math.max(el.scrollTop / el.clientHeight, 0), items.length - 1);
const rounded = Math.round(raw);
setProgress(raw);
setActive(rounded);
y.set(-rounded * rowHeight);
}, [items.length, rowHeight, y]);
return (
<div className={cn("relative h-full w-full", className)}>
<div
ref={scrollerRef}
className="absolute inset-0 z-10 overflow-y-scroll [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{ scrollSnapType: "y mandatory" }}
onScroll={onScroll}
>
{height > 0 &&
items.map((_, i) => (
<div key={i} style={{ height, scrollSnapAlign: "start" }} />
))}
</div>
<div className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden">
<div className="flex items-center" style={{ gap: rowGap }}>
<span
className="select-none whitespace-nowrap font-semibold leading-none tracking-tight text-zinc-900 dark:text-zinc-100"
style={{ fontSize }}
>
{prefix}
</span>
<div className={showNeighbors ? undefined : "overflow-hidden"} style={{ height: rowHeight }}>
<motion.div style={{ y: springY }}>
{items.map((item, i) => {
const isActive = i === active;
const opacity = Math.max(0.15, 1 - 0.82 * Math.abs(i - progress));
return (
<div
key={i}
className="flex items-center whitespace-nowrap"
style={{ height: rowHeight, opacity }}
>
<span
className="select-none font-semibold leading-none tracking-tight"
style={{
fontSize,
color: isActive
? "hsl(var(--foreground))"
: "hsl(var(--foreground) / 0.35)",
transition: "color 150ms ease",
}}
>
{item}
</span>
</div>
);
})}
</motion.div>
</div>
</div>
</div>
</div>
);
}
Update the import paths to match your project setup.
Similar components
Install via CLI
Resource details
PublishedJuly 17, 2026
CategoryText
ReactMotionTailwind CSS
Install via CLI
Resource details
PublishedJuly 17, 2026
CategoryText
ReactMotionTailwind CSS