这篇文档系统讲解“边缘过渡遮罩”的交互设计与多种实现方式,帮助你在任何滚动区域、列表、图片容器、可拖拽面板等场景中灵活复用。

它解决什么问题

  • 提示用户“还有内容可看”,但不打断阅读(对比箭头/提示文字的侵入性)。
  • 与深浅色主题天然适配,视觉干扰小。
  • 可移植到任意容器:横向/纵向/四边同时。

设计原则

  • 仅在“可以继续滚动”的方向显示渐变遮罩。
  • 渐变层不拦截交互:pointer-events: none。
  • 不改变内容层级与结构;不要影响可访问性读取顺序。
  • 尽量以纯 CSS/计算派生实现,减少重排。

三大实现路线对比

  • 叠加层 Overlay(推荐)

    • 思路:在滚动容器上方放置绝对定位的线性渐变层,按需显示/隐藏。
    • 优点:兼容性最好、样式直观、易于主题适配。
    • 缺点:需要额外 DOM;与复杂层叠时注意 z-index。
  • CSS Mask(适合视觉纯净/不增加 DOM)

    • 思路:给“内容容器”应用 mask-image(Safari 用 -webkit-mask-image),让内容在边缘处逐渐透明。
    • 优点:无需额外 DOM;视觉融合感强。
    • 缺点:浏览器兼容和细节坑较多(尤其 Safari + 圆角)。
  • 观察者方案(触发逻辑)

    • scroll 计算 scrollLeft/Top 即可。
    • 或引入 IntersectionObserver 放哨兵节点,更稳定但略复杂。
    • 搭配 ResizeObserver 处理内容变化。

通用变量与主题适配

  • 使用 CSS 变量 --background 或 tailwind 的 from-background,自动跟随主题。
  • 深色模式下建议从容器背景衍生渐变颜色,避免“白雾”突兀。

方案一:Overlay 渐变层(横向)

HTML 结构建议:

<div className="relative">
  <div className="overflow-x-auto whitespace-nowrap" ref={viewportRef}>
    {children}
  </div>

  {/* 左侧渐变 */}
  {showLeft && (
    <div
      aria-hidden
      className="pointer-events-none absolute left-0 top-0 bottom-0 w-8 z-10 bg-gradient-to-r from-background to-transparent"
    />
  )}

  {/* 右侧渐变 */}
  {showRight && (
    <div
      aria-hidden
      className="pointer-events-none absolute right-0 top-0 bottom-0 w-8 z-10 bg-gradient-to-l from-background to-transparent"
    />
  )}
</div>

React Hook(滚动与尺寸联动):

import { useEffect, useState } from "react";

export function useHorizontalFade(viewport: React.RefObject<HTMLElement>) {
  const [showLeft, setShowLeft] = useState(false);
  const [showRight, setShowRight] = useState(false);

  useEffect(() => {
    const el = viewport.current;
    if (!el) return;

    const update = () => {
      const max = el.scrollWidth - el.clientWidth;
      const cur = el.scrollLeft;
      setShowLeft(cur > 1);
      setShowRight(max > 0 && cur < max - 1);
    };

    const ro = new ResizeObserver(update);
    el.addEventListener("scroll", update, { passive: true });
    ro.observe(el);
    if (el.firstElementChild) ro.observe(el.firstElementChild as Element);
    update();

    return () => {
      el.removeEventListener("scroll", update as EventListener);
      ro.disconnect();
    };
  }, [viewport]);

  return { showLeft, showRight };
}

横向滚轮体验(可选:触控板纵向→横向):

useEffect(() => {
  const el = viewportRef.current;
  if (!el) return;
  const onWheel = (e: WheelEvent) => {
    const delta = (e.deltaY || 0) + (e.deltaX || 0);
    if (delta !== 0) {
      e.preventDefault();
      el.scrollLeft += delta;
    }
  };
  el.addEventListener("wheel", onWheel, { passive: false });
  return () => el.removeEventListener("wheel", onWheel as EventListener);
}, []);

要点:

  • pointer-events: none 确保遮罩不挡点击。
  • overscroll-behavior/overscroll-none 抑制外层页面滚动与回弹。
  • 对于 shadcn/uiScrollArea,监听它的 viewport 即可。

方案二:mask-image 渐变(横向/纵向/四边)

左/右两侧 fade(横向):

/* 纯 CSS */
.mask-fade-x {
  /* Safari 需要 -webkit 前缀 */
  -webkit-mask-image: linear-gradient(to right,
    transparent 0,
    black 32px,
    black calc(100% - 32px),
    transparent 100%);
  mask-image: linear-gradient(to right,
    transparent 0,
    black 32px,
    black calc(100% - 32px),
    transparent 100%);
}

上/下两侧 fade(纵向):

.mask-fade-y {
  -webkit-mask-image: linear-gradient(to bottom,
    transparent 0,
    black 32px,
    black calc(100% - 32px),
    transparent 100%);
  mask-image: linear-gradient(to bottom,
    transparent 0,
    black 32px,
    black calc(100% - 32px),
    transparent 100%);
}

