Floating Navbar That Shrinks to a Pill on Scroll

A site header shown over a sample studio page inside a fixed-height frame. At the top it is a wide, transparent bar with a logo, four links and a button. Once the frame scrolls, it springs into a compact glass pill: the wordmark and the button text fold away and a shadow appears. A dark highlight slides between links as each section comes into view, and clicking a link scrolls to it. On phones the bar is already compact and the same pill fits a 360px screen.

Code

import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { motion, useReducedMotion } from "framer-motion";

const links = [
  { id: "intro", label: "Intro" },
  { id: "work", label: "Work" },
  { id: "studio", label: "Studio" },
  { id: "contact", label: "Contact" },
];

const projects = [
  { name: "Saltmarsh Cider", kind: "Identity and packaging", tile: "from-amber-300 to-orange-500", shape: "rounded-full" },
  { name: "Quillon Bikes", kind: "Brand system", tile: "from-sky-300 to-blue-600", shape: "rounded-[28%] rotate-12" },
  { name: "Brae Health", kind: "Product design", tile: "from-emerald-300 to-teal-600", shape: "rounded-t-full" },
  { name: "Osk Records", kind: "Web and motion", tile: "from-rose-300 to-fuchsia-600", shape: "rounded-full scale-x-50" },
];

const facts = [
  { value: "11", label: "People in the studio" },
  { value: "64", label: "Brands launched" },
  { value: "9 yrs", label: "Average client stay" },
];

// The bar turns into a pill once the frame has scrolled this many pixels.
const SHRINK_AFTER = 28;
const DEMO_SECONDS = 10;

function easeDemo(t: number) {
  if (t < 0.1) return (5 * t * t) / 0.9;
  if (t > 0.9) return 1 - (5 * (1 - t) * (1 - t)) / 0.9;
  return (t - 0.05) / 0.9;
}

/** Scrolls the frame slowly once so the preview shows the effect. Stops for good on any input. */
function useDemoScroll(ref: RefObject<HTMLDivElement>, enabled: boolean, seconds: number) {
  useEffect(() => {
    const el = ref.current;
    if (!el || !enabled) return;
    let raf = 0;
    let last = 0;
    let t = 0;
    let done = false;
    const inputs = ["wheel", "touchstart", "pointerdown", "keydown"];
    const tick = (now: number) => {
      if (done) return;
      const dt = last ? Math.min(now - last, 50) : 0;
      last = now;
      t = Math.min(1, t + dt / (seconds * 1000));
      el.scrollTop = easeDemo(t) * (el.scrollHeight - el.clientHeight);
      if (t < 1) raf = requestAnimationFrame(tick);
    };
    const io = new IntersectionObserver(
      ([entry]) => {
        cancelAnimationFrame(raf);
        last = 0;
        if (entry.isIntersecting && !done) raf = requestAnimationFrame(tick);
      },
      { threshold: 0.25 }
    );
    const stop = () => {
      done = true;
      cancelAnimationFrame(raf);
    };
    io.observe(el);
    inputs.forEach((name) => el.addEventListener(name, stop, { passive: true }));
    return () => {
      stop();
      io.disconnect();
      inputs.forEach((name) => el.removeEventListener(name, stop));
    };
  }, [ref, enabled, seconds]);
}

