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