ScrollScroll Animationsno dependencies

ScrollFocusList

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

  1. Design in the browser
  2. Prototype with real data
  3. Review on any device
  4. Ship behind a flag
  5. Measure what changed
  6. Learn and iterate
  7. Repeat, faster

Customize

9 props
5
0.22
1.08
0.1

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add scroll-focus-list

Writes 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.

example.tsx
1import { ScrollFocusList } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ScrollFocusList />6  );7}

Props

PropTypeDefault
items

List entries; the one nearest the viewport center is in focus.

string[]['Design in the browser', 'Prototype with real data', 'Review on any device', 'Ship behind a flag', 'Measure what changed', 'Learn and iterate', 'Repeat, faster']
title

Small label pinned at the top; also the region's accessible name.

string'How we build'
blur

Maximum blur in px applied to entries far from center.

number5
minOpacity

Opacity of entries far from center (0 to 1).

number0.22
scale

Scale of the focused entry (1 disables scaling).

number1.08
accentColor

Accent color for the focused entry's index and underline.

string'#facc15'
align

Text alignment of the list.

'left' | 'center''left'
height

Height of the component's own scroll viewport in px (width is always 100%).

number380
borderRadius

Corner radius of the viewport in px.

number20
lerp

Interpolation factor per 60fps frame for wheel smoothing (0.05 floaty, 0.3 snappy).

number0.1
wheelMultiplier

Multiplier applied to wheel and trackpad deltas.

number1
smooth

Enable smooth wheel scrolling; false (or prefers-reduced-motion) uses native scrolling.

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/scroll-focus-list.tsx
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}