# Sticky Subnav

> A sticky secondary navigation that highlights the current section on scroll, with smooth scrolling and mobile overflow fades.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/sticky-subnav
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/sticky-subnav.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/sticky-subnav-demo.tsx"
"use client"

import * as React from "react"

import { StickySubnav, type SubnavSection } from "@/components/sticky-subnav"

const sections: SubnavSection[] = [
  { id: "spy-overview", label: "Overview" },
  { id: "spy-features", label: "Features" },
  { id: "spy-pricing", label: "Pricing" },
  { id: "spy-reviews", label: "Reviews" },
  { id: "spy-faq", label: "FAQ" },
]

function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
  return (
    <section id={id} aria-labelledby={`${id}-title`} className="grid min-h-72 content-start gap-3 border-b px-5 py-8 last:border-b-0">
      <h2 id={`${id}-title`} className="text-lg font-semibold">
        {title}
      </h2>
      {children}
    </section>
  )
}

const features = [
  ["Smart scheduling", "Finds the slot that works for everyone, time zones included."],
  ["Shared calendars", "Layer team, project and personal calendars in one view."],
  ["Focus time", "Protects deep-work blocks and declines meetings on your behalf."],
]

const plans = [
  ["Starter", "$0", "For individuals"],
  ["Team", "$12", "Per seat, billed yearly"],
  ["Scale", "Custom", "SSO and audit logs"],
]

export default function StickySubnavDemo() {
  const container = React.useRef<HTMLDivElement>(null)

  return (
    <div
      ref={container}
      role="region"
      aria-label="Product page preview"
      tabIndex={0}
      className="h-[28rem] w-full max-w-2xl overflow-y-auto rounded-2xl border bg-background focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"
    >
      <header className="grid gap-2 border-b bg-muted/40 px-5 py-8">
        <p className="text-xs font-medium tracking-wide text-primary uppercase">Orbit Calendar</p>
        <h1 className="text-2xl font-semibold text-balance">Plan the week in half the time</h1>
        <p className="max-w-md text-sm text-muted-foreground">Scroll the page and watch the section bar follow along.</p>
      </header>
      <StickySubnav
        sections={sections}
        scrollContainer={container}
        actions={
          <button
            type="button"
            className="hidden h-8 rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none sm:inline-flex sm:items-center"
          >
            Start free trial
          </button>
        }
      />
      <Section id="spy-overview" title="Overview">
        <p className="max-w-prose text-sm text-muted-foreground">
          Orbit brings meetings, tasks and focus time into one calendar. Teams use it to cut scheduling back-and-forth and keep their
          afternoons free for real work.
        </p>
        <p className="max-w-prose text-sm text-muted-foreground">Set up takes about five minutes and works with the calendars you already use.</p>
      </Section>
      <Section id="spy-features" title="Features">
        <ul className="grid gap-3 sm:grid-cols-3">
          {features.map(([name, copy]) => (
            <li key={name} className="rounded-xl border bg-card p-3">
              <p className="text-sm font-medium">{name}</p>
              <p className="mt-1 text-xs text-muted-foreground">{copy}</p>
            </li>
          ))}
        </ul>
      </Section>
      <Section id="spy-pricing" title="Pricing">
        <ul className="grid gap-3 sm:grid-cols-3">
          {plans.map(([name, price, note]) => (
            <li key={name} className="rounded-xl border bg-card p-3">
              <p className="text-sm font-medium">{name}</p>
              <p className="mt-1 text-xl font-semibold">{price}</p>
              <p className="text-xs text-muted-foreground">{note}</p>
            </li>
          ))}
        </ul>
      </Section>
      <Section id="spy-reviews" title="Reviews">
        <blockquote className="max-w-prose rounded-xl border bg-card p-4 text-sm text-muted-foreground">
          &ldquo;We stopped losing Mondays to scheduling threads. Orbit paid for itself in the first week.&rdquo;
          <footer className="mt-2 text-xs font-medium text-foreground">Priya N., Head of Operations</footer>
        </blockquote>
      </Section>
      <Section id="spy-faq" title="FAQ">
        <dl className="grid max-w-prose gap-3 text-sm">
          <div>
            <dt className="font-medium">Does it work with existing calendars?</dt>
            <dd className="text-muted-foreground">Yes, Orbit syncs two-way with the major calendar providers.</dd>
          </div>
          <div>
            <dt className="font-medium">Can I cancel any time?</dt>
            <dd className="text-muted-foreground">Monthly plans cancel instantly; yearly plans refund unused months.</dd>
          </div>
        </dl>
      </Section>
    </div>
  )
}
```

## Source

```tsx title="components/sticky-subnav.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface SubnavSection {
  /** id of the page section this link scrolls to */
  id: string
  label: string
}

