🔥 Oxide UI v0.1.0 — Dark mode only, copy-paste ready. Get started →
Skip to content

Scrollspy

IntersectionObserver + active class

Tracks which section is in view and reflects it in a sticky side-nav. Used for long-form pages, docs, and article tables of contents.

Overview

Preview

Introduction

Oxide UI is a dark-mode-only Tailwind CSS component library built for Nuxt. All components are copy-paste ready with zero runtime dependencies for static parts.

Installation

Install via npm with @headlessui/vue and @nuxtjs/tailwindcss. Copy the Tailwind config from the theming page to get the zinc colour palette and oxide-red accent.

Theming

Override CSS variables in your global stylesheet. The accent colour #D40C37 is used sparingly — active states, links, focus rings, and brand moments only.

Components

Browse 25 components in the sidebar. Every component page has a live preview, per-variant code snippets, Headless UI examples, and accessibility notes.

Examples

Four standalone HTML pages show full page layouts: landing page, tool app, dashboard, and marketing sections. Each opens in a new tab and can be inspected directly.

Structure

A scrollspy has two parts: a scrollable content area with id-tagged sections, and a sticky nav that links to them and highlights the active one.

html
<div class="flex gap-8">
  <!-- Scrollable content -->
  <div ref="contentEl" class="flex-1 overflow-y-auto">
    <section id="intro"      class="min-h-40 ...">...</section>
    <section id="install"    class="min-h-40 ...">...</section>
    <section id="components" class="min-h-40 ...">...</section>
  </div>

  <!-- Sticky nav -->
  <nav class="w-40 shrink-0 sticky top-6 self-start space-y-0.5" aria-label="On this page">
    <p class="mb-2 text-[9px] font-semibold uppercase tracking-widest text-zinc-600">On this page</p>
    <a
      v-for="section in sections"
      :key="section.id"
      :href="'#' + section.id"
      @click.prevent="scrollTo(section.id)"
      class="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-all"
      :class="active === section.id
        ? 'text-[#D40C37] bg-[#D40C37]/8 font-medium'
        : 'text-zinc-600 hover:text-zinc-300'"
    >
      <span class="h-1 w-1 rounded-full transition-colors"
        :class="active === section.id ? 'bg-[#D40C37]' : 'bg-zinc-700'"></span>
      {{ section.label }}
    </a>
  </nav>
</div>

IntersectionObserver setup

vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const contentEl = ref(null)
const active = ref('intro')

const sections = [
  { id: 'intro',      label: 'Introduction' },
  { id: 'install',    label: 'Installation' },
  { id: 'theming',    label: 'Theming'      },
  { id: 'components', label: 'Components'   },
]

let observer = null

function scrollTo(id) {
  document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}

onMounted(() => {
  observer = new IntersectionObserver(
    (entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) active.value = entry.target.id
      })
    },
    {
      root: contentEl.value, // observe within the scrollable container
      threshold: 0.4,        // 40% of section visible = active
    }
  )

  sections.forEach(s => {
    const el = document.getElementById(s.id)
    if (el) observer.observe(el)
  })
})

onUnmounted(() => observer?.disconnect())
</script>

Full-page scrollspy (no scroll container)

When the entire page scrolls, pass root: null and use rootMargin to trigger slightly before centre:

vue
observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) active.value = entry.target.id
    })
  },
  {
    root: null,              // observe against the viewport
    rootMargin: '-20% 0px -70% 0px',  // active zone = top 20–30% of viewport
    threshold: 0,
  }
)

Nuxt composable

ts
// composables/useScrollspy.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useScrollspy(ids: string[], options?: IntersectionObserverInit) {
  const active = ref(ids[0])
  let observer: IntersectionObserver | null = null

  onMounted(() => {
    observer = new IntersectionObserver(entries => {
      entries.forEach(e => { if (e.isIntersecting) active.value = e.target.id })
    }, { threshold: 0.4, ...options })

    ids.forEach(id => {
      const el = document.getElementById(id)
      if (el) observer!.observe(el)
    })
  })

  onUnmounted(() => observer?.disconnect())

  return { active }
}

// Usage
const { active } = useScrollspy(['intro', 'install', 'theming'])

Accessibility notes

  • The sticky nav is a <nav> landmark with aria-label="On this page" to distinguish it from primary nav
  • Active link: add aria-current="true" on the currently active anchor
  • Smooth scrolling should respect prefers-reduced-motion — use behavior: 'auto' when the preference is set
  • @click.prevent intercepts the default jump-scroll and replaces it with smooth scroll; href still points to the section for right-click / open-in-new-tab

Released under the MIT License.