← Visual Library / Navigation

Scroll-spy active nav

The active nav link automatically follows the user's scroll position. Scroll to "Carta" and the red pill jumps to "Carta." A small detail that makes the site feel alive.

Navigation Captured
Use when — any single-page site where the nav links jump to anchor sections. The visual confirmation that "you are here" reduces cognitive load and lets the user navigate by section name instead of scrolling blind. Pair with the glass-pill nav or any nav style that has a clear active state.

Live demo

Scroll inside the frame — watch the red pill move from Inicio → Nosotros → Carta → Contacto as each section comes into view.

Inicio

Scroll down — the active pill in the nav will follow you.

Nosotros

Notice how the red pill jumps to "Nosotros" once enough of this section is on screen.

Carta

The threshold sits around 50% of the section being visible — feels deliberate but not laggy.

Contacto

Scroll back up and the pill returns. IntersectionObserver handles both directions.

How it works

An IntersectionObserver watches every section. When a section's intersection ratio passes a threshold (typically 0.5 — 50% visible), its data-section ID is captured. The corresponding nav link gets .is-active; all other links lose it. The CSS handles the visual swap.

HTML — pair the nav and sections by ID

<nav>
  <a href="#inicio"   data-spy="inicio"  class="is-active">Inicio</a>
  <a href="#nosotros" data-spy="nosotros">Nosotros</a>
  <a href="#carta"    data-spy="carta">Carta</a>
  <a href="#contacto" data-spy="contacto">Contacto</a>
</nav>

<section id="inicio"   data-section="inicio">...</section>
<section id="nosotros" data-section="nosotros">...</section>
<section id="carta"    data-section="carta">...</section>
<section id="contacto" data-section="contacto">...</section>

JS — IntersectionObserver, ~12 lines

const links = document.querySelectorAll('[data-spy]');
const byId = id => document.querySelector(`[data-spy="${id}"]`);

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      links.forEach(l => l.classList.remove('is-active'));
      byId(entry.target.dataset.section)?.classList.add('is-active');
    }
  });
}, { threshold: 0.5, rootMargin: '-72px 0px 0px 0px' });
// rootMargin pulls the trigger zone down by header height (72px),
// so a section becomes "active" when its visible portion (below the header) crosses 50%.

document.querySelectorAll('[data-section]').forEach(s => observer.observe(s));

Gotchas worth knowing

rootMargin compensates for sticky header. If your header is 72px tall and you don't offset for it, sections become "active" too early (before the user can actually see them under the header). Use rootMargin: '-72px 0px 0px 0px' to push the observation zone below the header.

Threshold tuning — 0.5 (50% visible) is the sweet spot. Lower (0.2) makes the nav feel jumpy; higher (0.8) makes it lag behind. Test by scrolling slowly with your eyes on the nav.

Short final section — if the last section is shorter than the threshold can register, it never becomes "active." Either shorten the threshold for it specifically, or accept that "Contacto" stays active when you reach the footer too.

Click-to-scroll keeps working — the nav links are real anchor links. The IntersectionObserver fires after the smooth scroll lands. Don't override the click — let the browser do its thing.

Source: Chevy 55 main navigation behavior (user-uploaded HTML, 2026-05-18).