export interface StickySubnavProps extends Omit<React.ComponentProps<"nav">, "children"> {
  sections: SubnavSection[]
  /** The scrolling element that contains the sections. Omit to use the page itself */
  scrollContainer?: React.RefObject<HTMLElement | null>
  /** Optional content pinned to the right edge, e.g. a call-to-action */
  actions?: React.ReactNode
}

function findSection(root: HTMLElement | null, id: string) {
  return root ? root.querySelector<HTMLElement>(`#${CSS.escape(id)}`) : document.getElementById(id)
}

function prefersReducedMotion() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}

export function StickySubnav({ sections, scrollContainer, actions, className, ...props }: StickySubnavProps) {
  const [active, setActive] = React.useState(sections[0]?.id)
  const [stuck, setStuck] = React.useState(false)
  const [fade, setFade] = React.useState({ start: false, end: false })
  const nav = React.useRef<HTMLElement>(null)
  const scroller = React.useRef<HTMLDivElement>(null)
  const sentinel = React.useRef<HTMLDivElement>(null)
  // Ignore observer updates while a click-initiated smooth scroll is still travelling
  const lockUntil = React.useRef(0)
  const ids = sections.map((section) => section.id).join("|")

  // Scroll-spy: the active section is the lowest one that reaches the upper half of the scroll area
  React.useEffect(() => {
    const root = scrollContainer?.current ?? null
    const order = ids.split("|")
    const targets = order.map((id) => findSection(root, id))
    if (targets.every((target) => !target)) return
    const visible = new Set<string>()
    const scrollEl = root ?? document.documentElement

    const apply = (id: string | undefined) => {
      if (id && performance.now() >= lockUntil.current) setActive(id)
    }
    const evaluate = () => {
      const scrollable = scrollEl.scrollHeight > scrollEl.clientHeight + 2
      if (scrollable && scrollEl.scrollTop <= 1) apply(order[0])
      else if (scrollable && scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 2) apply(order[order.length - 1])
      else apply([...order].reverse().find((id) => visible.has(id)))
    }

    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) visible.add(entry.target.id)
          else visible.delete(entry.target.id)
        }
        evaluate()
      },
      { root, rootMargin: `-${(nav.current?.offsetHeight ?? 48) + 8}px 0px -55% 0px` }
    )
    targets.forEach((target) => target && observer.observe(target))

    // The first and last sections may never reach the observed band, so also react to the scroll edges
    const eventTarget: EventTarget = root ?? window
    eventTarget.addEventListener("scroll", evaluate, { passive: true })
    return () => {
      observer.disconnect()
      eventTarget.removeEventListener("scroll", evaluate)
    }
  }, [ids, scrollContainer])

  // Stuck state: a sentinel just above the bar leaves the scroll area once the bar is pinned
  React.useEffect(() => {
    const el = sentinel.current
    if (!el) return
    const observer = new IntersectionObserver(
      ([entry]) => setStuck(!entry.isIntersecting && entry.boundingClientRect.top < (entry.rootBounds?.top ?? 0)),
      { root: scrollContainer?.current ?? null }
    )
    observer.observe(el)
    return () => observer.disconnect()
  }, [scrollContainer])

  // Edge fades for the horizontally scrolling tab strip
  React.useEffect(() => {
    const el = scroller.current
    if (!el) return
    const update = () => {
      const start = el.scrollLeft > 1
      const end = el.scrollLeft + el.clientWidth < el.scrollWidth - 1
      setFade((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
    }
    el.addEventListener("scroll", update, { passive: true })
    const observer = new ResizeObserver(update)
    observer.observe(el)
    return () => {
      el.removeEventListener("scroll", update)
      observer.disconnect()
    }
  }, [])

  // Keep the active tab centred in the strip when it overflows (e.g. on mobile)
  React.useEffect(() => {
    const strip = scroller.current
    const link = strip?.querySelector<HTMLElement>('[aria-current="location"]')
    if (!strip || !link || strip.scrollWidth <= strip.clientWidth) return
    const offset = link.getBoundingClientRect().left - strip.getBoundingClientRect().left + strip.scrollLeft
    strip.scrollTo({ left: offset - (strip.clientWidth - link.offsetWidth) / 2, behavior: prefersReducedMotion() ? "auto" : "smooth" })
  }, [active])

  const go = (event: React.MouseEvent, id: string) => {
    event.preventDefault()
    const root = scrollContainer?.current ?? null
    const target = findSection(root, id)
    if (!target) return
    const reduced = prefersReducedMotion()
    const navHeight = nav.current?.offsetHeight ?? 0
    const current = root ? root.scrollTop : window.scrollY
    const top = current + target.getBoundingClientRect().top - (root ? root.getBoundingClientRect().top : 0) - navHeight
    // event.timeStamp shares its time origin with performance.now()
    lockUntil.current = event.timeStamp + (reduced ? 50 : 800)
    setActive(id)
    ;(root ?? window).scrollTo({ top, behavior: reduced ? "auto" : "smooth" })
  }

  return (
    <>
      <div ref={sentinel} aria-hidden className="pointer-events-none -mb-px h-px w-full" />
      <nav
        ref={nav}
        aria-label="Page sections"
        data-stuck={stuck || undefined}
        className={cn(
          "sticky top-0 z-20 flex h-12 items-center gap-2 border-b bg-background/90 px-3 backdrop-blur transition-shadow motion-reduce:transition-none data-[stuck]:shadow-sm",
          className
        )}
        {...props}
      >
        <div className="relative min-w-0 flex-1">
          <div ref={scroller} className="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
            <ul className="flex w-max items-center gap-1">
              {sections.map((section) => (
                <li key={section.id}>
                  <a
                    href={`#${section.id}`}
                    aria-current={section.id === active ? "location" : undefined}
                    onClick={(event) => go(event, section.id)}
                    className="relative flex h-12 items-center rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-colors outline-none after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:origin-center after:scale-x-0 after:rounded-full after:bg-primary after:transition-transform hover:text-foreground focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-ring/40 aria-[current=location]:text-foreground aria-[current=location]:after:scale-x-100 motion-reduce:after:transition-none"
                  >
                    {section.label}
                  </a>
                </li>
              ))}
            </ul>
          </div>
          <span
            aria-hidden
            className={cn(
              "pointer-events-none absolute inset-y-0 left-0 w-8 bg-linear-to-r from-background to-transparent transition-opacity",
              fade.start ? "opacity-100" : "opacity-0"
            )}
          />
          <span
            aria-hidden
            className={cn(
              "pointer-events-none absolute inset-y-0 right-0 w-8 bg-linear-to-l from-background to-transparent transition-opacity",
              fade.end ? "opacity-100" : "opacity-0"
            )}
          />
        </div>
        {actions && <div className="shrink-0">{actions}</div>}
      </nav>
    </>
  )
}
```
