Scroll Area

Scroll Area

【免费下载链接】base-ui Unstyled UI components for building accessible web apps and design systems. From the creators of Radix, Floating UI, and Material UI. 【免费下载链接】base-ui 项目地址: https://gitcode.com/GitHub_Trending/ba/base-ui

Base UI(@base-ui/react)中的 ScrollArea 是一个"原生滚动容器 + 自定义滚动条"的复合组件:它保留浏览器原生滚动的性能与可访问性,同时通过组件化的 Root / Viewport / Content / Scrollbar / Thumb / Corner 六件套和丰富的状态属性(overflow、scrolling、hovering 等),让你可以完全自定义滚动条外观与边缘渐变遮罩等交互效果。本文基于仓库中的官方 API 参考文档 types.md/react/components/scroll-area/types.md),逐部件拆解其 Props、Data Attributes、CSS Variables 与 State 类型,并结合 packages/react/src/scroll-area 下的源码实现,说明每个参数在底层是如何生效的。

一、组件全貌:Anatomy 与六个 Part

先看官方文档给出的标准结构(见 page.mdx/react/components/scroll-area/page.mdx)):

import { ScrollArea } from '@base-ui/react/scroll-area';

<ScrollArea.Root>
  <ScrollArea.Viewport>
    <ScrollArea.Content />
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
  <ScrollArea.Corner />
</ScrollArea.Root>;

各 Part 的职责一句话概括:

