Scroll Observatory | Virtual Scroll Inspector

Scroll Observatory explains a virtual list with viewport, overscan, scroll offset, and rendered range signals for data-heavy React interface work.

Effect Showcase

Loading interaction...

Design Notes

Scenario

Long lists often need a lightweight signal that the user is seeing only one slice of the content. A fade at the scroll edge communicates that affordance without adding explanatory UI copy.

Interaction Design

Scroll Observatory follows the scroll fade list pattern: a centered list sits inside a quiet stage, while top and bottom gradient edges imply more content above or below. The interaction is readable before the user knows anything about virtualization internals.

Fade edge

The overlay sits above the scroll region and does not participate in content layout.

Native scroll

The list keeps browser-native scrolling instead of replacing scroll behavior for the visual effect.

Focused region

The scroll area can receive keyboard focus so wheel, trackpad, and keyboard users can all explore the list.

Implementation Notes

The smallest model is a fixed-height scroll container, a semantic list, and two pointer-events-none gradient layers. The layers only communicate affordance; they must not intercept pointer, wheel, or keyboard interaction.

Accessibility and Reduced Motion

This case does not depend on animation. The list uses aria-label for the scroll region and tabIndex={0} for keyboard access. The fade is a supporting cue, not the only way to understand the list.

Testing Notes

Test three boundaries: the mobile layout has no horizontal overflow, the list actually scrolls, and the fade layers do not block interaction.

Copyable Source

Source is read from the case examples folder, so the displayed snippet and typechecked code stay aligned.

"use client"; import { type ReactNode, useEffect, useRef } from "react"; const countries = [  "South Africa",  "Canada",  "Brazil",  "Japan",  "Germany",  "Paraguay",  "Netherlands",  "Morocco",  "Ivory Coast",  "Norway",  "Singapore",  "Argentina",  "New Zealand",  "Finland",  "Portugal",  "South Korea",]; type ScrollFadeViewportProps<TItem> = {  ariaLabel: string;  getKey: (item: TItem) => string;  items: readonly TItem[];  renderItem: (item: TItem) => ReactNode;  className?: string;  maxFadeHeight?: number;  scrollClassName?: string;}; function countryCode(country: string) {  return country    .split(" ")    .map((part) => part[0])    .join("")    .slice(0, 2)    .toUpperCase();} function ScrollFadeViewport<TItem>({  ariaLabel,  className = "relative overflow-hidden rounded-card border border-border bg-card [--scroll-fade-list-bg:hsl(var(--card))] [--scrollbar-gutter-width:13px]",  getKey,  items,  maxFadeHeight = 64,  renderItem,  scrollClassName = "site-scrollbar h-80 overflow-y-auto overscroll-contain [scrollbar-gutter:stable] outline-none focus-visible:ring-2 focus-visible:ring-ring",}: ScrollFadeViewportProps<TItem>) {  const frameRef = useRef<HTMLDivElement>(null);  const scrollRef = useRef<HTMLDivElement>(null);   useEffect(() => {    const frameElement = frameRef.current;    const scrollElement = scrollRef.current;    let animationFrame = 0;     if (!frameElement || !scrollElement) {      return;    }     const updateFadeHeights = () => {      const maxScrollTop = Math.max(        0,        scrollElement.scrollHeight - scrollElement.clientHeight,      );      const distanceFromTop = scrollElement.scrollTop;      const distanceFromBottom = maxScrollTop - scrollElement.scrollTop;       frameElement.style.setProperty(        "--top-fade-height",        `${Math.min(maxFadeHeight, Math.max(0, distanceFromTop))}px`,      );      frameElement.style.setProperty(        "--bottom-fade-height",        `${Math.min(maxFadeHeight, Math.max(0, distanceFromBottom))}px`,      );    };     const scheduleFadeUpdate = () => {      cancelAnimationFrame(animationFrame);      animationFrame = requestAnimationFrame(updateFadeHeights);    };     updateFadeHeights();    scrollElement.addEventListener("scroll", scheduleFadeUpdate, {      passive: true,    });     const resizeObserver =      typeof ResizeObserver === "undefined"        ? undefined        : new ResizeObserver(scheduleFadeUpdate);     resizeObserver?.observe(scrollElement);     if (scrollElement.firstElementChild) {      resizeObserver?.observe(scrollElement.firstElementChild);    }     return () => {      cancelAnimationFrame(animationFrame);      scrollElement.removeEventListener("scroll", scheduleFadeUpdate);      resizeObserver?.disconnect();    };  }, [maxFadeHeight]);   return (    <div      className={`${className} [--bottom-fade-height:0px] [--top-fade-height:0px] before:pointer-events-none before:absolute before:left-0 before:right-[var(--scrollbar-gutter-width)] before:top-0 before:z-10 before:h-[var(--top-fade-height)] before:bg-gradient-to-b before:from-[var(--scroll-fade-list-bg)] before:to-transparent before:content-[''] after:pointer-events-none after:absolute after:bottom-0 after:left-0 after:right-[var(--scrollbar-gutter-width)] after:z-10 after:h-[var(--bottom-fade-height)] after:bg-gradient-to-b after:from-transparent after:to-[var(--scroll-fade-list-bg)] after:content-['']`}      ref={frameRef}    >      <div        aria-label={ariaLabel}        className={scrollClassName}        ref={scrollRef}        role="region"        tabIndex={0}      >        <ul className="p-2">          {items.map((item) => (            <li              className="rounded-md border-b border-border/50 px-3 py-2 last:border-b-0"              key={getKey(item)}            >              {renderItem(item)}            </li>          ))}        </ul>      </div>    </div>  );} export function ScrollFadeList() {  return (    <div className="rounded-card border border-border bg-background p-2 shadow-soft md:p-2">      <div className="flex min-h-[480px] items-center justify-center rounded-card border border-border/70 bg-card/60 p-5 sm:p-6">        <ScrollFadeViewport          ariaLabel="Scroll fade country list"          className="relative w-full max-w-[340px] overflow-hidden rounded-card border border-border bg-card [--scroll-fade-list-bg:hsl(var(--card))] [--scrollbar-gutter-width:13px]"          getKey={(country) => country}          items={countries}          renderItem={(country) => (            <div className="flex min-h-8 items-center gap-3 text-sm">              <span className="inline-flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-muted font-mono text-[0.68rem] text-muted-foreground">                {countryCode(country)}              </span>              <span className="font-medium text-foreground">{country}</span>            </div>          )}        />      </div>    </div>  );} export default ScrollFadeList;

Agent Prompt

Agent Prompt

Build a Scroll Observatory interaction inspired by the scroll fade list pattern from dqnamo.com/experiments/scroll-fade-list. The result should be a compact React demo that centers a scrollable list inside a quiet framed stage, uses gradient fade edges to show that more content exists above or below, and keeps the site theme tokens instead of custom brand colors. Deliver a self-contained React component plus a small preview. Use native scrolling, a semantic list, stable row heights, subtle borders, and `prefers-reduced-motion` friendly styling. The case should stay easy to reuse in this Lab architecture: the demo, preview, content, and copyable example live inside the case folder; only the registry should need a new slug for the next case. Acceptance: the list is scrollable with visible top/bottom fade masks, the first viewport fits desktop and mobile without horizontal overflow, keyboard focus can reach the scroll region, the code example is the same source used by the demo, and the page has no console errors.