Anchor

An in-page table of contents that tracks the section you are reading.

Overview

Scroll this area and the entries on the right follow along.

Installation

Scroll this area and the entries on the right follow along.

Usage

Scroll this area and the entries on the right follow along.

<script setup lang="ts">
  import { Anchor, Heading, Inline, ScrollArea, Section, Text } from '@hina-ui/vue'

  const sections = [
    { id: 'hero-intro', label: 'Overview' },
    { id: 'hero-install', label: 'Installation' },
    { id: 'hero-usage', label: 'Usage' },
  ]
</script>

<template>
  <Inline gap="lg" align="start" class="w-full max-w-2xl">
    <ScrollArea class="border-line h-56 flex-1 rounded-lg border">
      <Section v-for="s in sections" :id="s.id" :key="s.id" class="min-h-40 p-4">
        <Heading :level="3" size="sm">{{ s.label }}</Heading>
        <Text size="sm" tone="muted">
          Scroll this area and the entries on the right follow along.
        </Text>
      </Section>
    </ScrollArea>
    <Anchor :items="sections" class="w-28 shrink-0" />
  </Inline>
</template>
vue

Usage

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

Anchor is driven by items. Each entry’s id matches the id of an element on the page, and label is the text shown in the contents.

It renders the contents, not the content. Every id must resolve to an element in the document; an entry whose target is missing is neither tracked nor clickable.

Synopsis

Body text for this section.

Characters

Body text for this section.

Staff

Body text for this section.

<script setup lang="ts">
  import { Anchor, Heading, Inline, ScrollArea, Section, Text } from '@hina-ui/vue'

  const items = [
    { id: 'basic-a', label: 'Synopsis' },
    { id: 'basic-b', label: 'Characters' },
    { id: 'basic-c', label: 'Staff' },
  ]
</script>

<template>
  <Inline gap="lg" align="start" class="w-full max-w-2xl">
    <ScrollArea class="border-line h-56 flex-1 rounded-lg border">
      <Section v-for="item in items" :id="item.id" :key="item.id" class="min-h-40 p-4">
        <Heading :level="3" size="sm">{{ item.label }}</Heading>
        <Text size="sm" tone="muted">Body text for this section.</Text>
      </Section>
    </ScrollArea>
    <Anchor :items="items" class="w-28 shrink-0" />
  </Inline>
</template>
vue

Tracking is based on an element’s visibility in the viewport, so the content works equally well in the page’s main scroll region or inside a ScrollArea.

Examples

Levels

children adds a second level, shown one indent in. The contents support two levels only — a children array nested inside children is not rendered. An in-page contents list deeper than two levels is a sign the page should be split rather than nested further.

Overview

Body text for this section.

Routes

Body text for this section.

Common route

Body text for this section.

Character routes

Body text for this section.

Staff

Body text for this section.

<script setup lang="ts">
  import { Anchor, Heading, Inline, ScrollArea, Section, Text } from '@hina-ui/vue'

  const items = [
    { id: 'nest-intro', label: 'Overview' },
    {
      id: 'nest-route',
      label: 'Routes',
      children: [
        { id: 'nest-route-a', label: 'Common route' },
        { id: 'nest-route-b', label: 'Character routes' },
      ],
    },
    { id: 'nest-staff', label: 'Staff' },
  ]

  const flat = items.flatMap(item => [item, ...(item.children ?? [])])
</script>

<template>
  <Inline gap="lg" align="start" class="w-full max-w-2xl">
    <ScrollArea class="border-line h-56 flex-1 rounded-lg border">
      <Section v-for="item in flat" :id="item.id" :key="item.id" class="min-h-32 p-4">
        <Heading :level="3" size="sm">{{ item.label }}</Heading>
        <Text size="sm" tone="muted">Body text for this section.</Text>
      </Section>
    </ScrollArea>
    <Anchor :items="items" class="w-32 shrink-0" />
  </Inline>
</template>
vue

Trailing content

#trailing receives { item, active } for non-interactive content such as a Tag, text or an icon. item is the original entry, including custom fields. Both top-level and second-level entries support the slot.

active matches aria-current="location". When several sections are visible, only the first visible entry in directory order is active; the existing visible-range highlight is unchanged.

Trailing content aligns to the end of the row without shrinking, while long labels can wrap. Empty trailing content takes no space. Updating a status mark keeps the current entry, and clicking the mark still follows its link.