Part渲染元素职责
ScrollArea.Root<div>组织所有部件的容器,持有共享状态与滚动上下文
ScrollArea.Viewport<div>真正可滚动的容器(内部强制 overflow: scroll
ScrollArea.Content<div>内容的容器,负责让内容撑开视口
ScrollArea.Scrollbar<div>垂直或水平滚动条轨道
ScrollArea.Thumb<div>滚动条上可拖拽的滑块,指示当前滚动位置
ScrollArea.Corner<div>垂直与水平滚动条交叉处的小矩形

从源码看,这些 Part 通过 index.parts.ts 以命名空间方式导出(RootViewportScrollbarContentThumbCorner),类型则由 index.ts 统一 re-export。Root 通过 ScrollAreaRootContext.ts 向下传递 ref、滚动状态、溢出阈值等上下文,Viewport 再通过 ScrollAreaViewportContext.ts 提供 computeThumbPosition,形成"Root 测量、Viewport 滚动、Scrollbar/Thumb 呈现"的协作链路。

二、Root:全局状态与溢出边缘的枢纽

Root 将滚动区的所有部分组合在一起,渲染一个 <div> 元素。它的核心能力是测量内容溢出情况并驱动所有子部件的状态属性

Root Props

Prop类型默认值说明
overflowEdgeThresholdnumber \| Partial<{ xStart; xEnd; yStart; yEnd }>0溢出边缘属性被应用前必须超过的像素阈值。可传一个数字统一作用于四条边,也可传对象逐边配置
classNamestring \| ((state) => string \| undefined)-应用于元素的 CSS 类,或基于组件状态返回类的函数
styleReact.CSSProperties \| ((state) => React.CSSProperties \| undefined)-应用于元素的样式,或基于组件状态返回样式对象的函数
renderReactElement \| ((props, state) => ReactElement)-用不同标签或另一个组件替换默认 HTML 元素,可传元素或返回元素的函数

其中 overflowEdgeThreshold 是 Root 独有的业务属性。在 ScrollAreaRoot.tsx 中,normalizeOverflowEdgeThreshold 会把数字展开为四条边的配置,并统一用 Math.max(0, ...) 将负数收敛为 0;随后 computeThumbPosition 在每次滚动时用"从各边缘的滚动距离"与该阈值比较(源码位于 ScrollAreaViewport.tsx):

const nextOverflowEdges = {
  xStart: !scrollbarXHidden && scrollLeftFromStart > overflowEdgeThreshold.xStart,
  xEnd: !scrollbarXHidden && scrollLeftFromEnd > overflowEdgeThreshold.xEnd,
  yStart: !scrollbarYHidden && scrollTopFromStart > overflowEdgeThreshold.yStart,
  yEnd: !scrollbarYHidden && scrollTopFromEnd > overflowEdgeThreshold.yEnd,
};

也就是说,只有当滚动距离严格大于阈值时,对应的溢出边缘属性才会出现。这在实现"滚动 N 像素后才显示边缘阴影"的交互时非常有用。

Root Data Attributes

以下属性由 stateAttributes.ts 中的映射自动应用到元素上(布尔值为 true 时属性以空字符串值出现):

属性说明
data-has-overflow-x内容宽度超过视口宽度时出现
data-has-overflow-y内容高度超过视口高度时出现
data-overflow-x-end水平方向末端存在溢出时出现
data-overflow-x-start水平方向起始端存在溢出时出现
data-overflow-y-end垂直方向末端存在溢出时出现
data-overflow-y-start垂直方向起始端存在溢出时出现
data-scrolling用户在滚动区内部滚动时出现

注意 data-has-overflow-x 在源码中等价于"横向滚动条未隐藏"(hasOverflowX: !hiddenState.x,见 ScrollAreaRoot.tsx),隐藏状态由 getHiddenState 通过 clientHeight >= scrollHeight 判断得出。

data-scrolling 背后是 500ms 的超时机制:常量 SCROLL_TIMEOUT = 500 定义在 constants.ts 中,滚动事件触发后,500ms 无滚动即移除该属性。这在实现"滚动时显示、停止后隐藏滚动条"的经典效果时非常实用。

Root CSS Variables

变量类型说明
--scroll-area-corner-heightnumber滚动区角落(corner)的高度
--scroll-area-corner-widthnumber滚动区角落(corner)的宽度

这两个变量由 Root 根据测量到的 corner 实际尺寸写入内联样式(见 ScrollAreaRoot.tsx),常被 Scrollbar 用来让轨道避开 corner 区域(例如垂直滚动条 bottom: var(--scroll-area-corner-height))。

Root.Props 与 Root.State

  • ScrollArea.Root.Props:Root 属性的 re-export(等价于 ScrollAreaRootProps)。
  • ScrollArea.Root.State(即 ScrollAreaRootState):
type ScrollAreaRootState = {
  /** 是否正在滚动 */
  scrolling: boolean;
  /** 是否存在水平溢出 */
  hasOverflowX: boolean;
  /** 是否存在垂直溢出 */
  hasOverflowY: boolean;
  /** 水平轴 inline start 侧是否存在溢出 */
  overflowXStart: boolean;
  /** 水平轴 inline end 侧是否存在溢出 */
  overflowXEnd: boolean;
  /** block start 侧是否存在溢出 */
  overflowYStart: boolean;
  /** block end 侧是否存在溢出 */
  overflowYEnd: boolean;
  /** 滚动条 corner 是否隐藏 */
  cornerHidden: boolean;
};

State 会作为 className/style 函数的入参,例如:

<ScrollArea.Root
  className={(state) => (state.hasOverflowY ? styles.withScrollbar : styles.without)}
/>

三、Viewport:真正的滚动容器

Viewport 是滚动区实际可滚动的容器,渲染 <div>。源码中它被注入 style: { overflow: 'scroll' }(见 ScrollAreaViewport.tsx),并做了几件关键的事:

  • 无障碍焦点管理:当两个方向都没有溢出时,tabIndex-1(不进入 Tab 顺序);一旦可滚动则变为 0,保证键盘用户能聚焦滚动区域(对应 ScrollAreaViewport.tsx)。
  • role="presentation":从辅助技术视角隐藏容器本身。
  • ResizeObserver 监听:视口尺寸或内容尺寸变化时自动重算 thumb 位置;动画结束后(animation.finished)也会重新计算,避免 transform 动画干扰 thumb 几何。
  • RTL 支持:横向滚动计算在 direction === 'rtl' 时对 scrollLeft 取反处理(源码见 ScrollAreaViewport.tsx)。

Viewport CSS Variables(滚动渐变遮罩的关键)

Viewport 独有的四个 CSS 变量,表示从各边缘到当前滚动位置的像素距离

变量类型说明
--scroll-area-overflow-x-startnumber距水平起始边缘的距离(px)
--scroll-area-overflow-x-endnumber距水平末端边缘的距离(px)
--scroll-area-overflow-y-startnumber距垂直起始边缘的距离(px)
--scroll-area-overflow-y-endnumber距垂直末端边缘的距离(px)

这些变量在每次滚动帧内由 computeThumbPosition 写入(见 ScrollAreaViewport.tsx)。官方文档提供了用它们驱动 CSS mask 实现"滚动渐隐"的经典用法(page.mdx/react/components/scroll-area/page.mdx#L46-L75)):

.Viewport {
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    black min(40px, var(--scroll-area-overflow-y-start)),
    black calc(100% - min(40px, var(--scroll-area-overflow-y-end, 40px))),
    transparent 100%
  );
  mask-repeat: no-repeat;
}

当 fade 直接应用在 Viewport 自身时,变量可以直接使用;但为了性能,组件禁用了这些变量的继承(源码 removeCSSVariableInheritance 通过 CSS.registerProperty({ inherits: false }) 实现,WebKit/Safari 下自动跳过该优化),因此子元素必须显式 inherit 才可用

.Child {
  --scroll-area-overflow-y-start: inherit;
  --scroll-area-overflow-y-end: inherit;
}

对于 SSR,还可以在 var() 中提供兜底值,让遮罩在水合(hydrate)前就能显示:

var(--scroll-area-overflow-y-end, 40px);

一个完整的可运行示例见 demos/scroll-fade/css-modules/index.tsx/react/components/scroll-area/demos/scroll-fade/css-modules/index.tsx) 与对应的 index.module.css/react/components/scroll-area/demos/scroll-fade/css-modules/index.module.css)。

