Anchor
Overview
Anchor provides in-page navigation for long content sections and keeps the current section highlighted while scrolling.
When the active item changes while scrolling, the hash in the address bar is also synchronized. Scroll-driven updates always use history.replaceState to avoid creating excessive history entries.
If the current URL already contains a hash on initial render, Anchor will scroll to the matching section after mount. When a custom scroll container becomes available later, Anchor will re-sync once that container is ready.
SAnchornow delegates recursive item rendering to AriaAnchorCompact. The same sixuislots are available from bothSAnchorand@vean/aria/anchor.
Features
- Scroll-spy highlighting — the root observes its scroll container (
windowby default or a custom element viagetContainer) and marks the last section that crossed theoffsetTop + boundsthreshold as active, witharia-current="location"on the matching link. - Hash synchronization — clicking a link writes the hash with
history.pushState(orhistory.replaceStatewhenreplaceis set); scroll-driven updates alwaysreplaceState; on mount the component scrolls to the hash that is already in the URL. - Smooth in-page scrolling — clicking a hash link scrolls the container with
behavior: 'smooth'and suspends scroll-spy re-sync until the animation settles (300 ms guard). - Custom scroll container —
getContainerreturns any scrollableHTMLElement; anchor positions are measured relative to that container and re-synced when it replaces the initialwindowfallback. - Offset control —
offsetTop(default0) andtargetOffset(takes precedence) shift the scroll destination so headings land below sticky headers; the value is also exposed as--vean-anchor-offset-topfor the sticky root. - Active detection tuning —
bounds(default5) is the pixel tolerance used to decide whether a section has been passed;getCurrentAnchorcan remap the resolved href before it is emitted. - Recursive data-driven composition —
SAnchorrenders nesteditems(arbitrary depth) through the generic AriaAnchorCompact; each level wraps its ownAnchorLink, indicator, title, and nestedsublist. - Sticky rail —
sticky(defaulttrue) pins the anchor list to the top of the container withtop: var(--vean-anchor-offset-top)and caps its height to the viewport. - Per-item and global link props —
disabledandtargetcan be set per item or globally vialinkProps; an explicit item value always wins, andlinkProps.hrefis intentionally excluded because the item’shrefowns the target. - Eight theme colors + six sizes —
color(primary…accent) andsize(xs…2xl) variants from theanchorVariantsscv()recipe, plus vertical / horizontalorientation. - Accessible by default — the root renders a
<nav>landmark with a localizedaria-label(overridable), disabled links carryaria-disabledand leave the tab order, and the indicator isaria-hidden. - Aria composition —
AnchorRoot/AnchorLink/AnchorCompact/AnchorItemCompactare exported from@vean/aria/anchorfor fully custom styled builds.
Usage
SAnchornow delegates recursive item rendering to AriaAnchorCompact. For unstyled, data-driven usage, importAnchorCompactfrom@vean/aria/anchor.
Demos
Basic
Overview
Scroll the content area to watch the active anchor update automatically.
Usage
Scroll the content area to watch the active anchor update automatically.
Installation
Scroll the content area to watch the active anchor update automatically.
Configuration
Scroll the content area to watch the active anchor update automatically.
API
Scroll the content area to watch the active anchor update automatically.
Custom Styling
API
Aria Layer
5Anchor
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | ThemeColor | — | Theme color of the component. |
size | ThemeSize | — | Visual size of the component. |
ui | Partial<AnchorExtendedUi> | — | Per-slot class overrides for the component. |
class | ClassValue | — | Additional class names applied to the root element. |
sticky | boolean | — | Whether sticky. |
items | AnchorOptionData[] | — | Items rendered by the component. |
linkProps | Omit<AnchorLinkProps, 'href'> | — | Properties forwarded to the link element. |
indicatorProps | HTMLAttributes | — | Properties forwarded to the indicator element. |
titleProps | HTMLAttributes | — | Properties forwarded to the title element. |
subProps | HTMLAttributes | — | Properties forwarded to the sub element. |
bounds | number | — | Bounds. |
dir | Direction | — | Reading direction of the component. |
getContainer | (() => AnchorContainer | null) | — | Get container. |
getCurrentAnchor | ((activeHref: string) => string) | — | Get current anchor. |
modelValue | string | — | Current model value. |
offsetTop | number | — | Offset top. |
orientation | DataOrientation | — | Orientation of the component. |
replace | boolean | — | Whether replace. |
targetOffset | number | — | Target offset. |
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
Emits
| Emit Name | Parameters | Description |
|---|---|---|
update:modelValue | string | Emitted when the model value changes. |
activeChange | string | Emitted when active change occurs. |
itemSelect | MouseEvent, payload: { href: string; } | Emitted when item select occurs. |
AnchorCompact
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | AnchorOptionData[] | — | Items rendered by the component. |
linkProps | Omit<AnchorLinkProps, 'href'> | — | Properties forwarded to the link element. |
indicatorProps | HTMLAttributes | — | Properties forwarded to the indicator element. |
titleProps | HTMLAttributes | — | Properties forwarded to the title element. |
subProps | HTMLAttributes | — | Properties forwarded to the sub element. |
bounds | number | — | Bounds. |
dir | Direction | — | Reading direction of the component. |
getContainer | (() => AnchorContainer | null) | — | Get container. |
getCurrentAnchor | ((activeHref: string) => string) | — | Get current anchor. |
modelValue | string | — | Current model value. |
offsetTop | number | — | Offset top. |
orientation | DataOrientation | — | Orientation of the component. |
replace | boolean | — | Whether replace. |
targetOffset | number | — | Target offset. |
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
Emits
| Emit Name | Parameters | Description |
|---|---|---|
update:modelValue | string | Emitted when the model value changes. |
activeChange | string | Emitted when active change occurs. |
itemSelect | MouseEvent, payload: { href: string; } | Emitted when item select occurs. |
AnchorItemCompact
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | — | Current model value. |
item | AnchorOptionData | — | Current item data. |
linkProps | Omit<AnchorLinkProps, 'href'> | — | Properties forwarded to the link element. |
indicatorProps | HTMLAttributes | — | Properties forwarded to the indicator element. |
titleProps | HTMLAttributes | — | Properties forwarded to the title element. |
subProps | HTMLAttributes | — | Properties forwarded to the sub element. |
AnchorLink
Props
| Prop | Type | Default | Description |
|---|---|---|---|
href | string | — | The link of anchor |
disabled | boolean | — | Whether the component is disabled. |
to | string | import("vue-router").RouteLocationAsRelativeGeneric | import("vue-router").RouteLocationAsPathGeneric | — | Route Location the link should navigate to when clicked on. |
external | boolean | — | Forces the link to be considered as external (true) or internal (false). This is helpful to handle edge-cases |
target | (string & {}) | '_blank' | '_parent' | '_self' | '_top' | null | — | Where to display the linked URL, as the name for a browsing context. |
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
AnchorRoot
Props
| Prop | Type | Default | Description |
|---|---|---|---|
bounds | number | — | Bounds. |
dir | Direction | — | Reading direction of the component. |
getContainer | (() => AnchorContainer | null) | — | Get container. |
getCurrentAnchor | ((activeHref: string) => string) | — | Get current anchor. |
modelValue | string | — | Current model value. |
offsetTop | number | — | Offset top. |
orientation | DataOrientation | — | Orientation of the component. |
replace | boolean | — | Whether replace. |
targetOffset | number | — | Target offset. |
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
Emits
| Emit Name | Parameters | Description |
|---|---|---|
update:modelValue | string | Emitted when the model value changes. |
activeChange | string | Emitted when active change occurs. |
itemSelect | MouseEvent, payload: { href: string; } | Emitted when item select occurs. |
Notes
Architecture and benchmark comparison
| Concern | VeanUI | Ant Design Anchor | Element Plus Anchor |
|---|---|---|---|
| Aria / styled separation | ✅ @vean/aria/anchor + scv() | ❌ single package | ❌ single package |
| Data-driven compact API | ✅ generic AnchorCompact + recursive items | ✅ items | ✅ items |
| Scroll container | ✅ getContainer (HTMLElement or window) | ✅ getContainer | ✅ container |
| Scroll offset | ✅ offsetTop + targetOffset | ✅ offsetTop + targetOffset | ✅ offset |
| Active bounds tolerance | ✅ bounds | ✅ bounds | ✅ bound |
| Active href transform | ✅ getCurrentAnchor | ✅ getCurrentAnchor | — |
| Hash sync | ✅ pushState on click / replaceState on scroll | ✅ | ✅ |
| Initial hash scroll | ✅ | ✅ | — |
| Sticky rail | ✅ CSS sticky + offset var | ✅ affix wrapper | ✅ CSS sticky |
| Nested items | ✅ recursive (arbitrary depth) | ❌ flat | ✅ 2 levels |
| Disabled items | ✅ per-item + linkProps fallback | ❌ | ❌ |
| Custom link props | ✅ linkProps / indicatorProps / titleProps / subProps | — | — |
| Orientation | ✅ vertical / horizontal | ❌ vertical only | ✅ direction |
| Theme colors / sizes | ✅ 8 colors × 6 sizes | ❌ | ❌ |
| Events | ✅ activeChange / itemSelect | ✅ onChange / onClick | ✅ change |
Runtime considerations
- Measurement timing — anchor positions are re-measured on every scroll event against the current scroll container. Sections that do not exist yet (or links not yet registered) are skipped, so the active item is only derived from registered links.
- Controlled vs. uncontrolled — when
modelValueis provided, internal writes only emitupdate:modelValue; the highlighted link follows the prop and no scrolling is triggered by external value changes. getContainerinstability — the component re-syncs whenever the container reference changes; returning a new element from every call causes repeated re-syncs, so memoize it when the element is created asynchronously.- Hash-only links — only links starting with
#participate in scrolling and hash updates; plain/external hrefs are left to the browser’s default navigation (and theitemSelectevent still fires). - Scroll-animation guard — during the 300 ms after a programmatic scroll the scroll-spy is suspended so the intermediate positions do not flip the active item or rewrite the hash.
- Sticky rail —
stickyrequires a scrollable ancestor for the rail to actually pin; the offset var keeps the rail below sticky headers. Setsticky: falsefor non-pinned usage. - Disabled links — a disabled item is inert:
aria-disabled="true",tabindex="-1", and click/keyboard activation are blocked (noitemSelect). It stays registered so nested children can still be highlighted.
FAQ
How do I scroll an inner container instead of the window?
Pass getContainer={() => myScrollableEl}. Anchor positions are then measured relative to that element and scrolling is applied to it. When the element becomes available after mount, Anchor re-syncs automatically.
How do I keep headings below a sticky header?
Set offsetTop to the header height. SAnchor scrolls to elementTop - offsetTop and exposes the same value as --vean-anchor-offset-top so the sticky anchor rail also clears the header. Use targetOffset if the anchor offset must differ from the scroll offset.
Why does the address bar update on scroll?
This is intentional — scroll-driven changes use history.replaceState so the URL hash always matches the section in view without flooding the history. Click-driven jumps use pushState unless you set replace.
How is the active section chosen?
A section becomes active once its measured top is at or above offsetTop + bounds. Among the passed sections the one closest to the threshold (the most recently crossed) is highlighted. bounds increases the tolerance; getCurrentAnchor lets you remap the resolved value before it is emitted.
Can I disable or customize individual links?
Yes — each item supports disabled and target; global defaults go through linkProps. Because the item’s href always owns the destination, linkProps.href is not part of the type. indicatorProps / titleProps / subProps forward HTML attributes to the per-item elements.
What does the nested composition look like?
Each item renders an AnchorLink (indicator + title) and, when it has children, a sub container that recursively renders AnchorItemCompact levels. The wrapper carries data-vean-anchor-item with a data-state reflecting whether the item or any of its descendants is active.