Top offset
Scroll through the checklist. The toolbar stops at the chosen offset so your progress stays visible.
Release checks · 0/6
In flowAffixing never moves or remounts controls inside the toolbar.
<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('en')
</script>
<template>
<Stack class="w-full max-w-xl">
<Inline align="center" gap="lg" wrap>
<Switch v-model="disabled">Disable affixing</Switch>
<Inline align="center" gap="sm">
<Text size="sm" tone="muted">Top offset</Text>
<NumberInput
v-model="offset"
:min="0"
:max="40"
:step="4"
size="sm"
aria-label="Top offset in pixels"
class="w-28"
/>
</Inline>
</Inline>
<Card :padded="false">
<ScrollArea class="h-80" :shadow="false" focusable label="Release checklist">
<Stack class="p-4" gap="lg">
<Text size="sm" tone="muted">
Scroll through the checklist. The toolbar stops at the chosen offset so your progress
stays visible.
</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">Release checks · {{ completed }}/6</Text>
<Tag size="sm" :tone="affixed ? 'accent' : 'neutral'">
{{ affixed ? 'Affixed' : 'In flow' }}
</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">
Affixing never moves or remounts controls inside the toolbar.
</Text>
</Stack>
</ScrollArea>
</Card>
</Stack>
</template>
Usage
import { Affix } from '@hina-ui/vue'
Wrap the content that should stick. It follows the top of its nearest scrolling region by default; offset sets the distance from that edge in pixels.
<Affix :offset="16">
<Card>Toolbar or actions</Card>
</Affix>
Affix uses native position: sticky, preserving layout space, width and DOM identity. It adds no card styling, background, scroll container or movement, scaling or fading animation. Slot content owns its appearance.
Examples
Offset and disabled state
The first example lets you adjust the offset, disable affixing and complete checklist items. disabled restores ordinary flow without losing internal state. Use #default="{ affixed }" or @change to detect whether the content is at the chosen edge, for example to add a toolbar shadow.
<Affix v-slot="{ affixed }" :offset="16" :disabled="disabled">
<Card :class="affixed ? 'shadow-md' : 'shadow-none'">Actions</Card>
</Affix>
Prefer changing colors or shadows on state changes. Changing height or margins can repeatedly move the threshold.
Bottom actions
Put the action bar at its natural position and set position="bottom". While that position is below the visible region, the bar stays at the bottom. It rejoins the content when its natural position comes into view, and remains bounded by its parent region.
Edit article
A short introduction for article lists.
Leave context for the next editor.
Changes stay in this demo
<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: 'Component design review',
author: 'Design team',
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="Edit article">
<Stack class="p-4" gap="lg">
<Heading :level="3" size="base">Edit article</Heading>
<FormField label="Title"><Input v-model="draft.title" /></FormField>
<FormField label="Author"><Input v-model="draft.author" /></FormField>
<FormField label="Summary" description="A short introduction for article lists.">
<Textarea v-model="draft.summary" :rows="4" placeholder="What is this article about?" />
</FormField>
<FormField label="Editorial notes" description="Leave context for the next editor.">
<Textarea
v-model="draft.notes"
:rows="4"
placeholder="Explain changes or open questions"
/>
</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 ? 'Draft saved in this demo' : 'Changes stay in this demo' }}
</Text>
<Button size="sm" :disabled="!draft.title.trim() || saved" @click="saved = true">
Save draft
</Button>
</Inline>
</Card>
</Affix>
</Stack>
</ScrollArea>
</Card>
</template>
Parent boundaries
Each Affix is constrained by its parent layout region. These headings stick only within their own groups, then leave with the group instead of covering later content.
Each heading stays inside its own section. The previous heading leaves as the next section arrives.
Design and content
Content and copy
Use clear titles, actionable errors, and a useful next step in empty states.
Keyboard access
Check Tab order, visible focus, and focus restoration after closing overlays.
Narrow layouts
Try long titles and wrapping toolbars, and check for horizontal overflow.
Runtime and adaptation
Loading and failure
Preserve content on slow connections and let users retry without losing their input.
Theme and direction
Check dark-mode contrast and the arrangement of icons, spacing, and text in RTL.
First paint and hydration
Refresh the page and check that content is readable before scripts run and stays in place afterward.
<script setup lang="ts">
import { Affix, Card, Heading, ScrollArea, Stack, Text } from '@hina-ui/vue'
import { affixChecklist } from '../../affix'
const items = affixChecklist('en')
const groups = [
{ title: 'Design and content', items: items.slice(0, 3) },
{ title: 'Runtime and adaptation', items: items.slice(3) },
]
</script>
<template>
<Stack class="w-full max-w-xl" gap="sm">
<Text size="sm" tone="muted">
Each heading stays inside its own section. The previous heading leaves as the next section
arrives.
</Text>
<Card :padded="false">
<ScrollArea class="h-72" :shadow="false" focusable label="Grouped review notes">
<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>
Scroll containers and layout
- The nearest ancestor with a scrolling mechanism is used automatically; otherwise the page viewport is used. Place Affix inside ScrollArea without reading its
viewportor passing atarget. - The parent needs room for sticky movement. A wrapper only as tall as Affix leaves no travel distance. In a horizontal Flex, use
self-starton a sidebar Affix to retain its natural height instead of stretching it to the full column. overflow: auto / scroll / hiddenestablishes a new sticky reference. When clipping should not establish a scroll container, useoverflow: clipwhere appropriate. A non-scrollingoverflow: hiddenancestor prevents page-level sticking.- Width, clipping, direction and stacking remain part of the original layout. The default stack level is
z-10, adjustable throughclass. Use FloatButton for an action that should always be fixed in a viewport corner.
<ScrollArea class="h-96">
<Stack>
<Affix :offset="12"><Card>Filters and batch actions</Card></Affix>
<DataList :items="items" :get-key="item => item.id">
<template #default="{ item }">{{ item.title }}</template>
</DataList>
</Stack>
</ScrollArea>
SSR and performance
CSS owns positioning, so SSR and the client use the same layout without a measured switch to fixed positioning. Content remains readable and sticky before JavaScript runs.
The affixed slot value is false on the server and synchronizes after mount. Scroll and resize measurements only detect edge state, are batched within a frame, and emit only on changes. They never write positioning or placeholder dimensions on every frame. The state returns to false when the parent boundary pushes the element away from the chosen edge.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
as | string | 'div' | Root tag |
position | 'top' | 'bottom' | 'top' | Sticky edge |
offset | number | 0 | Offset in pixels; negative values are supported |
disabled | boolean | false | Restore normal flow |
class | string | — | Root styling; native attributes and style also reach the root |
Slots
| Slot | Props | Description |
|---|---|---|
default | { affixed: boolean } | Whether content is at the chosen sticky edge |
Events
| Event | Payload | Description |
|---|---|---|
change | affixed: boolean | Emitted when edge state changes |
Expose
| Name | Type | Description |
|---|---|---|
element | HTMLElement | undefined | Root element |
affixed | boolean | Current edge state |
update | () => void | Recheck state next frame; normal scrolling and resizing are automatic |
The root exposes data-position, data-affixed and data-disabled for custom styling.