本文总结一种在“出现横向滚动且不在两端时,显示左右渐变提示”的可复用实现。适用于任何横向滚动区域,包含基于 shadcn/ui ScrollArea 的实现,以及纯 CSS/JS 方案与可访问性、性能优化要点。

为什么需要渐变遮罩

  • 提示“还有内容可滚动”,提升可发现性
  • 不占据布局空间,视觉干扰小
  • 易于主题适配(使用背景色到透明的渐变)

设计要点

  • 在滚动容器上方放置左右两个绝对定位的“渐变层”
  • scrollLeft > 0 显示左侧渐变;当 scrollLeft < scrollWidth - clientWidth 显示右侧渐变
  • 渐变层需 pointer-events: none 不拦截交互
  • 需监听 scroll 与尺寸变化,实时更新显示状态

基于 shadcn/ui ScrollArea 的实现

下面是一个可复用的组件,自动处理:

  • 渐变遮罩左右显示
  • 触控板纵向滚轮 → 横向映射(可选)
  • macOS 橡皮筋回弹的抑制(通过 passive: false + preventDefaultoverscroll-none
import React, { useEffect, useRef, useState } from "react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";

// 约束:children 需要是宽度可能溢出的横向内容(flex-nowrap 等)
export function HorizontalFadeScrollArea({
  className,
  viewportClassName = "overscroll-none",
  fadeWidth = 32,
  mapVerticalWheelToHorizontal = true,
  children,
}: {
  className?: string;
  viewportClassName?: string;
  fadeWidth?: number;
  mapVerticalWheelToHorizontal?: boolean;
  children: React.ReactNode;
}) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [showLeftFade, setShowLeftFade] = useState(false);
  const [showRightFade, setShowRightFade] = useState(false);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    const viewport = container.querySelector(
      '[data-slot="scroll-area-viewport"]'
    ) as HTMLElement | null;
    if (!viewport) return;

    const updateFades = () => {
      const max = viewport.scrollWidth - viewport.clientWidth;
      const cur = viewport.scrollLeft;
      setShowLeftFade(cur > 1);
      setShowRightFade(max > 0 && cur < max - 1);
    };

    const handleScroll = () => updateFades();

    let rafId: number | null = null;
    const handleWheel = (e: WheelEvent) => {
      if (!mapVerticalWheelToHorizontal) return;
      const delta = (e.deltaY || 0) + (e.deltaX || 0);
      if (delta !== 0) {
        e.preventDefault(); // 抑制弹性回弹 & 外层滚动
        // 使用 rAF 平滑滚动
        if (rafId) cancelAnimationFrame(rafId);
        rafId = requestAnimationFrame(() => {
          viewport.scrollLeft += delta;
          updateFades();
        });
      }
    };

    const ro = new ResizeObserver(updateFades);
    viewport.addEventListener("scroll", handleScroll, { passive: true });
    viewport.addEventListener("wheel", handleWheel, { passive: false });
    ro.observe(viewport);
    if (viewport.firstElementChild) ro.observe(viewport.firstElementChild);

    // 初始化一次
    updateFades();

    return () => {
      viewport.removeEventListener("scroll", handleScroll as EventListener);
      viewport.removeEventListener("wheel", handleWheel as EventListener);
      ro.disconnect();
      if (rafId) cancelAnimationFrame(rafId);
    };
  }, [mapVerticalWheelToHorizontal]);

  return (
    <div className="relative w-full" ref={containerRef}>
      <ScrollArea className={className} viewportClassName={viewportClassName}>
        {children}
        <ScrollBar orientation="horizontal" />
      </ScrollArea>

      {showLeftFade && (
        <div
          aria-hidden
          className="pointer-events-none absolute left-0 top-0 bottom-0 z-10"
          style={{
            width: fadeWidth,
            background:
              "linear-gradient(to right, var(--background) 0%, rgba(0,0,0,0) 100%)",
          }}
        />
      )}
      {showRightFade && (
        <div
          aria-hidden
          className="pointer-events-none absolute right-0 top-0 bottom-0 z-10"
          style={{
            width: fadeWidth,
            background:
              "linear-gradient(to left, var(--background) 0%, rgba(0,0,0,0) 100%)",
          }}
        />
      )}
    </div>
  );
}

