A large-type list inside a smooth-scroll viewport where the entry nearest the center is sharp, bright, scaled up and underlined in the accent color, while every other entry blurs and dims continuously with its distance from center.
Preview
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add scroll-focus-listWrites components/ui/scroll-focus-list.tsx and components/styles/scroll-focus-list.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ScrollFocusList } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ScrollFocusList />6 );7}Props
Accessibility
The viewport is a focusable role="region" labelled by the title, so keyboard users can Tab to it and scroll with arrows/Page keys/Space (native) with a 2px :focus-visible outline. Entries are an ordered list of real text that stays in the accessibility tree regardless of blur; index numbers, the pinned title chip and edge fades are aria-hidden. Wheel input is smoothed only while the viewport can still move and is released at the edges so the page keeps scrolling; touch and scrollbar dragging stay native. Under prefers-reduced-motion the wheel is not intercepted and blur/scale are disabled (entries only dim).
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/scroll-focus-list.module.css";5 6interface SmoothScrollState {7 /** Current scrollTop of the viewport in px */8 scroll: number;9 /** Maximum scrollTop in px */10 limit: number;11 /** scroll / limit, 0..1 */12 progress: number;13}14 15interface SmoothScrollOptions {16 lerp: number;17 wheelMultiplier: number;18 smooth: boolean;19 onScroll?: (state: SmoothScrollState) => void;20}21 22const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));23 24function usePrefersReducedMotion(): boolean {25 const [reduced, setReduced] = React.useState(false);26 React.useEffect(() => {27 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");28 const update = () => setReduced(mq.matches);29 update();30 mq.addEventListener("change", update);31 return () => mq.removeEventListener("change", update);32 }, []);33 return reduced;34}35 36/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */37function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {38 let el: Element | null = node instanceof Element ? node : null;39 while (el && el !== root) {40 if (el instanceof HTMLElement) {41 const overflowY = window.getComputedStyle(el).overflowY;42 if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight + 1) {43 if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true;44 }45 }46 el = el.parentElement;47 }48 return false;49}50 51/**52 * Self-contained smooth scroller for an overflow container: wheel input sets a target and a rAF53 * loop eases scrollTop toward it. Touch, keyboard and scrollbar stay native; wheel is released at54 * the edges so the page keeps scrolling.55 */56function useSmoothScroll(57 containerRef: React.RefObject<HTMLElement | null>,58 { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,59) {60 const callbackRef = React.useRef(onScroll);61 React.useEffect(() => {62 callbackRef.current = onScroll;63 }, [onScroll]);64 65 React.useEffect(() => {66 const el = containerRef.current;67 if (!el) return;68 const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);69 const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;70 let target = el.scrollTop;71 let current = target;72 let raf = 0;73 let lastTime = 0;74 const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);75 const emit = () => {76 const limit = getLimit();77 const scroll = clamp(el.scrollTop, 0, limit);78 callbackRef.current?.({ scroll, limit, progress: limit > 0 ? scroll / limit : 0 });79 };80 const tick = (time: number) => {81 const dt = lastTime ? Math.min(64, Math.max(1, time - lastTime)) : 16.667;82 lastTime = time;83 target = clamp(target, 0, getLimit());84 if (Math.abs(el.scrollTop - current) > 2) {85 current = target = el.scrollTop;86 raf = 0;87 lastTime = 0;88 emit();89 return;90 }91 current += (target - current) * (1 - Math.pow(1 - ease, dt / 16.667));92 if (Math.abs(target - current) < 0.35) current = target;93 el.scrollTop = current;94 emit();95 if (current === target) {96 raf = 0;97 lastTime = 0;98 return;99 }100 raf = requestAnimationFrame(tick);101 };102 const onWheel = (event: WheelEvent) => {103 if (event.ctrlKey || event.defaultPrevented || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;104 let dy = event.deltaY;105 if (event.deltaMode === 1) dy *= 16;106 else if (event.deltaMode === 2) dy *= el.clientHeight;107 dy *= multiplier;108 if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;109 const limit = getLimit();110 if (!raf) current = target = el.scrollTop;111 if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;112 event.preventDefault();113 target = clamp(target + dy, 0, limit);114 if (!raf) {115 lastTime = 0;116 raf = requestAnimationFrame(tick);117 }118 };119 const onNativeScroll = () => {120 if (raf) return;121 current = target = el.scrollTop;122 emit();123 };124 if (smooth) el.addEventListener("wheel", onWheel, { passive: false });125 el.addEventListener("scroll", onNativeScroll, { passive: true });126 const ro = new ResizeObserver(emit);127 ro.observe(el);128 if (el.firstElementChild) ro.observe(el.firstElementChild);129 emit();130 return () => {131 if (smooth) el.removeEventListener("wheel", onWheel);132 el.removeEventListener("scroll", onNativeScroll);133 ro.disconnect();134 if (raf) cancelAnimationFrame(raf);135 };136 }, [containerRef, lerp, wheelMultiplier, smooth]);137}138 139export interface ScrollFocusListProps {140 /** List entries; the one nearest the viewport center is in focus */141 items?: string[];142 /** Small label pinned at the top (also the region label) */143 title?: string;144 /** Maximum blur in px applied to entries far from center */145 blur?: number;146 /** Opacity of entries far from center (0..1) */147 minOpacity?: number;148 /** Scale of the focused entry (1 = no scaling) */149 scale?: number;150 /** Accent color for the focused entry marker */151 accentColor?: string;152 /** Text alignment of the list */153 align?: "left" | "center";154 /** Viewport height in px */155 height?: number;156 /** Viewport corner radius in px */157 borderRadius?: number;158 /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */159 lerp?: number;160 /** Multiplier applied to wheel / trackpad deltas */161 wheelMultiplier?: number;162 /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */163 smooth?: boolean;164 /** Optional className merged onto the root */165 className?: string;166 /** Inline styles merged onto the root */167 style?: React.CSSProperties;168}169 170const DEFAULT_ITEMS = [171 "Design in the browser",172 "Prototype with real data",173 "Review on any device",174 "Ship behind a flag",175 "Measure what changed",176 "Learn and iterate",177 "Repeat, faster",178];179 180export function ScrollFocusList({181 items = DEFAULT_ITEMS,182 title = "How we build",183 blur = 5,184 minOpacity = 0.22,185 scale = 1.08,186 accentColor = "#facc15",187 align = "left",188 height = 380,189 borderRadius = 20,190 lerp = 0.1,191 wheelMultiplier = 1,192 smooth = true,193 className = "",194 style,195}: ScrollFocusListProps) {196 const reduced = usePrefersReducedMotion();197 const viewportRef = React.useRef<HTMLDivElement>(null);198 const itemRefs = React.useRef<(HTMLLIElement | null)[]>([]);199 const scrollRef = React.useRef(0);200 201 const apply = React.useCallback(() => {202 const vp = viewportRef.current;203 if (!vp) return;204 const center = scrollRef.current + vp.clientHeight / 2;205 const half = Math.max(1, vp.clientHeight * 0.5);206 let nearest = -1;207 let best = Infinity;208 itemRefs.current.forEach((item, i) => {209 if (!item) return;210 const dist = Math.abs(item.offsetTop + item.offsetHeight / 2 - center);211 if (dist < best) {212 best = dist;213 nearest = i;214 }215 item.style.setProperty("--mf-sfl-d", clamp(dist / half, 0, 1).toFixed(4));216 });217 itemRefs.current.forEach((item, i) => {218 if (item) item.dataset.active = String(i === nearest);219 });220 }, []);221 222 const handleScroll = React.useCallback(223 (state: SmoothScrollState) => {224 scrollRef.current = state.scroll;225 apply();226 },227 [apply],228 );229 230 useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });231 232 React.useEffect(() => {233 apply();234 }, [apply, items, height]);235 236 const vars = {237 "--mf-sfl-h": `${height}px`,238 "--mf-sfl-r": `${borderRadius}px`,239 "--mf-sfl-accent": accentColor,240 "--mf-sfl-blur": `${reduced ? 0 : Math.max(0, blur)}px`,241 "--mf-sfl-min": clamp(minOpacity, 0, 1),242 "--mf-sfl-scale": reduced ? 1 : scale,243 ...style,244 } as React.CSSProperties;245 246 return (247 <div style={vars} className={`${styles.root} ${className}`.trim()} data-align={align}>248 <span className={styles.kicker} aria-hidden="true">249 {title}250 </span>251 <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={title}>252 <ol className={styles.list}>253 {items.map((item, i) => (254 <li255 key={`${item}-${i}`}256 ref={(node) => {257 itemRefs.current[i] = node;258 }}259 className={styles.item}260 data-active={i === 0 ? "true" : "false"}261 >262 <span className={styles.index} aria-hidden="true">263 {String(i + 1).padStart(2, "0")}264 </span>265 <span className={styles.text}>{item}</span>266 </li>267 ))}268 </ol>269 </div>270 <span aria-hidden="true" className={styles.fadeTop} />271 <span aria-hidden="true" className={styles.fadeBottom} />272 </div>273 );274}