Entry A

The content of this section.

Entry B

The content of this section.

Child entry

The content of this section.

<script setup lang="ts">
  import { computed, ref } from 'vue'
  import {
    Anchor,
    Button,
    Heading,
    Inline,
    ScrollArea,
    Section,
    Stack,
    Tag,
    Text,
  } from '@hina-ui/vue'

  const modified = ref(true)
  const items = computed(() => [
    { id: 'trailing-a', label: 'Entry A', modified: modified.value },
    {
      id: 'trailing-b',
      label: 'Entry B',
      modified: false,
      children: [{ id: 'trailing-c', label: 'Child entry', modified: true }],
    },
  ])
  const sections = computed(() => items.value.flatMap(item => [item, ...(item.children ?? [])]))
</script>

<template>
  <Stack gap="md" class="w-full max-w-2xl">
    <Inline>
      <Button variant="soft" tone="neutral" :aria-pressed="modified" @click="modified = !modified">
        Toggle the mark on entry A
      </Button>
    </Inline>
    <Inline gap="lg" align="start" :wrap="false">
      <ScrollArea class="border-line h-56 min-w-0 flex-1 rounded-lg border">
        <Section v-for="item in sections" :id="item.id" :key="item.id" class="min-h-40 p-4">
          <Heading :level="3" size="sm">{{ item.label }}</Heading>
          <Text size="sm" tone="muted">The content of this section.</Text>
        </Section>
      </ScrollArea>
      <Anchor :items="items" label="Contents with trailing marks" class="w-44 shrink-0">
        <template #trailing="{ item, active }">
          <Tag v-if="item.modified" :tone="active ? 'accent' : 'neutral'">Modified</Tag>
        </template>
      </Anchor>
    </Inline>
  </Stack>
</template>
vue

Following a long directory

Place a long directory inside a height-constrained ScrollArea. When the highlighted range extends beyond the directory viewport, Anchor scrolls the entire range into view with a small margin. If the range is taller than the viewport, it follows the first current entry instead of alternating between the two ends. It detects the nearest vertical scroll container, including native containers or a scrollable root element, without requiring a viewport ref.

Fully visible ranges are not repeatedly centered, and browsing the directory manually does not continually pull it back. Initial positioning and size adjustments are instant; subsequent changes to either end of the highlighted range scroll smoothly unless reduced motion is enabled. Following never moves focus or scrolls an outer container shared with the article.

Set :auto-scroll="false" to disable following while keeping scroll-spy and highlighting. @change receives the current entry’s id, and a component ref exposes the read-only current value, so observing aria-current is unnecessary. This example contains 51 entries; scroll the article and directory independently or disable following to compare.

51 sections

1. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

2. Design process

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

3. Implementation

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

4. User experience

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

5. Conclusions

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

6. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

7. Design process

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

8. Implementation

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

9. User experience

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

10. Conclusions

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

11. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

12. Design process

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

13. Implementation

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

14. User experience

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

15. Conclusions

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

16. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

17. Design process

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

18. Implementation

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

19. User experience

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

20. Conclusions

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

21. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

22. Design process

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

23. Implementation

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

24. User experience

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

25. Conclusions

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

26. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

27. Design process

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

28. Implementation

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

29. User experience

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

30. Conclusions

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

31. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

32. Design process

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

33. Implementation

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

34. User experience

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

35. Conclusions

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

36. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

37. Design process

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

38. Implementation

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

39. User experience

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

40. Conclusions

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

41. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

42. Design process

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

43. Implementation

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

44. User experience

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

45. Conclusions

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

46. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

47. Design process

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

48. Implementation

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

49. User experience

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

50. Conclusions

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

51. Background

Scroll through the article to keep its current entry visible in the contents. You can also browse the contents independently to find another section.

Current: Article is outside the reading area

<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { Anchor, Heading, Inline, ScrollArea, Section, Stack, Switch, Text } from '@hina-ui/vue'

  const topics = [
    'Background',
    'Design process',
    'Implementation',
    'User experience',
    'Conclusions',
  ]
  const items = Array.from({ length: 51 }, (_, index) => ({
    id: `long-toc-${index + 1}`,
    label: `${index + 1}. ${topics[index % topics.length]}`,
  }))
  const autoScroll = ref(true)
  const current = ref<string>()
  const currentLabel = computed(() => items.find(item => item.id === current.value)?.label)
