Affix 吸附

让工具栏、操作区或分组标题在滚动时留在可视区域边缘。

顶部偏移

向下滚动检查清单。工具栏到达指定偏移后会停住,勾选进度始终可见。

发布前检查 · 0/6

随内容滚动

吸附不会移动或重建工具栏里的控件。

<script setup lang="ts">
  import { computed, ref } from 'vue'
  import {
    Affix,
    Card,
    Checkbox,
    Inline,
    NumberInput,
    ScrollArea,
    Stack,
    Switch,
    Tag,
    Text,
  } from '@hina-ui/vue'
  import { affixChecklist } from '../../affix'

  const disabled = ref(false)
  const offset = ref(12)
  const checked = ref<boolean[]>(Array(6).fill(false))
  const completed = computed(() => checked.value.filter(Boolean).length)
  const items = affixChecklist('zh-CN')
</script>

<template>
  <Stack class="w-full max-w-xl">
    <Inline align="center" gap="lg" wrap>
      <Switch v-model="disabled">禁用吸附</Switch>
      <Inline align="center" gap="sm">
        <Text size="sm" tone="muted">顶部偏移</Text>
        <NumberInput
          v-model="offset"
          :min="0"
          :max="40"
          :step="4"
          size="sm"
          aria-label="顶部偏移(px)"
          class="w-28"
        />
      </Inline>
    </Inline>
    <Card :padded="false">
      <ScrollArea class="h-80" :shadow="false" focusable label="发布前检查清单">
        <Stack class="p-4" gap="lg">
          <Text size="sm" tone="muted">
            向下滚动检查清单。工具栏到达指定偏移后会停住,勾选进度始终可见。
          </Text>
          <Affix v-slot="{ affixed }" :offset="offset" :disabled="disabled">
            <Card class="p-3" :class="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>
          <Checkbox
            v-for="(item, index) in items"
            :key="item.title"
            v-model="checked[index]"
            :description="item.description"
            block
          >
            {{ item.title }}
          </Checkbox>
          <Text size="xs" tone="muted">吸附不会移动或重建工具栏里的控件。</Text>
        </Stack>
      </ScrollArea>
    </Card>
  </Stack>
</template>
vue

用法

import { Affix } from '@hina-ui/vue'
ts

将需要吸附的内容放进 Affix。默认贴到最近滚动区域的顶部,offset 设置与边缘的距离,单位 px。

<Affix :offset="16">
  <Card>工具栏或操作区</Card>
</Affix>
vue

Affix 使用原生 position: sticky,保留原来的布局空间、宽度和 DOM 节点。它不添加卡片、背景或滚动容器,也不为吸附加入位移、缩放或淡入淡出动画。外观由插槽内容决定。

示例

顶部偏移与禁用

顶部示例可以调整偏移、禁用吸附并勾选检查项。disabled 使内容恢复普通流,保留内部状态。用 #default="{ affixed }" 或 @change 获取当前是否贴到指定边缘;例如给已吸附的工具栏添加阴影。

<Affix v-slot="{ affixed }" :offset="16" :disabled="disabled">
  <Card :class="affixed ? 'shadow-md' : 'shadow-none'">操作区</Card>
</Affix>
vue

状态改变时尽量只调整颜色、边框颜色或阴影,避免改变高度和外边距,反复推动吸附阈值。

底部操作区

将底部操作区放在它自然应该出现的位置,设置 position="bottom"。当自然位置位于可视区域下方时,它留在底部;滚到原位置后与内容一起移动。底部吸附仍保留在当前父区域内。

编辑文章

在列表中展示的一段简短介绍。

为下一位编辑保留上下文。

修改仅保留在当前示例

<script setup lang="ts">
  import { reactive, ref, watch } from 'vue'
  import {
    Affix,
    Button,
    Card,
    FormField,
    Heading,
    Inline,
    Input,
    ScrollArea,
    Stack,
    Text,
    Textarea,
  } from '@hina-ui/vue'

  const draft = reactive({ title: '组件设计回顾', author: '设计团队', summary: '', notes: '' })
  const saved = ref(false)
  watch(draft, () => {
    saved.value = false
  })
</script>

<template>
  <Card :padded="false" class="w-full max-w-xl">
    <ScrollArea class="h-96" :shadow="false" focusable label="编辑文章">
      <Stack class="p-4" gap="lg">
        <Heading :level="3" size="base">编辑文章</Heading>
        <FormField label="标题"><Input v-model="draft.title" /></FormField>
        <FormField label="作者"><Input v-model="draft.author" /></FormField>
        <FormField label="摘要" description="在列表中展示的一段简短介绍。">
          <Textarea v-model="draft.summary" :rows="4" placeholder="这一篇主要讨论什么?" />
        </FormField>
        <FormField label="编辑备注" description="为下一位编辑保留上下文。">
          <Textarea v-model="draft.notes" :rows="4" placeholder="补充修改原因或待确认事项" />
        </FormField>
        <Affix position="bottom" :offset="12">
          <Card class="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" @click="saved = true">
                保存草稿
              </Button>
            </Inline>
          </Card>
        </Affix>
      </Stack>
    </ScrollArea>
  </Card>