Viewport.Props 与 Viewport.State

  • Props 只有通用的 className / style / render(类型与 Root 相同,基于 ScrollArea.Viewport.State)。
  • Viewport.StateRoot.State 完全一致(源码 ScrollAreaViewportState extends ScrollAreaRootState)。

四、Content:内容容器

Content 是滚动区内容的容器,渲染 <div>。它主要负责:

  • 撑开内容宽度:内置 minWidth: 'fit-content',保证横向内容不会被压缩(见 ScrollAreaContent.tsx)。
  • 内容尺寸变化时重算:通过自己的 ResizeObserver 调用 computeThumbPosition,并处理"内容在视口首次测量之后才挂载"的边界情况(对应测试见 ScrollAreaRoot.test.tsx:内容后挂载时,溢出状态与 tabIndex 会随之更新)。

Props 同样只有 className / style / renderContent.StateRoot.State 一致。

五、Scrollbar 与 Thumb:可定制的滚动条

Scrollbar Props

Prop类型默认值说明
orientation'vertical' \| 'horizontal''vertical'控制垂直或水平滚动
keepMountedbooleanfalse视口不可滚动时是否将元素保留在 DOM 中
className / style / render同前-通用外观与替换能力

关键实现细节(见 ScrollAreaScrollbar.tsx):

  • keepMountedfalse(默认)时,对应方向不可滚动(hiddenState.x/hiddenState.y 为真)则返回 null 直接卸载,适合需要过渡动画的场景;若设为 true,元素常驻 DOM,配合 data-* 属性做显隐动画。
  • 轨道上的滚轮事件:在滚动条轨道上滚动时,组件会接管并把增量应用到 scrollTop/scrollLeft;到达边缘后放行事件(不 preventDefault),让滚轮事件正常冒泡到父级页面。
  • 轨道点击跳转:点击轨道(非 Thumb 区域)会按比例跳转滚动位置,并支持 RTL(scrollLeft 为负区间)。
  • 滚动条对辅助技术隐藏aria-hidden="true"

