Scrollspy
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
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.
<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
<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:
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
// 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 witharia-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— usebehavior: 'auto'when the preference is set @click.preventintercepts the default jump-scroll and replaces it with smooth scroll;hrefstill points to the section for right-click / open-in-new-tab