在面板内向下滚动,超过 120px 后显示回顶按钮。
明确操作的主次
一个区域通常只需要一个主要操作。次要操作放在内容附近,低频操作可以收到菜单里,让常用路径保持清晰。
给出可见的反馈
保存、上传和同步都需要反馈。等待时保留内容,完成后给出结果;失败时保留用户输入,让用户能够直接重试。
让长内容易于浏览
用标题建立结构,用间距区分段落。列表很长时保留稳定的滚动位置,提供返回开头的入口,而不是在内容更新时强制滚动。
照顾不同输入方式
鼠标、触摸和键盘都应当能完成操作。焦点清晰可见,图标按钮有明确名称,浮层关闭后将焦点返回合理的位置。
减少不必要的动态效果
动效用来解释状态和位置的变化。系统开启减少动态效果时,保留操作结果,让用户不必等待滚动或移动动画。
最后检查真实场景
换一组更长的文案,缩窄窗口,试用键盘,再检查深色模式。组件只有在这些场景里都能工作,才算完成。
'use client'
import { useRef } from 'react'
import {
Card,
Heading,
ScrollArea,
ScrollTop,
Stack,
Text,
type ScrollAreaHandle,
} from '@hina-ui/react'
const sections = [
{
title: '明确操作的主次',
body: '一个区域通常只需要一个主要操作。次要操作放在内容附近,低频操作可以收到菜单里,让常用路径保持清晰。',
},
{
title: '给出可见的反馈',
body: '保存、上传和同步都需要反馈。等待时保留内容,完成后给出结果;失败时保留用户输入,让用户能够直接重试。',
},
{
title: '让长内容易于浏览',
body: '用标题建立结构,用间距区分段落。列表很长时保留稳定的滚动位置,提供返回开头的入口,而不是在内容更新时强制滚动。',
},
{
title: '照顾不同输入方式',
body: '鼠标、触摸和键盘都应当能完成操作。焦点清晰可见,图标按钮有明确名称,浮层关闭后将焦点返回合理的位置。',
},
{
title: '减少不必要的动态效果',
body: '动效用来解释状态和位置的变化。系统开启减少动态效果时,保留操作结果,让用户不必等待滚动或移动动画。',
},
{
title: '最后检查真实场景',
body: '换一组更长的文案,缩窄窗口,试用键盘,再检查深色模式。组件只有在这些场景里都能工作,才算完成。',
},
]
export default function Demo() {
const area = useRef<ScrollAreaHandle>(null)
return (
<Stack className="w-full max-w-lg" gap="sm">
<Text size="sm" tone="muted">
在面板内向下滚动,超过 120px 后显示回顶按钮。
</Text>
<Card padded={false} className="relative overflow-hidden">
<ScrollArea ref={area} className="h-72" shadow={false} focusable label="交互设计笔记">
<Stack gap="lg" className="p-5 pb-24">
{sections.map(section => (
<Stack key={section.title} gap="sm">
<Heading level={3} size="base">
{section.title}
</Heading>
<Text size="sm" tone="muted">
{section.body}
</Text>
</Stack>
))}
</Stack>
</ScrollArea>
<ScrollTop
target={() => area.current?.viewport}
threshold={120}
position="absolute"
offset={16}
/>
</Card>
</Stack>
)
}
用法
import { ScrollTop } from '@hina-ui/react'
不传 target 时监听页面 window,默认滚动超过 300px 后显示。点击只改变纵向位置,保留横向位置。
<ScrollTop />
页面由 AppShell、ScrollArea 或 Dialog 内部容器滚动时,传入它们暴露的 viewport。滚动目标与按钮定位是独立的:target 决定滚动哪个元素,position 决定按钮摆在哪里。
'use client'
import { useRef } from 'react'
import { Card, ScrollArea, ScrollTop, type ScrollAreaHandle } from '@hina-ui/react'
export function History() {
const area = useRef<ScrollAreaHandle>(null)
return (
<Card className="relative" padded={false}>
<ScrollArea ref={area} className="h-72">
…
</ScrollArea>
<ScrollTop target={() => area.current?.viewport} position="absolute" />
</Card>
)
}
AppShell 对应 () => shell.current?.mainViewport。默认固定定位时,将按钮放在应用壳外层;局部使用则放在滚动区域的外面、定位容器里面,避免按钮跟着内容一起滚走。
示例
外观、滚动方式与焦点
沿用 FloatButton 的尺寸、形状、文字和定位。默认平滑滚动;behavior="instant" 立即定位。系统开启减少动态效果时,总是立即定位。
默认保留键盘用户的按钮焦点,直到焦点离开才隐藏。也可以通过 focusTarget 将焦点明确移到顶部标题;目标需可聚焦,例如设置 tabIndex={-1} 的标题。
发布检查项
验证核心流程
检查空状态
检查加载和重试
检查键盘操作
检查表单错误
检查移动端排版
检查深色模式
检查 RTL
检查服务端渲染
核对文档示例
补齐变更记录
确认发布版本
回顶后将焦点移到标题,可以从这里继续键盘浏览。
'use client'
import { useRef, useState } from 'react'
import { ChevronsUp } from 'lucide-react'
import {
Button,
Card,
FormField,
Heading,
ScrollArea,
ScrollTop,
Stack,
Switch,
Text,
type ScrollAreaHandle,
} from '@hina-ui/react'
export default function Demo() {
const area = useRef<ScrollAreaHandle>(null)
const heading = useRef<HTMLHeadingElement>(null)
const [instant, setInstant] = useState(false)
function goDown() {
area.current?.viewport?.scrollTo({ top: 450, behavior: 'instant' })
}
return (
<Stack className="w-full max-w-md" gap="sm">
<FormField label="立即回顶" orientation="horizontal">
<Switch checked={instant} onCheckedChange={setInstant} />
</FormField>
<Button variant="outline" tone="neutral" className="self-start" onClick={goDown}>
滚动到中段
</Button>
<Card padded={false} className="relative overflow-hidden">
<ScrollArea ref={area} className="h-64" shadow={false} focusable label="发布检查项">
<Stack className="p-5 pb-24" gap="lg">
<Heading
ref={heading}
level={3}
size="base"
tabIndex={-1}
className="hn-focus-ring rounded-sm"
>
发布检查项
</Heading>
{[
'验证核心流程',
'检查空状态',
'检查加载和重试',
'检查键盘操作',
'检查表单错误',
'检查移动端排版',
'检查深色模式',
'检查 RTL',
'检查服务端渲染',
'核对文档示例',
'补齐变更记录',
'确认发布版本',
].map(item => (
<Text key={item} size="sm" className="border-line border-b pb-3">
{item}
</Text>
))}
</Stack>
</ScrollArea>
<ScrollTop
target={() => area.current?.viewport}
focusTarget={() => heading.current}
threshold={80}
behavior={instant ? 'instant' : 'smooth'}
position="absolute"
offset={16}
extended
size="sm"
shape="square"
variant="soft"
tone="accent"
label="返回检查项开头"
>
<ChevronsUp />
</ScrollTop>
</Card>
<Text size="sm" tone="muted">
回顶后将焦点移到标题,可以从这里继续键盘浏览。
</Text>
</Stack>
)
}
延迟挂载的目标
传入 getter 可以跟随 ScrollArea 的初始化、卸载和替换。getter 返回空值时按钮隐藏,不会回退到页面滚动。切换目标时旧监听器会被移除,新目标立即按自己的滚动位置决定显隐。
审阅记录 1 · 检查交互与视觉细节
审阅记录 2 · 检查交互与视觉细节
审阅记录 3 · 检查交互与视觉细节
审阅记录 4 · 检查交互与视觉细节
审阅记录 5 · 检查交互与视觉细节
审阅记录 6 · 检查交互与视觉细节
审阅记录 7 · 检查交互与视觉细节
审阅记录 8 · 检查交互与视觉细节
审阅记录 9 · 检查交互与视觉细节
审阅记录 10 · 检查交互与视觉细节
审阅记录 11 · 检查交互与视觉细节
审阅记录 12 · 检查交互与视觉细节
审阅记录 13 · 检查交互与视觉细节
审阅记录 14 · 检查交互与视觉细节
审阅记录 15 · 检查交互与视觉细节
审阅记录 16 · 检查交互与视觉细节
审阅记录 17 · 检查交互与视觉细节
审阅记录 18 · 检查交互与视觉细节
审阅记录 19 · 检查交互与视觉细节
审阅记录 20 · 检查交互与视觉细节
回顶组件保持挂载。目标尚未准备好或已卸载时,按钮隐藏,也不会改为滚动页面。
'use client'
import { useRef, useState } from 'react'
import {
Button,
Card,
FormField,
ScrollArea,
ScrollTop,
Stack,
Switch,
Text,
type ScrollAreaHandle,
} from '@hina-ui/react'
export default function Demo() {
const area = useRef<ScrollAreaHandle>(null)
const [show, setShow] = useState(true)
return (
<Stack className="w-full max-w-md" gap="sm">
<FormField label="挂载滚动区域" orientation="horizontal">
<Switch checked={show} onCheckedChange={setShow} />
</FormField>
<Button
variant="outline"
tone="neutral"
className="self-start"
disabled={!show}
onClick={() => area.current?.viewport?.scrollTo({ top: 500, behavior: 'instant' })}
>
滚动到中段
</Button>
<Card padded={false} className="relative h-64 overflow-hidden">
{show ? (
<ScrollArea ref={area} className="h-full" shadow={false} focusable label="审阅记录">
<Stack className="p-5 pb-24" gap="lg">
{Array.from({ length: 20 }, (_, i) => i + 1).map(i => (
<Text key={i} size="sm" className="border-line border-b pb-3">
审阅记录 {i} · 检查交互与视觉细节
</Text>
))}
</Stack>
</ScrollArea>
) : (
<Text size="sm" tone="muted" className="p-5">
滚动区域已卸载
</Text>
)}
<ScrollTop
target={() => area.current?.viewport}
position="absolute"
threshold={120}
offset={16}
/>
</Card>
<Text size="sm" tone="muted">
回顶组件保持挂载。目标尚未准备好或已卸载时,按钮隐藏,也不会改为滚动页面。
</Text>
</Stack>
)
}
行为
- 只监听目标的被动
scroll事件,不逐帧轮询或扫描内容。 - 服务端不读取目标 getter,也不渲染回顶按钮;挂载后读取真实滚动位置。
- 按钮在纵向滚动位置严格大于
threshold时显示。数值变化可以动态生效。 loading/disabled阻止点击和 ref 的scrollToTop触发滚动。- 在
onClick中调用event.preventDefault()可取消默认回顶行为。onClick表示操作被触发,不代表平滑滚动已经结束。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
target | HTMLElement | Window | null | (() => HTMLElement | Window | null | undefined) | window | 滚动目标;传 getter 等待异步容器 |
threshold | number | 300 | 按钮显示的纵向阈值,单位 px |
behavior | 'smooth' | 'instant' | 'auto' | 'smooth' | 滚动方式; auto 遵循目标的 CSS 滚动行为 |
focusTarget | HTMLElement | (() => HTMLElement | null | undefined) | — | 激活时聚焦此节点,使用 preventScroll 避免额外跳动 |
label | string | 当前语言的“返回顶部” | 按钮名称 |
variant | 'solid' | 'soft' | 'outline' | 'outline' | 按钮外观 |
tone | 'accent' | 'neutral' | 'danger' | 'neutral' | 按钮色调 |
另外支持 FloatButton 的 position、placement、offset、size、shape、extended、tooltip、tooltipSide、loading、disabled、ripple、className 和 style,默认值相同。显隐由滚动状态决定,不接收 visible。
内容属性
| 属性 | 默认内容 | 说明 |
|---|---|---|
children | 向上箭头 | 替换按钮图标 |
回调
| 回调 | 参数 | 说明 |
|---|---|---|
onClick | MouseEvent | 点击回顶按钮;可阻止默认行为 |
Ref
| 名称 | 类型 | 说明 |
|---|---|---|
visible | boolean | 当前显示状态(只读) |
scrollToTop | () => void | 使用当前配置滚动到目标顶部 |