Anchor
Vue navigationIn-page navigation list with scroll-spy. Renders a vertical list of links; the active item highlights as the user scrolls through anchored sections.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/anchor.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/anchor.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/anchor.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/anchor.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/anchor
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
items | AnchorItem[] | () => [], offsetTop: 0, bounds: 5, scrollContainer: null,… | optional |
offsetTop | number | — | optional |
bounds | number | — | optional |
scrollContainer | HTMLElement | string | null | — | optional |
affix | boolean | — | optional |
class | HTMLAttributes['class'] | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
AnchorContext interface AnchorContext {
activeHref: Ref<string | null>
setActive: (href: string) => void
register: (href: string) => void
unregister: (href: string) => void
scrollContainer: Ref<HTMLElement | Window>
offsetTop: Ref<number>
} AnchorItem interface AnchorItem {
href: string
title: string
children?: AnchorItem[]
} Files (4)
-
app/components/ui/anchor/Anchor.vue 3.6 kB
<script setup lang="ts"> import { onBeforeUnmount, onMounted, provide, ref, toRef, watch } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import type { AnchorItem } from '.' import { ANCHOR_INJECTION_KEY } from './context' import AnchorLink from './AnchorLink.vue' const props = withDefaults( defineProps<{ items?: AnchorItem[] offsetTop?: number bounds?: number scrollContainer?: HTMLElement | string | null affix?: boolean class?: HTMLAttributes['class'] }>(), { items: () => [], offsetTop: 0, bounds: 5, scrollContainer: null, affix: false, }, ) const emits = defineEmits<{ (e: 'change', href: string): void }>() const activeHref = ref<string | null>(null) const registered = ref<Set<string>>(new Set()) const resolvedContainer = ref<HTMLElement | Window>(typeof window !== 'undefined' ? window : (null as any)) function resolveContainer(): HTMLElement | Window { if (!props.scrollContainer) return window if (typeof props.scrollContainer === 'string') { return (document.querySelector(props.scrollContainer) as HTMLElement) ?? window } return props.scrollContainer } let observer: IntersectionObserver | null = null const observed = new Map<string, Element>() function rebuildObserver() { if (observer) { observer.disconnect() observed.clear() } const root = resolvedContainer.value === window ? null : (resolvedContainer.value as HTMLElement) observer = new IntersectionObserver( (entries) => { const intersecting = entries.filter((e) => e.isIntersecting) if (intersecting.length === 0) return intersecting.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) const id = '#' + intersecting[0]!.target.id if (id !== activeHref.value) { activeHref.value = id emits('change', id) } }, { root, rootMargin: `-${props.offsetTop}px 0px -60% 0px`, threshold: [0, 1], }, ) for (const href of registered.value) { const el = document.querySelector(href) if (el) { observed.set(href, el) observer.observe(el) } } } function setActive(href: string) { activeHref.value = href emits('change', href) } function register(href: string) { registered.value.add(href) if (!observer) return const el = document.querySelector(href) if (el && !observed.has(href)) { observed.set(href, el) observer.observe(el) } } function unregister(href: string) { registered.value.delete(href) const el = observed.get(href) if (el && observer) observer.unobserve(el) observed.delete(href) } provide(ANCHOR_INJECTION_KEY, { activeHref, setActive, register, unregister, scrollContainer: resolvedContainer, offsetTop: toRef(props, 'offsetTop'), }) onMounted(() => { resolvedContainer.value = resolveContainer() rebuildObserver() }) onBeforeUnmount(() => { observer?.disconnect() observer = null }) watch( () => props.scrollContainer, () => { resolvedContainer.value = resolveContainer() rebuildObserver() }, ) </script> <template> <nav data-uipkge data-slot="anchor" aria-label="Table of contents" :class="cn('border-border flex flex-col gap-1 border-l text-sm', affix && 'sticky', props.class)" :style="affix ? { top: `${offsetTop}px` } : undefined" > <template v-if="items.length"> <AnchorLink v-for="item in items" :key="item.href" :href="item.href" :title="item.title"> <AnchorLink v-for="child in item.children ?? []" :key="child.href" :href="child.href" :title="child.title" /> </AnchorLink> </template> <slot v-else /> </nav> </template> -
app/components/ui/anchor/AnchorLink.vue 2.1 kB
<script setup lang="ts"> import { computed, inject, onBeforeUnmount, onMounted, useSlots } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { ANCHOR_INJECTION_KEY } from './context' const props = defineProps<{ href: string title: string class?: HTMLAttributes['class'] }>() const _maybeCtx = inject(ANCHOR_INJECTION_KEY, null) if (!_maybeCtx) throw new Error('AnchorLink must be used inside <Anchor>.') const ctx: NonNullable<typeof _maybeCtx> = _maybeCtx const isActive = computed(() => ctx.activeHref.value === props.href) const slots = useSlots() onMounted(() => ctx.register(props.href)) onBeforeUnmount(() => ctx.unregister(props.href)) function onClick(e: MouseEvent) { e.preventDefault() const el = document.querySelector(props.href) as HTMLElement | null if (!el) return const offset = ctx.offsetTop.value const container = ctx.scrollContainer.value const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto' if (container === window) { const top = el.getBoundingClientRect().top + window.scrollY - offset window.scrollTo({ top, behavior }) } else { const c = container as HTMLElement const top = el.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop - offset c.scrollTo({ top, behavior }) } history.replaceState(null, '', props.href) ctx.setActive(props.href) } </script> <template> <div data-uipkge data-slot="anchor-link" class="flex flex-col"> <a :href="href" :aria-current="isActive ? 'location' : undefined" :class=" cn( 'text-muted-foreground -ml-px block border-l-2 border-transparent py-2 pl-3 transition-colors', 'hover:text-foreground', 'focus-visible:ring-ring rounded-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none', isActive && 'border-primary text-foreground font-medium', props.class, ) " @click="onClick" > {{ title }} </a> <div v-if="slots.default" class="ml-3"> <slot /> </div> </div> </template> -
app/components/ui/anchor/context.ts 0.4 kB
import type { InjectionKey, Ref } from 'vue' export interface AnchorContext { activeHref: Ref<string | null> setActive: (href: string) => void register: (href: string) => void unregister: (href: string) => void scrollContainer: Ref<HTMLElement | Window> offsetTop: Ref<number> } export const ANCHOR_INJECTION_KEY: InjectionKey<AnchorContext> = Symbol('uipkge-anchor') -
app/components/ui/anchor/index.ts 0.2 kB
export interface AnchorItem { href: string title: string children?: AnchorItem[] } export { default as Anchor } from './Anchor.vue' export { default as AnchorLink } from './AnchorLink.vue'
Raw manifest: https://uipkge.dev/r/vue/anchor.json