Affix

Keep toolbars, actions and section headings at the edge of a scrolling region.

Top offset

Scroll through the checklist. The toolbar stops at the chosen offset so your progress stays visible.

Release checks · 0/6

In flow

Affixing 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>
vue

Usage

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

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>
vue

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>
vue

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>
vue

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>
vue

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 viewport or passing a target.
  • The parent needs room for sticky movement. A wrapper only as tall as Affix leaves no travel distance. In a horizontal Flex, use self-start on a sidebar Affix to retain its natural height instead of stretching it to the full column.
  • overflow: auto / scroll / hidden establishes a new sticky reference. When clipping should not establish a scroll container, use overflow: clip where appropriate. A non-scrolling overflow: hidden ancestor prevents page-level sticking.
  • Width, clipping, direction and stacking remain part of the original layout. The default stack level is z-10, adjustable through class. 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>
vue

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.