export default function FloatingNavbarThatShrinksToPillOnScroll() {
  const reduced = !!useReducedMotion();
  const frame = useRef<HTMLDivElement>(null);
  const sectionEls = useRef<(HTMLElement | null)[]>([]);
  const linkEls = useRef<(HTMLAnchorElement | null)[]>([]);
  const [shrunk, setShrunk] = useState(false);
  const [active, setActive] = useState(0);
  const [pill, setPill] = useState({ x: 0, width: 0 });

  useDemoScroll(frame, !reduced, DEMO_SECONDS);

  useEffect(() => {
    const el = frame.current;
    if (!el) return;
    let raf = 0;
    const measure = () => {
      raf = 0;
      setShrunk(el.scrollTop > SHRINK_AFTER);
      const top = el.getBoundingClientRect().top;
      let current = 0;
      sectionEls.current.forEach((node, i) => {
        if (node && node.getBoundingClientRect().top - top <= el.clientHeight * 0.42) current = i;
      });
      if (el.scrollTop + el.clientHeight >= el.scrollHeight - 4) current = links.length - 1;
      setActive(current);
    };
    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(measure);
    };
    measure();
    el.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      cancelAnimationFrame(raf);
      el.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, []);

  // Slide the highlight under the active link by measuring that link.
  useEffect(() => {
    const place = () => {
      const node = linkEls.current[active];
      if (node) setPill({ x: node.offsetLeft, width: node.offsetWidth });
    };
    place();
    window.addEventListener("resize", place);
    return () => window.removeEventListener("resize", place);
  }, [active]);

  const go = (i: number) => {
    const el = frame.current;
    const node = sectionEls.current[i];
    if (!el || !node) return;
    const top = i === 0 ? 0 : node.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop - 76;
    el.scrollTo({ top, behavior: reduced ? "auto" : "smooth" });
  };

  const ease = "duration-500 ease-[cubic-bezier(0.3,1.35,0.5,1)] motion-reduce:transition-none";

  return (
    <section className="bg-zinc-200 px-4 py-10 sm:py-14">
      <div className="mx-auto max-w-5xl overflow-hidden rounded-3xl bg-zinc-50 shadow-xl shadow-zinc-900/10 ring-1 ring-zinc-900/10">
        <div
          ref={frame}
          tabIndex={0}
          role="region"
          aria-label="Fieldnote Studio page with a floating navbar"
          className="relative h-[620px] overflow-y-auto overscroll-contain focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-600"
        >
          {/* Zero-height sticky holder: the bar floats over the page without pushing content down. */}
          <div className="sticky top-0 z-30 h-0">
            <div className="flex justify-center px-3 pt-3">
              <nav
                aria-label="Main"
                className={`flex w-full items-center justify-between gap-1 border transition-all ${ease} ${
                  shrunk
                    ? "max-w-[352px] rounded-[32px] border-zinc-200 bg-white/85 p-1.5 shadow-xl shadow-zinc-900/10 backdrop-blur-md sm:max-w-[392px]"
                    : "max-w-[960px] rounded-2xl border-transparent bg-white/0 p-2 shadow-none sm:px-4 sm:py-3"
                }`}
              >
                <a href="#" onClick={(e) => { e.preventDefault(); go(0); }} aria-label="Fieldnote Studio, back to top" className="flex h-10 shrink-0 items-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600">
                  <span className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600">
                    <svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="white" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
                      <path d="M5 15V5h9M5 10h7" />
                    </svg>
                  </span>
                  <span className={`hidden overflow-hidden whitespace-nowrap text-sm font-semibold text-zinc-900 transition-all sm:block ${ease} ${shrunk ? "max-w-0 pl-0 opacity-0" : "max-w-[140px] pl-2.5 opacity-100"}`}>Fieldnote Studio</span>
                </a>

                <ul className="relative flex items-center">
                  <motion.li
                    aria-hidden="true"
                    className="absolute left-0 top-0 h-10 rounded-full bg-zinc-900"
                    initial={false}
                    animate={{ x: pill.x, width: pill.width, opacity: pill.width ? 1 : 0 }}
                    transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 30, mass: 0.8 }}
                  />
                  {links.map((link, i) => (
                    <li key={link.id}>
                      <a
                        ref={(node) => {
                          linkEls.current[i] = node;
                        }}
                        href="#"
                        onClick={(e) => {
                          e.preventDefault();
                          go(i);
                        }}
                        aria-current={active === i ? "location" : undefined}
                        className={`relative flex h-10 items-center rounded-full px-3 text-[13px] font-medium transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 sm:px-4 sm:text-sm ${active === i ? "text-white" : "text-zinc-700 hover:text-zinc-950"}`}
                      >
                        {link.label}
                      </a>
                    </li>
                  ))}
                </ul>

                <a
                  href="#"
                  onClick={(e) => {
                    e.preventDefault();
                    go(links.length - 1);
                  }}
                  aria-label="Start a project"
                  className="flex h-10 shrink-0 items-center rounded-full bg-blue-600 text-sm font-semibold text-white transition-colors hover:bg-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
                >
                  <span className={`hidden overflow-hidden whitespace-nowrap transition-all sm:block ${ease} ${shrunk ? "max-w-0 pl-0 opacity-0" : "max-w-[130px] pl-4 opacity-100"}`}>Start a project</span>
                  <span className="flex h-10 w-10 items-center justify-center">
                    <svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                      <path d="M5 15L15 5M7 5h8v8" />
                    </svg>
                  </span>
                </a>
              </nav>
            </div>
          </div>

          <div
            ref={(node) => {
              sectionEls.current[0] = node;
            }}
            className="relative overflow-hidden px-6 pb-16 pt-32 sm:px-12 sm:pt-36"
          >
            <div aria-hidden="true" className="absolute -right-24 -top-24 h-80 w-80 rounded-full bg-blue-200 opacity-60 blur-3xl" />
            <p className="relative inline-flex items-center gap-2 rounded-full bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 ring-1 ring-zinc-200">
              <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
              Booking projects for spring
            </p>
            <h2 className="relative mt-6 max-w-3xl text-4xl font-semibold leading-[1.02] tracking-tight text-zinc-950 sm:text-6xl">Brand systems for companies that make real things.</h2>
            <p className="relative mt-5 max-w-lg text-base leading-7 text-zinc-600">We are a small design studio in Rotterdam. Cider makers, bike builders and clinics hire us when the product is good and the brand has not caught up.</p>
          </div>

          <div
            ref={(node) => {
              sectionEls.current[1] = node;
            }}
            className="px-6 pb-16 sm:px-12"
          >
            <h3 className="text-sm font-semibold uppercase tracking-[0.2em] text-zinc-600">Selected work</h3>
            <ul className="mt-6 grid gap-4 sm:grid-cols-2">
              {projects.map((p) => (
                <li key={p.name} className="overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200">
                  <div className={`flex h-36 items-center justify-center bg-gradient-to-br ${p.tile}`}>
                    <span className={`h-16 w-16 bg-white/85 ${p.shape}`} />
                  </div>
                  <div className="flex items-center justify-between gap-3 p-4">
                    <p className="text-sm font-semibold text-zinc-900">{p.name}</p>
                    <p className="text-sm text-zinc-600">{p.kind}</p>
                  </div>
                </li>
              ))}
            </ul>
          </div>

          <div
            ref={(node) => {
              sectionEls.current[2] = node;
            }}
            className="bg-zinc-950 px-6 py-16 sm:px-12"
          >
            <h3 className="max-w-xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">Eleven people, one long table, no account managers.</h3>
            <p className="mt-4 max-w-lg text-base leading-7 text-zinc-300">The designer who draws your logo is the person who answers your email. That is the whole process.</p>
            <dl className="mt-10 grid grid-cols-3 gap-4">
              {facts.map((f) => (
                <div key={f.label} className="border-t border-white/20 pt-4">
                  <dt className="text-xs text-zinc-300 sm:text-sm">{f.label}</dt>
                  <dd className="mt-1 text-2xl font-semibold tabular-nums text-white sm:text-4xl">{f.value}</dd>
                </div>
              ))}
            </dl>
          </div>

          <div
            ref={(node) => {
              sectionEls.current[3] = node;
            }}
            className="px-6 py-16 sm:px-12"
          >
            <div className="rounded-3xl bg-blue-600 p-8 sm:p-12">
              <h3 className="max-w-md text-3xl font-semibold tracking-tight text-white sm:text-4xl">Have something worth branding?</h3>
              <p className="mt-3 max-w-md text-base text-blue-50">Tell us what you make. We reply within two working days, usually with questions.</p>
              <a href="#" className="mt-6 inline-flex h-12 items-center rounded-full bg-white px-6 text-sm font-semibold text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-blue-600">
                [email protected]
              </a>
            </div>
            <p className="mt-8 text-center text-xs text-zinc-600">Fieldnote Studio · Rotterdam · Est. 2014</p>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/floating-navbar-that-shrinks-to-pill-on-scroll.json

