UIPackage

Anchor

Vue navigation
Edit on GitHub

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

$ npx 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