本文总结一种在“出现横向滚动且不在两端时,显示左右渐变提示”的可复用实现。适用于任何横向滚动区域,包含基于 shadcn/ui ScrollArea
的实现,以及纯 CSS/JS 方案与可访问性、性能优化要点。
为什么需要渐变遮罩
- 提示“还有内容可滚动”,提升可发现性
- 不占据布局空间,视觉干扰小
- 易于主题适配(使用背景色到透明的渐变)
设计要点
- 在滚动容器上方放置左右两个绝对定位的“渐变层”
- 当
scrollLeft > 0
显示左侧渐变;当scrollLeft < scrollWidth - clientWidth
显示右侧渐变 - 渐变层需
pointer-events: none
不拦截交互 - 需监听 scroll 与尺寸变化,实时更新显示状态
基于 shadcn/ui ScrollArea 的实现
下面是一个可复用的组件,自动处理:
- 渐变遮罩左右显示
- 触控板纵向滚轮 → 横向映射(可选)
- macOS 橡皮筋回弹的抑制(通过
passive: false + preventDefault
和overscroll-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)
思路相同,将“滚动容器 + 渐变层”组合在一起,监听 scroll
与 ResizeObserver
即可。滚动容器用 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
或 Tailwindoverscroll-none
,抑制外层滚动与橡皮筋回弹 - 将 wheel 监听设为
passive: false
才能preventDefault()
性能与可维护性
- 使用
requestAnimationFrame
合并频繁滚动更新(上例已演示) - 避免在滚动回调里 setState 太频繁:只有状态变化时才 set
- 复杂页面可将逻辑抽为
useHorizontalFade(viewportRef)
自定义 Hook
可访问性
- 渐变层应加
aria-hidden
并pointer-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-none
与passive:false
防止回弹 - 分装成复用组件/Hook,轻松在任何横向滚动区域复用
- 代码要点:左右两侧绝对定位渐变层 + 滚动/尺寸监听 + 可选的滚轮横向映射
Comments NOTHING