Focus Strip Scroll
A pinned horizontal image rail driven by vertical scroll: the strip slides sideways while a focus zone travels with it — the card under focus runs bright, sharp, and saturated while everything outside falls into blur, dimness, and grayscale. Per-card intensity derives from one GSAP scrub progress, with sensitivity, focus spread, travel, and filter strengths all tunable.
SliderReactGSAPTailwind CSS
CSSTailwind
Manual
Create a file and paste the following code into it.
focus-strip-scroll.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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
"use client";
import { type RefObject, useEffect, useRef, useState } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { cn } from "@/lib/cn";
/* ------------------------------------------------------------------ */
/* FocusStripScroll */
/* ------------------------------------------------------------------ */
export interface StripImage {
src: string;
alt?: string;
}
interface FocusStripScrollProps {
images: StripImage[];
/** Horizontal travel as % of the strip overflow [Optional, default: 100] */
travel?: number;
/** Max blur away from the focus zone [Optional, default: 10] */
blur?: number;
/** Min brightness % away from focus [Optional, default: 20] */
dim?: number;
/** Extra brightness at the focus zone [Optional, default: 45] */
brightnessBoost?: number;
/** Focus zone width as normalized progress [Optional, default: 0.16] */
focusSpread?: number;
/** Scale reduction away from focus [Optional, default: 0.09] */
scaleEffect?: number;
/** <1 stretches the scroll, >1 compresses it [Optional, default: 0.6] */
scrollSensitivity?: number;
gap?: string;
itemWidth?: number;
itemHeight?: number;
/** Section height in vh driving the scrub length [Optional, default: 280] */
scrollLength?: number;
scrollContainerRef?: RefObject<HTMLElement | null>;
className?: string;
}
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
/** 0 at the card's focus center, 1 fully out of the zone. */
function focusIntensity(progress: number, index: number, total: number, influence: number) {
if (total <= 1) return 0;
const center = index / (total - 1);
return clamp(Math.abs(progress - center) / clamp(influence, 0.04, 1), 0, 1);
}
/** Track how much wider the strip is than its viewport. */
function useStripOverflow(
viewportRef: RefObject<HTMLDivElement | null>,
trackRef: RefObject<HTMLDivElement | null>,
) {
const [maxShift, setMaxShift] = useState(0);
useEffect(() => {
const viewport = viewportRef.current;
const track = trackRef.current;
if (!viewport || !track) return;
const update = () =>
setMaxShift(Math.max(0, track.scrollWidth - viewport.clientWidth));
update();
const obs = new ResizeObserver(update);
obs.observe(viewport);
obs.observe(track);
return () => obs.disconnect();
}, [viewportRef, trackRef]);
return maxShift;
}
/**
* A pinned horizontal image rail driven by vertical scroll: the strip slides
* sideways while a focus zone travels with it — the card under focus runs
* bright, sharp, and saturated, everything outside falls into blur, dimness,
* and grayscale, with intensity computed per card from one scrub progress.
* @param {StripImage[]} images - Rail images [Required]
* @param {number} scrollLength - Section height in vh [Optional, default: 280]
*/
export function FocusStripScroll({
images,
travel = 100,
blur = 10,
dim = 20,
brightnessBoost = 45,
focusSpread = 0.16,
scaleEffect = 0.09,
scrollSensitivity = 0.6,
gap = "1.5rem",
itemWidth = 360,
itemHeight = 460,
scrollLength = 280,
scrollContainerRef,
className,
}: FocusStripScrollProps) {
const sectionRef = useRef<HTMLElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const maxShift = useStripOverflow(viewportRef, trackRef);
const effectiveShift = maxShift * (clamp(travel, 0, 100) / 100);
useEffect(() => {
const section = sectionRef.current;
const track = trackRef.current;
if (!section || !track) return;
const context = gsap.context(() => {
const cards = Array.from(track.querySelectorAll<HTMLElement>(".fss-item"));
const applyState = (rawProgress: number) => {
/* Sensitivity reshapes progress: exponent 1/s stretches the start. */
const p = Math.pow(clamp(rawProgress, 0, 1), 1 / clamp(scrollSensitivity, 0.25, 1.6));
gsap.set(track, { x: -effectiveShift * p });
cards.forEach((card, index) => {
const intensity = focusIntensity(p, index, cards.length, focusSpread);
const boosted = clamp(intensity * 1.35, 0, 1);
const peak = clamp(100 + brightnessBoost, 100, 220);
gsap.set(card, {
filter: `blur(${boosted * blur}px) brightness(${dim + (1 - boosted) * (peak - dim)}%) saturate(${(1 - boosted) * 100}%)`,
scale: 1 - boosted * scaleEffect,
});
});
};
applyState(0);
ScrollTrigger.create({
trigger: section,
scroller: scrollContainerRef?.current ?? undefined,
start: "top top",
end: "bottom bottom",
scrub: true,
onUpdate: (self) => applyState(self.progress),
});
}, sectionRef);
ScrollTrigger.refresh();
return () => context.revert();
}, [
scrollContainerRef,
images,
effectiveShift,
blur,
dim,
brightnessBoost,
focusSpread,
scaleEffect,
scrollSensitivity,
]);
return (
<section
ref={sectionRef}
className={cn("relative w-full", className)}
style={{ height: `${clamp(scrollLength, 140, 600)}vh` }}
>
<div ref={viewportRef} className="sticky top-0 h-dvh w-full overflow-hidden">
<div className="flex h-full w-full items-center">
<div ref={trackRef} className="flex w-max items-center px-[8vw]" style={{ gap }}>
{images.map((img, i) => (
<figure
key={img.src}
className="fss-item relative z-10 m-0 shrink-0 overflow-hidden rounded-xl"
style={{ width: itemWidth, height: itemHeight }}
>
<div
className="absolute inset-0 h-full w-full bg-cover bg-center"
style={{ backgroundImage: `url(${img.src})` }}
role="img"
aria-label={img.alt ?? `Image ${i + 1}`}
/>
</figure>
))}
</div>
</div>
</div>
</section>
);
}
Update the import paths to match your project setup.
Similar components
Install via CLI
Resource details
PublishedJuly 17, 2026
CategorySlider
ReactGSAPTailwind CSS
Install via CLI
Resource details
PublishedJuly 17, 2026
CategorySlider
ReactGSAPTailwind CSS