用法(示例):

<HorizontalFadeScrollArea className="w-full" fadeWidth={32}>
  <div className="flex flex-nowrap gap-4 bg-muted/50 p-4 rounded-lg border border-border">
    {/* 水平内容 */}
  </div>
</HorizontalFadeScrollArea>

说明:

  • 使用了 CSS 变量 --background(shadcn/tailwind 环境默认有),深浅色主题自动适配
  • 若没有该变量,可替换为具体颜色或通过 Tailwind 类实现:
    • 左侧:bg-gradient-to-r from-background to-transparent
    • 右侧:bg-gradient-to-l from-background to-transparent

纯 CSS/JS 版本(不依赖 ScrollArea)

思路相同,将“滚动容器 + 渐变层”组合在一起,监听 scrollResizeObserver 即可。滚动容器用 overflow-x-auto whitespace-nowrap,渐变层绝对定位。

<div className="relative w-full">
  <div
    id="h-scroll"
    className="overscroll-none overflow-x-auto whitespace-nowrap"
    style={{ scrollBehavior: "smooth" }}
  >
    {/* 横向内容 */}
  </div>

  {/* 渐变遮罩层 */}
  <div className="pointer-events-none absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white to-transparent" />
  <div className="pointer-events-none absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white to-transparent" />
</div>

JS 监听逻辑和上面一致(略),注意初始化与 ResizeObserver 配合。

触控板与回弹优化

  • 将纵向滚轮映射为横向滚动:拦截 wheel,在区域内 preventDefault(),将 (deltaY + deltaX) 累加到 scrollLeft
  • 设置 overscroll-behavior 或 Tailwind overscroll-none,抑制外层滚动与橡皮筋回弹
  • 将 wheel 监听设为 passive: false 才能 preventDefault()

性能与可维护性

  • 使用 requestAnimationFrame 合并频繁滚动更新(上例已演示)
  • 避免在滚动回调里 setState 太频繁:只有状态变化时才 set
  • 复杂页面可将逻辑抽为 useHorizontalFade(viewportRef) 自定义 Hook

可访问性

  • 渐变层应加 aria-hiddenpointer-events: none,不阻碍交互与可聚焦元素
  • 保持原生滚动条和键盘滚动可用
  • 渐变仅作提示,不遮挡内容的可读性(必要时加淡阴影)

替代方案:CSS 遮罩(mask-image)

有些场景可用 mask-image 给“内容”加遮罩,而非叠加层:

.scroll-mask {
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 32px, #000 calc(100% - 32px), transparent 100%);
  mask-image: linear-gradient(to right, transparent 0, #000 32px, #000 calc(100% - 32px), transparent 100%);
}
  • 优点:无需额外 DOM
  • 注意:不同浏览器对 mask 支持差异、需要深浅色适配考虑

常见问题

  • 渐变不显示:内容不足以溢出,scrollWidth <= clientWidth,这是正常的;初始化时就应隐藏
  • 渐变遮盖点击:请确认 pointer-events: none
  • z-index 冲突:给遮罩 z-10,但不要遮挡浮层组件(Dropdown/Popover 等)

小结

  • 左右“渐变遮罩”是提示横向可滚动的简洁方案
  • 用 shadcn/ui ScrollArea 时,监听 viewport 即可;加 overscroll-nonepassive:false 防止回弹
  • 分装成复用组件/Hook,轻松在任何横向滚动区域复用
  • 代码要点:左右两侧绝对定位渐变层 + 滚动/尺寸监听 + 可选的滚轮横向映射

A Student on the way to full stack of Web3.