</script>

<template>
  <Stack class="w-full max-w-2xl">
    <Inline justify="between">
      <Switch v-model="autoScroll">Follow current entry</Switch>
      <Text size="sm" tone="muted">51 sections</Text>
    </Inline>
    <Inline align="start" gap="md" :wrap="false">
      <ScrollArea
        class="border-line h-80 min-w-0 flex-1 rounded-lg border"
        focusable
        label="Article content"
      >
        <Section v-for="item in items" :id="item.id" :key="item.id" class="min-h-56 p-4">
          <Heading :level="3" size="sm">{{ item.label }}</Heading>
          <Text size="sm" tone="muted">
            Scroll through the article to keep its current entry visible in the contents. You can
            also browse the contents independently to find another section.
          </Text>
        </Section>
      </ScrollArea>
      <ScrollArea
        class="h-80 w-2/5 max-w-48 shrink-0"
        :shadow="false"
        focusable
        label="Scrollable article contents"
      >
        <Anchor
          :items="items"
          :auto-scroll="autoScroll"
          label="Long article contents"
          @change="current = $event"
        />
      </ScrollArea>
    </Inline>
    <Text size="sm" tone="muted">
      Current: {{ currentLabel ?? 'Article is outside the reading area' }}
    </Text>
  </Stack>
</template>
vue

Landmark name

Anchor renders as a nav landmark with an accessible name in the interface language (“On this page” in English). When a page holds several navigation landmarks, name each with label.

Version 1.2

What changed in this version.

Version 1.1

What changed in this version.

Version 1.0

What changed in this version.

<script setup lang="ts">
  import { Anchor, Heading, Inline, ScrollArea, Section, Text } from '@hina-ui/vue'

  const items = [
    { id: 'label-a', label: 'Version 1.2' },
    { id: 'label-b', label: 'Version 1.1' },
    { id: 'label-c', label: 'Version 1.0' },
  ]
</script>

<template>
  <Inline gap="lg" align="start" class="w-full max-w-2xl">
    <ScrollArea class="border-line h-56 flex-1 rounded-lg border">
      <Section v-for="item in items" :id="item.id" :key="item.id" class="min-h-40 p-4">
        <Heading :level="3" size="sm">{{ item.label }}</Heading>
        <Text size="sm" tone="muted">What changed in this version.</Text>
      </Section>
    </ScrollArea>
    <Anchor :items="items" label="Version contents" class="w-28 shrink-0" />
  </Inline>
</template>
vue

Behaviour

  • When several sections are visible at once they are all marked, and the highlight bar stretches across the whole run rather than picking one out.
  • The bar moves continuously rather than jumping.
  • Roughly the bottom 15% of the viewport does not count as visible, so a section is marked only once it has properly entered the reading area.
  • Clicking an entry scrolls smoothly to the target and replaces the hash in the address bar, but adds no history entry.
  • With reduced motion enabled the jump is instant rather than smooth.

Accessibility

  • The wrapper is a nav landmark holding a list of links.
  • The current entry carries aria-current="location" — location rather than page, because the reader is still on the same page and only the position within it has changed.
  • Entries are real links whose href points at the anchor, so they can be copied or opened in a new tab.
  • The highlight bar is decoration; position is carried by aria-current and the heavier text together, never by colour alone.

API

Props

Prop
Type
Default
Description
items
T[]
Required
Entries of the contents list
label
string
Interface language
Accessible name of the landmark
autoScroll
boolean
true
Follow the highlighted range, or its first entry when too tall
class
string
—
Classes appended to the root

AnchorItem

Field
Type
Description
id
string
id of the target element, without the #
label
string
Text shown in the contents
children
AnchorItem[]
Second-level entries; their own children ignored

Slots

SlotParameters
Description
trailing{ item, active: boolean }
Trailing content inside each entry link

T is inferred from items and must include the base fields of AnchorItem. item preserves the original types of top-level entries and their children; use a field or discriminant to narrow the type when parent and child fields differ.

Events

Event
Payload
Description
change
current: string | undefined
Emitted when the current entry changes, including its first detection. Becomes undefined when resetting entries leaves no current entry.

Expose

Property
Type
Description
current
string | undefined
Read-only current entry id; undefined until an entry is identified