When to use it

How to customize it

Accessibility

Questions

How do I make a navbar shrink on scroll with Tailwind?

Store a boolean that is true once `scrollTop` passes a threshold, and switch between two sets of classes: one wide with a transparent background, one with a small `max-width`, a background, a border and a shadow. A `transition-all` class animates between them, because `max-width`, padding, radius and colour can all be interpolated.

How does the sliding active indicator work?

A single absolutely positioned element sits behind the links. When the active link changes, its `offsetLeft` and `offsetWidth` are measured and the indicator animates its `x` and `width` to match with a spring. Measuring keeps it correct for labels of any length.

How does it know which section is active?

On scroll it finds the last section whose top edge has passed a line 42% down the frame. Reaching the very bottom always selects the last link, so a short final section can still become active.

How do I attach it to the page instead of a frame?

Change the sticky holder to `fixed inset-x-0 top-0`, listen to `window` scroll, read `window.scrollY`, and use `window.scrollTo` in the `go` function. Remove the frame's fixed height and overflow.

Why is the sticky holder zero pixels tall?

A sticky element normally takes up space and pushes the page down. Giving the holder `h-0` lets the bar overflow it and float above the hero, while still scrolling and sticking with the frame.

Related: Animated Tabs with Sliding Highlight and Crossfade, Navbar with Centered Links, CTA Button and Mobile Menu, Reading Progress Bar with Section Reveals.

More navbars or all free UI components. Start for free to have a full site built around it.

More in Navbars

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap