顶部偏移
向下滚动检查清单。工具栏到达指定偏移后会停住,勾选进度始终可见。
发布前检查 · 0/6
随内容滚动吸附不会移动或重建工具栏里的控件。
'use client'
import { useState } from 'react'
import {
Affix,
Card,
Checkbox,
Inline,
NumberInput,
ScrollArea,
Stack,
Switch,
Tag,
Text,
} from '@hina-ui/react'
import { affixChecklist } from '../../affix'
const items = affixChecklist('zh-CN')
export default function Demo() {
const [disabled, setDisabled] = useState(false)
const [offset, setOffset] = useState<number | undefined>(12)
const [checked, setChecked] = useState<boolean[]>(Array(6).fill(false))
const completed = checked.filter(Boolean).length
return (
<Stack className="w-full max-w-xl">
<Inline align="center" gap="lg" wrap>
<Switch checked={disabled} onCheckedChange={setDisabled}>
禁用吸附
</Switch>
<Inline align="center" gap="sm">
<Text size="sm" tone="muted">
顶部偏移
</Text>
<NumberInput
value={offset}
onValueChange={setOffset}
min={0}
max={40}
step={4}
size="sm"
aria-label="顶部偏移(px)"
className="w-28"
/>
</Inline>
</Inline>
<Card padded={false}>
<ScrollArea className="h-80" shadow={false} focusable label="发布前检查清单">
<Stack className="p-4" gap="lg">
<Text size="sm" tone="muted">
向下滚动检查清单。工具栏到达指定偏移后会停住,勾选进度始终可见。
</Text>
<Affix offset={offset} disabled={disabled}>
{({ affixed }) => (
<Card className={`p-3 ${affixed ? 'shadow-md' : 'shadow-none'}`}>
<Inline align="center" justify="between" gap="sm" wrap>
<Text size="sm" weight="medium">
发布前检查 · {completed}/6
</Text>
<Tag size="sm" tone={affixed ? 'accent' : 'neutral'}>
{affixed ? '已吸附' : '随内容滚动'}
</Tag>
</Inline>
</Card>
)}
</Affix>
{items.map((item, index) => (
<Checkbox
key={item.title}
checked={checked[index]}
onCheckedChange={value =>
setChecked(checked =>
checked.map((item, i) => (i === index ? value === true : item)),
)
}
description={item.description}
block
>
{item.title}
</Checkbox>
))}
<Text size="xs" tone="muted">
吸附不会移动或重建工具栏里的控件。
</Text>
</Stack>
</ScrollArea>
</Card>
</Stack>
)
}
用法
import { Affix } from '@hina-ui/react'
将需要吸附的内容放进 Affix。默认贴到最近滚动区域的顶部,offset 设置与边缘的距离,单位 px。
<Affix offset={16}>
<Card>工具栏或操作区</Card>
</Affix>
Affix 使用原生 position: sticky,保留原来的布局空间、宽度和 DOM 节点。它不添加卡片、背景或滚动容器,也不为吸附加入位移、缩放或淡入淡出动画。外观由传入的内容决定。
示例
顶部偏移与禁用
顶部示例可以调整偏移、禁用吸附并勾选检查项。disabled 使内容恢复普通流,保留内部状态。将 children 写成接收 { affixed } 的函数,或使用 onChange,获取当前是否贴到指定边缘;例如给已吸附的工具栏添加阴影。
<Affix offset={16} disabled={disabled}>
{({ affixed }) => <Card className={affixed ? 'shadow-md' : 'shadow-none'}>操作区</Card>}
</Affix>
状态改变时尽量只调整颜色、边框颜色或阴影,避免改变高度和外边距,反复推动吸附阈值。
底部操作区
将底部操作区放在它自然应该出现的位置,设置 position="bottom"。当自然位置位于可视区域下方时,它留在底部;滚到原位置后与内容一起移动。底部吸附仍保留在当前父区域内。
编辑文章
在列表中展示的一段简短介绍。
为下一位编辑保留上下文。
修改仅保留在当前示例
'use client'
import { useState } from 'react'
import {
Affix,
Button,
Card,
FormField,
Heading,
Inline,
Input,
ScrollArea,
Stack,
Text,
Textarea,
} from '@hina-ui/react'
export default function Demo() {
const [draft, setDraft] = useState({
title: '组件设计回顾',
author: '设计团队',
summary: '',
notes: '',
})
const [saved, setSaved] = useState(false)
const update = (patch: Partial<typeof draft>) => {
setDraft(draft => ({ ...draft, ...patch }))
setSaved(false)
}
return (
<Card padded={false} className="w-full max-w-xl">
<ScrollArea className="h-96" shadow={false} focusable label="编辑文章">
<Stack className="p-4" gap="lg">
<Heading level={3} size="base">
编辑文章
</Heading>
<FormField label="标题">
<Input value={draft.title} onValueChange={title => update({ title })} />
</FormField>
<FormField label="作者">
<Input value={draft.author} onValueChange={author => update({ author })} />
</FormField>
<FormField label="摘要" description="在列表中展示的一段简短介绍。">
<Textarea
value={draft.summary}
onValueChange={summary => update({ summary })}
rows={4}
placeholder="这一篇主要讨论什么?"
/>
</FormField>
<FormField label="编辑备注" description="为下一位编辑保留上下文。">
<Textarea
value={draft.notes}
onValueChange={notes => update({ notes })}
rows={4}
placeholder="补充修改原因或待确认事项"
/>
</FormField>
<Affix position="bottom" offset={12}>
<Card className="p-3">
<Inline align="center" justify="between" gap="sm" wrap>
<Text role="status" size="sm" tone="muted">
{saved ? '草稿已保存在当前示例' : '修改仅保留在当前示例'}
</Text>
<Button
size="sm"
disabled={!draft.title.trim() || saved}
onClick={() => setSaved(true)}
>
保存草稿
</Button>
</Inline>
</Card>
</Affix>
</Stack>
</ScrollArea>
</Card>
)
}
父区域边界
每个 Affix 受父布局区域约束。下面每个标题只在自己的分组范围内停留,分组结束时自动离开,不会盖住后面的内容。
每组标题只留在自己的区域内。滚到下一组时,上一组标题会一起离开。
设计与内容
内容与文案
标题清楚,错误信息给出恢复方法,空状态提供下一步入口。
键盘操作
检查 Tab 顺序、焦点可见性,以及浮层关闭后的焦点恢复。
窄屏布局
用较窄的窗口检查长标题、工具栏换行和内容是否横向溢出。
运行与适配
加载与失败
慢速网络下保留现有内容,失败后能够重试,避免清空已经填写的字段。
主题与方向
检查深色模式的对比度,以及 RTL 下图标、间距和文本的排列。
首屏与水合
刷新页面,确认内容在脚本执行前可读,水合后没有意外跳动。
import { Affix, Card, Heading, ScrollArea, Stack, Text } from '@hina-ui/react'
import { affixChecklist } from '../../affix'
const items = affixChecklist('zh-CN')
const groups = [
{ title: '设计与内容', items: items.slice(0, 3) },
{ title: '运行与适配', items: items.slice(3) },
]
export default function Demo() {
return (
<Stack className="w-full max-w-xl" gap="sm">
<Text size="sm" tone="muted">
每组标题只留在自己的区域内。滚到下一组时,上一组标题会一起离开。
</Text>
<Card padded={false}>
<ScrollArea className="h-72" shadow={false} focusable label="分组检查说明">
<Stack gap="none">
{groups.map(group => (
<Stack key={group.title} gap="none">
<Affix>
<Heading
level={3}
size="sm"
className="bg-surface border-line border-b px-4 py-3"
>
{group.title}
</Heading>
</Affix>
{group.items.map(item => (
<Stack key={item.title} className="p-4" gap="sm">
<Text size="sm" weight="medium">
{item.title}
</Text>
<Text size="sm" tone="muted">
{item.description}
</Text>
</Stack>
))}
</Stack>
))}
</Stack>
</ScrollArea>
</Card>
</Stack>
)
}
滚动容器与布局
- 自动使用最近具有滚动机制的祖先;没有这类祖先时跟随页面视口。放进 ScrollArea 的内容区即可,不需要读取
viewport或传入target。 - 父区域需要留有移动空间。不要给 Affix 外面包一个仅与它等高的容器,否则没有可吸附的行程。横向 Flex 中需要侧栏保持自然高度时,给 Affix 设置
self-start,不要将它拉伸到整列高度。 overflow: auto / scroll / hidden会改变吸附参照;用于裁剪但不希望建立滚动区域时,可在适用场景使用overflow: clip。不要跨过一个不滚动的overflow: hidden祖先去期待页面级吸附。- 自动跟随容器宽度,保留原来的裁剪、方向和层叠关系。默认层级为
z-10,可通过className调整。需要一直固定在窗口角落的操作入口使用 FloatButton。
<ScrollArea className="h-96">
<Stack>
<Affix offset={12}>
<Card>筛选与批量操作</Card>
</Affix>
<DataList items={items} itemKey="id">
{({ item }) => item.title}
</DataList>
</Stack>
</ScrollArea>
SSR 与性能
吸附由 CSS 完成,SSR 首屏与客户端使用相同布局,没有测量后切成 fixed 的阶段。即使 JavaScript 尚未执行,内容仍可读,吸附仍生效。
children 函数收到的 affixed 在服务端为 false,挂载后同步当前边缘状态。滚动与尺寸变化只用于状态检测,同一帧合并处理,状态未改变时不调用 onChange;不会逐帧写入定位或占位尺寸。父区域结束、离开指定边缘时状态恢复为 false。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
as | string | 'div' | 根节点标签 |
position | 'top' | 'bottom' | 'top' | 吸附边缘 |
offset | number | 0 | 边缘偏移,单位 px;支持负值 |
disabled | boolean | false | 停用吸附,恢复普通流 |
className | string | — | 根节点样式;原生属性及 style 也传给根节点 |
内容属性
| 属性 | 参数 | 说明 |
|---|---|---|
children | { affixed: boolean } | 当前是否处在指定吸附边缘 |
回调
| 回调 | 参数 | 说明 |
|---|---|---|
onChange | affixed: boolean | 边缘吸附状态发生变化时触发 |
Ref
| 名称 | 类型 | 说明 |
|---|---|---|
element | HTMLElement | undefined | 根节点 |
affixed | boolean | 当前边缘吸附状态 |
update | () => void | 下一帧重新检测状态;普通滚动和尺寸变化自动处理 |
根节点提供 data-position、data-affixed 和 data-disabled,便于自定义样式。