Scrollbar Data Attributes

属性类型说明
data-orientation'horizontal' \| 'vertical'滚动条方向
data-hovering-指针悬停在滚动区上时出现
data-scrolling-用户滚动时出现
data-has-overflow-x / data-has-overflow-y-对应方向存在溢出时出现
data-overflow-x/y-start / data-overflow-x/y-end-各边缘存在溢出时出现

data-hovering 是 Scrollbar 独有的状态:源码在 ScrollAreaRoot.tsx 中通过 pointerenter/pointermove 判断指针是否位于 Root 内部(contains(rootRef.current, event.target)),触摸(touch)模式下不触发 hover。示例中滚动条默认透明、data-hoveringdata-scrolling 时显现(见 index.module.css/react/components/scroll-area/demos/scroll-fade/css-modules/index.module.css#L60-L83))。

Scrollbar CSS Variables

变量类型说明
--scroll-area-thumb-heightnumber滚动条滑块的高度
--scroll-area-thumb-widthnumber滚动条滑块的宽度

这两个变量由 Root 测量后写入(thumbSize 状态),Thumb 通过 var(--scroll-area-thumb-height) / var(--scroll-area-thumb-width) 消费,实现"滑块尺寸随内容比例自动变化"。

Scrollbar.State

type ScrollAreaScrollbarState = {
  /** 滚动区是否被悬停 */
  hovering: boolean;
  /** 滚动区是否正在滚动 */
  scrolling: boolean;
  /** 滚动条方向 */
  orientation: 'vertical' | 'horizontal';
  /** 是否水平溢出 */
  hasOverflowX: boolean;
  /** 是否垂直溢出 */
  hasOverflowY: boolean;
  overflowXStart: boolean;
  overflowXEnd: boolean;
  overflowYStart: boolean;
  overflowYEnd: boolean;
  /** 滚动条 corner 是否隐藏 */
  cornerHidden: boolean;
};

注意 scrolling按方向区分的:垂直滚动条只响应纵向滚动、水平滚动条只响应横向滚动(vertical ? scrollingY : scrollingX,见 ScrollAreaScrollbar.tsx)。

Thumb

Thumb 是可拖拽的滑块,渲染 <div>。它没有额外 Props(仅 className / style / render),尺寸完全交给 CSS 变量驱动。其数据属性:

属性类型说明
data-orientation'horizontal' \| 'vertical'滑块方向(来自父级 Scrollbar 的上下文)
data-scrolling-用户滚动时出现

拖拽交互的底层实现在 ScrollAreaRoot.tsx:按下时记录指针位置与视口滚动起点,移动时按"滑块位移 / 轨道可移动距离"的比例换算滚动量,并做了一系列健壮性处理——多指同时按下时忽略后到的指针、pointercancel 时释放捕获、拖拽期间临时禁用 CSS scroll snap(disableViewportSnap,松手后恢复),避免滑块拖拽被吸附点打断。滑块有 16px 的最小尺寸(MIN_THUMB_SIZE = 16,见 constants.ts),轨道过短时会保护性地将滚动比例收敛为 0。

Thumb.State 相对精简:

type ScrollAreaThumbState = {
  /** 是否正在滚动 */
  scrolling: boolean;
  /** 组件方向 */
  orientation: 'horizontal' | 'vertical';
};

六、Corner:交叉处的补丁

Corner 是垂直与水平滚动条交叉处的小矩形区域,渲染 <div>,用于防止两个滚动条相互交叠(官方示例"Both scrollbars"即基于此,见 page.mdx/react/components/scroll-area/page.mdx#L34-L40))。

  • hiddenState.corner 为真(任意一个方向无溢出)时,Corner 返回 null(见 ScrollAreaCorner.tsx)。
  • 元素带 aria-hidden="true",定位为 position: absolute; bottom: 0; insetInlineEnd: 0,尺寸由 Root 的 cornerSize 状态提供。
  • Corner.State 是空对象 {}Corner.PropsclassName / style / render

七、附加类型与导出约定

Additional Types

文档还暴露了几个内部辅助类型:

type Coords = { x: number; y: number };
type HiddenState = { x: boolean; y: boolean; corner: boolean };
type OverflowEdges = { xStart: boolean; xEnd: boolean; yStart: boolean; yEnd: boolean };
type Size = { width: number; height: number };

它们分别对应滚动坐标、滚动条隐藏状态、溢出边缘布尔集合与尺寸测量结果,默认值定义在 ScrollAreaRoot.tsx

Export Groups 与 Canonical Types

命名空间导出与扁平类型名之间的对应关系(使用时注意:命名空间已导入时优先用 Canonical 名,否则用 Alias):

  • ScrollArea.RootScrollArea.RootScrollArea.Root.StateScrollArea.Root.Props
  • ScrollArea.ViewportScrollArea.ViewportScrollArea.Viewport.StateScrollArea.Viewport.Props
  • ScrollArea.ScrollbarScrollArea.ScrollbarScrollArea.Scrollbar.StateScrollArea.Scrollbar.Props
  • ScrollArea.ContentScrollArea.ContentScrollArea.Content.StateScrollArea.Content.Props
  • ScrollArea.ThumbScrollArea.ThumbScrollArea.Thumb.StateScrollArea.Thumb.Props
  • ScrollArea.CornerScrollArea.CornerScrollArea.Corner.StateScrollArea.Corner.Props
  • Default(扁平导出)→ HiddenStateOverflowEdgesSizeCoords 及全部 ScrollAreaXxxState/Props

Canonical ↔ Alias 映射示例:ScrollArea.Root.StateScrollAreaRootStateScrollArea.Scrollbar.PropsScrollAreaScrollbarProps,其余部件同理。

八、实战组合:与 Tabs 集成

官方文档还给出了一个高级组合:当 Tab 列表本身需要视口溢出变量做遮罩渐隐时,可以用 Tabs.Listrender 属性直接渲染 ScrollArea.Viewport,让遮罩逻辑与接收滚动状态的元素保持同一节点(见 page.mdx/react/components/scroll-area/page.mdx#L79-L94)):

<Tabs.Root defaultValue="overview">
  <ScrollArea.Root>
    <Tabs.List render={<ScrollArea.Viewport />}>
      <Tabs.Tab value="overview">Overview</Tabs.Tab>
      <Tabs.Indicator />
    </Tabs.List>
  </ScrollArea.Root>
  <Tabs.Panel value="overview">...</Tabs.Panel>
</Tabs.Root>

这正是 render 属性的用武之地:它允许任何部件"寄生"到另一个组件的元素上,保持语义结构不变的同时复用滚动能力。

结语

ScrollArea 的设计思路非常清晰:根组件负责状态测量,Viewport 负责原生滚动,Scrollbar/Thumb/Corner 只负责呈现与交互,全部通过 data-* 属性与 CSS 变量向外暴露状态。因此它既能保持原生滚动的性能与可达性,又给了样式层几乎无限的自由度——无论是自定义滚动条、边缘渐隐遮罩,还是与 Tabs 等组件组合,都能在不触碰内部实现的情况下完成。动手实践时,可以直接参考 demos/scroll-fade/react/components/scroll-area/demos/scroll-fade) 下的 CSS Modules 与 Tailwind 两个版本示例,并结合 ScrollAreaRoot.test.tsx 中关于溢出属性、RTL、后挂载内容的测试用例,验证你对每个状态属性触发时机的理解。

【免费下载链接】base-ui Unstyled UI components for building accessible web apps and design systems. From the creators of Radix, Floating UI, and Material UI. 【免费下载链接】base-ui 项目地址: https://gitcode.com/GitHub_Trending/ba/base-ui

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值