四边同时(轻微内缩):

.mask-fade-xy {
  -webkit-mask-image:
    radial-gradient(100% 100% at 50% 50%, black 60%, transparent 100%);
  mask-image:
    radial-gradient(100% 100% at 50% 50%, black 60%, transparent 100%);
}

注意:

  • mask 的“黑色”表示保留,“透明”表示隐藏(渐变透明)。
  • border-radius 同时使用在 Safari 可能出现锯齿,需要测试;复杂场景考虑 Overlay。

按需显示/隐藏(mask 的“窗口”动态收缩/扩展):

  • 可用 CSS 变量控制渐变阈值,JS 更新变量值实现显示/隐藏的平滑动画。

触发显示的计算方式

  • 滚动计算(简单稳健)

    • 横向:scrollLeft > 0 显示左;scrollLeft < scrollWidth - clientWidth 显示右
    • 纵向类似
  • 哨兵方案(更鲁棒)

    • 在内容左右各放一个宽度极小的“哨兵元素”
    • IntersectionObserver 观察是否“完全可见”
    • 不依赖数值计算,适合虚拟滚动/复杂布局

与组件库/系统的融合

  • shadcn/ui ScrollArea

    • 滚动容器是 data-slot="scroll-area-viewport" 对应的元素
    • 横向滚动条用 <ScrollBar orientation="horizontal" />
    • 监听它的 scroll/resize 即可;遮罩层放在组件外层 absolute 覆盖
  • 虚拟列表(react-window/virtuoso)

    • 遮罩逻辑不依赖内部渲染方式,只要能拿到 scrollContainer 即可
  • 移动端与触控板

    • overscroll-behavior: contain|none
    • 需要横向滚轮体验时,拦截 wheel 并 preventDefault(),监听需 passive: false

性能与可访问性

  • 仅状态变化时 setState;滚动频繁场景建议用 rAF 合并。
  • 渐变层 aria-hidden,不改变阅读顺序。
  • 遮罩层 z-index 不应压过弹层(如 Dropdown/Popover),必要时放在容器内层。

常见坑

  • Safari 的 mask-image 与圆角:必要时用 Overlay 方案规避。
  • 背景颜色选择:用容器背景(如 var(--background))更自然;纯白在深色主题会突兀。
  • 多层 overflow 与 sticky:确保“relative + absolute”层级关系清晰。

可复用组件 API 参考

type EdgeFadeProps = {
  showStart?: boolean; // 左/上
  showEnd?: boolean;   // 右/下
  direction?: "horizontal" | "vertical";
  size?: number;       // 渐变宽度/高度
  className?: string;
};

function EdgeFade({ showStart, showEnd, direction = "horizontal", size = 32 }: EdgeFadeProps) {
  const isX = direction === "horizontal";
  return (
    <>
      {showStart && (
        <div
          aria-hidden
          className={[
            "pointer-events-none absolute z-10",
            isX ? "left-0 top-0 bottom-0" : "top-0 left-0 right-0",
            isX ? "bg-gradient-to-r from-background to-transparent"
                : "bg-gradient-to-b from-background to-transparent"
          ].join(" ")}
          style={{ [isX ? "width" : "height"]: size } as React.CSSProperties}
        />
      )}
      {showEnd && (
        <div
          aria-hidden
          className={[
            "pointer-events-none absolute z-10",
            isX ? "right-0 top-0 bottom-0" : "bottom-0 left-0 right-0",
            isX ? "bg-gradient-to-l from-background to-transparent"
                : "bg-gradient-to-t from-background to-transparent"
          ].join(" ")}
          style={{ [isX ? "width" : "height"]: size } as React.CSSProperties}
        />
      )}
    </>
  );
}

配合 Hook:

function useEdgeFade(viewRef: React.RefObject<HTMLElement>, axis: "x" | "y") {
  const [start, setStart] = useState(false);
  const [end, setEnd] = useState(false);

  useEffect(() => {
    const el = viewRef.current;
    if (!el) return;
    const update = () => {
      const max = (axis === "x" ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight);
      const cur = axis === "x" ? el.scrollLeft : el.scrollTop;
      setStart(cur > 1);
      setEnd(max > 0 && cur < max - 1);
    };
    const ro = new ResizeObserver(update);
    el.addEventListener("scroll", update, { passive: true });
    ro.observe(el);
    if (el.firstElementChild) ro.observe(el.firstElementChild as Element);
    update();
    return () => {
      el.removeEventListener("scroll", update as EventListener);
      ro.disconnect();
    };
  }, [viewRef, axis]);

  return { showStart: start, showEnd: end };
}

结论

  • 首选 Overlay 叠加层,简单、直观、兼容好;对浏览器细节不敏感。
  • 追求零额外 DOM 时用 mask-image,但注意 Safari 细节与圆角。
  • 把“显示条件 + 渐变层”做成通用组件/Hook,一次封装、处处复用。
  • 记牢四要点:不挡交互、不改语义、主题适配、滚动/尺寸联动。

A Student on the way to full stack of Web3.