这篇文档系统讲解“边缘过渡遮罩”的交互设计与多种实现方式,帮助你在任何滚动区域、列表、图片容器、可拖拽面板等场景中灵活复用。
它解决什么问题
- 提示用户“还有内容可看”,但不打断阅读(对比箭头/提示文字的侵入性)。
- 与深浅色主题天然适配,视觉干扰小。
- 可移植到任意容器:横向/纵向/四边同时。
设计原则
- 仅在“可以继续滚动”的方向显示渐变遮罩。
- 渐变层不拦截交互: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/ui
的ScrollArea
,监听它的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,一次封装、处处复用。
- 记牢四要点:不挡交互、不改语义、主题适配、滚动/尺寸联动。
Comments NOTHING