Scroll Observatory | 虚拟滚动观测台

Scroll Observatory 用 viewport、overscan 和滚动偏移把虚拟列表的可见窗口解释清楚,适合作为复杂滚动容器的交互调试案例。

效果展示

Loading interaction...

设计说明

场景

长列表经常需要给用户一个轻量提示:当前只看到了列表的一段,上方或下方仍然有内容。相比额外放一个说明文本,滚动容器边缘的渐隐更接近界面本身的 affordance。

交互设计

Scroll Observatory 参考 scroll fade list 的处理方式:列表居中放在安静的展示舞台里,顶部和底部使用渐变遮罩表达“还有内容”。用户滚动时不需要理解虚拟滚动算法,也能马上知道列表可以继续探索。

Fade edge

渐隐层覆盖在滚动区域上下边缘,不参与滚动内容布局。

Native scroll

列表保持浏览器原生滚动行为,避免为了视觉效果重写滚动机制。

Focused region

滚动区域可键盘聚焦,用户可以用触控板、鼠标滚轮或键盘继续浏览。

实现说明

最小模型由一个固定高度滚动容器、语义化列表和两个 pointer-events-none 渐变层组成。渐变层只负责视觉提示,不应该拦截点击、滚轮或键盘焦点。

可访问性与减少动态效果

这个案例不依赖动画。列表使用 aria-label 标记滚动区域,tabIndex={0} 让键盘用户可以进入滚动区。渐隐只是辅助提示,不是唯一的信息来源。

测试说明

测试时重点检查三件事:移动端没有水平溢出,滚动区域可以真正滚动,顶部和底部渐隐层不会遮挡交互。

可复制源码

源码从案例目录的 examples 文件读取,展示内容和参与 typecheck 的代码保持同一事实来源。

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