</template>
vue

父区域边界

每个 Affix 受父布局区域约束。下面每个标题只在自己的分组范围内停留,分组结束时自动离开,不会盖住后面的内容。

每组标题只留在自己的区域内。滚到下一组时,上一组标题会一起离开。

设计与内容

内容与文案

标题清楚,错误信息给出恢复方法,空状态提供下一步入口。

键盘操作

检查 Tab 顺序、焦点可见性,以及浮层关闭后的焦点恢复。

窄屏布局

用较窄的窗口检查长标题、工具栏换行和内容是否横向溢出。

运行与适配

加载与失败

慢速网络下保留现有内容,失败后能够重试,避免清空已经填写的字段。

主题与方向

检查深色模式的对比度,以及 RTL 下图标、间距和文本的排列。

首屏与水合

刷新页面,确认内容在脚本执行前可读,水合后没有意外跳动。

<script setup lang="ts">
  import { Affix, Card, Heading, ScrollArea, Stack, Text } from '@hina-ui/vue'
  import { affixChecklist } from '../../affix'
  const items = affixChecklist('zh-CN')
  const groups = [
    { title: '设计与内容', items: items.slice(0, 3) },
    { title: '运行与适配', items: items.slice(3) },
  ]
</script>

<template>
  <Stack class="w-full max-w-xl" gap="sm">
    <Text size="sm" tone="muted">
      每组标题只留在自己的区域内。滚到下一组时,上一组标题会一起离开。
    </Text>
    <Card :padded="false">
      <ScrollArea class="h-72" :shadow="false" focusable label="分组检查说明">
        <Stack gap="none">
          <Stack v-for="group in groups" :key="group.title" gap="none">
            <Affix>
              <Heading :level="3" size="sm" class="bg-surface border-line border-b px-4 py-3">
                {{ group.title }}
              </Heading>
            </Affix>
            <Stack v-for="item in group.items" :key="item.title" class="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>
</template>
vue

滚动容器与布局

  • 自动使用最近具有滚动机制的祖先;没有这类祖先时跟随页面视口。放进 ScrollArea 的内容区即可,不需要读取 viewport 或传入 target。
  • 父区域需要留有移动空间。不要给 Affix 外面包一个仅与它等高的容器,否则没有可吸附的行程。横向 Flex 中需要侧栏保持自然高度时,给 Affix 设置 self-start,不要将它拉伸到整列高度。
  • overflow: auto / scroll / hidden 会改变吸附参照;用于裁剪但不希望建立滚动区域时,可在适用场景使用 overflow: clip。不要跨过一个不滚动的 overflow: hidden 祖先去期待页面级吸附。
  • 自动跟随容器宽度,保留原来的裁剪、方向和层叠关系。默认层级为 z-10,可通过 class 调整。需要一直固定在窗口角落的操作入口使用 FloatButton。
<ScrollArea class="h-96">
  <Stack>
    <Affix :offset="12"><Card>筛选与批量操作</Card></Affix>
    <DataList :items="items" :get-key="item => item.id">
      <template #default="{ item }">{{ item.title }}</template>
    </DataList>
  </Stack>
</ScrollArea>
vue

SSR 与性能

吸附由 CSS 完成,SSR 首屏与客户端使用相同布局,没有测量后切成 fixed 的阶段。即使 JavaScript 尚未执行,内容仍可读,吸附仍生效。

affixed 插槽值在服务端为 false,挂载后同步当前边缘状态。滚动与尺寸变化只用于状态检测,同一帧合并处理,状态未改变时不触发事件;不会逐帧写入定位或占位尺寸。父区域结束、离开指定边缘时状态恢复为 false。

API

Props

属性
类型
默认值
说明
as
string
'div'
根节点标签
position
'top' | 'bottom'
'top'
吸附边缘
offset
number
0
边缘偏移,单位 px;支持负值
disabled
boolean
false
停用吸附,恢复普通流
class
string
—
根节点样式;原生属性及 style 也传给根节点

Slots

插槽
参数
说明
default
{ affixed: boolean }
当前是否处在指定吸附边缘

Events

事件
参数
说明
change
affixed: boolean
边缘吸附状态发生变化时触发

Expose

名称
类型
说明
element
HTMLElement | undefined
根节点
affixed
boolean
当前边缘吸附状态
update
() => void
下一帧重新检测状态;普通滚动和尺寸变化自动处理

根节点提供 data-position、data-affixed 和 data-disabled,便于自定义样式。