Back to blog

React Navbar Components: Patterns and Copy-Paste Examples

Published on March 14, 2026·7 min read

Navigation is the skeleton of every web application. Users interact with the navbar on every page, every session. A poorly built navbar — one that flickers, mis-positions on scroll, or breaks on mobile — undermines trust immediately. This guide covers four essential patterns: the fixed navbar with backdrop blur, animated mobile menu, auth-aware navigation, and dropdown menus with outside-click dismissal.

Fixed Navbar with Backdrop Blur

The modern glass-morphism navbar scrolls with the page but stays fixed, with a frosted glass background that activates once the user scrolls past a threshold. This requires listening to scroll events in a useEffect:

"use client";

import { useState, useEffect } from "react";

export default function Navbar() {
  const [scrolled, setScrolled] = useState(false);

  useEffect(() => {
    function handleScroll() {
      setScrolled(window.scrollY > 16);
    }
    window.addEventListener("scroll", handleScroll, { passive: true });
    return () => window.removeEventListener("scroll", handleScroll);
  }, []);

  return (
    <header
      style={{
        position: "fixed",
        top: 0,
        left: 0,
        right: 0,
        zIndex: 50,
        transition: `
          background var(--duration-normal) var(--ease-out),
          box-shadow var(--duration-normal) var(--ease-out),
          backdrop-filter var(--duration-normal) var(--ease-out)
        `,
        background: scrolled
          ? "color-mix(in srgb, var(--color-background) 80%, transparent)"
          : "transparent",
        backdropFilter: scrolled ? "blur(12px)" : "none",
        borderBottom: scrolled
          ? "1px solid var(--color-border)"
          : "1px solid transparent",
      }}
    >
      {/* Nav content */}
    </header>
  );
}

The { passive: true } option on the scroll event listener is a performance hint to the browser — it promises you won't call preventDefault(), enabling smoother scrolling on mobile.

color-mix(in srgb, ...) is a native CSS function that mixes the background token with transparency, so the blur effect shows through correctly. For browsers without color-mix support, a fallback of rgba(255,255,255,0.8) works fine.

Inner Layout

The navbar content uses a flex row with three zones: logo left, links center, actions right:

<nav
  style={{
    maxWidth: "var(--container-max-width)",
    margin: "0 auto",
    padding: "0 var(--container-padding-x)",
    height: "4rem",
    display: "flex",
    alignItems: "center",
    justifyContent: "space-between",
    gap: "1.5rem",
  }}
>
  {/* Logo */}
  <a href="/" style={{ display: "flex", alignItems: "center", gap: "0.5rem", textDecoration: "none" }}>
    <span style={{ fontSize: "1.125rem", fontWeight: 700, color: "var(--color-foreground)" }}>
      Acme
    </span>
  </a>

  {/* Desktop links — hidden on mobile */}
  <div className="hidden md:flex" style={{ gap: "0.25rem" }}>
    {navLinks.map((link) => (
      <a
        key={link.href}
        href={link.href}
        style={{
          padding: "0.5rem 0.75rem",
          borderRadius: "var(--radius-md)",
          fontSize: "0.875rem",
          fontWeight: 500,
          color: "var(--color-foreground-muted)",
          textDecoration: "none",
          transition: `color var(--duration-fast) var(--ease-out)`,
        }}
      >
        {link.label}
      </a>
    ))}
  </div>

  {/* CTA + Hamburger */}
  <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
    <a href="/signup" className="hidden md:inline-flex" style={{
      padding: "0.5rem 1.25rem",
      borderRadius: "var(--radius-full)",
      background: "var(--color-accent)",
      color: "var(--color-foreground)",
      fontWeight: 600,
      fontSize: "0.875rem",
      textDecoration: "none",
    }}>
      Get started
    </a>

    {/* Hamburger — visible on mobile only */}
    <HamburgerButton open={mobileOpen} onClick={() => setMobileOpen(!mobileOpen)} />
  </div>
</nav>

Mobile Menu with AnimatePresence

The mobile menu slides down from the navbar using Framer Motion's AnimatePresence — this ensures the exit animation plays before the component is unmounted:

import { AnimatePresence, motion } from "framer-motion";

const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];

// Inside Navbar, after <nav>:
<AnimatePresence>
  {mobileOpen && (
    <motion.div
      initial={{ opacity: 0, height: 0 }}
      animate={{ opacity: 1, height: "auto" }}
      exit={{ opacity: 0, height: 0 }}
      transition={{ duration: 0.3, ease }}
      style={{ overflow: "hidden" }}
    >
      <div
        style={{
          padding: "1rem var(--container-padding-x) 1.5rem",
          borderTop: "1px solid var(--color-border)",
          display: "flex",
          flexDirection: "column",
          gap: "0.25rem",
          background: "var(--color-background)",
        }}
      >
        {navLinks.map((link, i) => (
          <motion.a
            key={link.href}
            href={link.href}
            initial={{ opacity: 0, x: -12 }}
            animate={{ opacity: 1, x: 0 }}
            transition={{ delay: i * 0.05, duration: 0.25, ease }}
            onClick={() => setMobileOpen(false)}
            style={{
              padding: "0.75rem",
              borderRadius: "var(--radius-md)",
              fontSize: "0.9375rem",
              fontWeight: 500,
              color: "var(--color-foreground)",
              textDecoration: "none",
            }}
          >
            {link.label}
          </motion.a>
        ))}
      </div>
    </motion.div>
  )}
</AnimatePresence>

Each link staggers with a 50ms delay so the menu feels purposeful rather than mechanical.

Auth-Aware Navigation

A navbar that knows about authentication state requires careful handling of the loading state. Avoid layout shifts (CLS) by reserving space while auth status is unknown:

"use client";

import { useSession } from "next-auth/react";

function NavActions() {
  const { data: session, status } = useSession();

  // Loading state — render a skeleton to prevent layout shift
  if (status === "loading") {
    return (
      <div style={{ display: "flex", gap: "0.75rem" }}>
        <div
          style={{
            width: "80px",
            height: "36px",
            borderRadius: "var(--radius-full)",
            background: "var(--color-border)",
            animation: "pulse 1.5s ease-in-out infinite",
          }}
        />
      </div>
    );
  }

  // Authenticated
  if (session?.user) {
    return (
      <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
        <a href="/dashboard" style={{
          padding: "0.5rem 1.25rem",
          borderRadius: "var(--radius-full)",
          background: "var(--color-accent)",
          color: "var(--color-foreground)",
          fontWeight: 600,
          fontSize: "0.875rem",
          textDecoration: "none",
        }}>
          Dashboard
        </a>
        <UserAvatar user={session.user} />
      </div>
    );
  }

  // Guest
  return (
    <div style={{ display: "flex", gap: "0.75rem" }}>
      <a href="/login" style={{
        padding: "0.5rem 1rem",
        fontSize: "0.875rem",
        fontWeight: 500,
        color: "var(--color-foreground-muted)",
        textDecoration: "none",
      }}>
        Sign in
      </a>
      <a href="/signup" style={{
        padding: "0.5rem 1.25rem",
        borderRadius: "var(--radius-full)",
        background: "var(--color-accent)",
        color: "var(--color-foreground)",
        fontWeight: 600,
        fontSize: "0.875rem",
        textDecoration: "none",
      }}>
        Get started
      </a>
    </div>
  );
}

The skeleton div has the same approximate dimensions as the actual buttons, so the navbar doesn't jump when auth resolves.

Dropdown Navigation with Outside-Click Dismissal

Dropdowns need to close when the user clicks outside. The cleanest pattern uses a ref on the container and a global mousedown listener:

import { useRef, useEffect, useState } from "react";

function NavDropdown({ label, items }: { label: string; items: NavItem[] }) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (ref.current && !ref.current.contains(event.target as Node)) {
        setOpen(false);
      }
    }
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  return (
    <div ref={ref} style={{ position: "relative" }}>
      <button
        onClick={() => setOpen(!open)}
        aria-expanded={open}
        aria-haspopup="true"
        style={{
          display: "flex",
          alignItems: "center",
          gap: "4px",
          padding: "0.5rem 0.75rem",
          borderRadius: "var(--radius-md)",
          background: "none",
          border: "none",
          cursor: "pointer",
          fontSize: "0.875rem",
          fontWeight: 500,
          color: "var(--color-foreground-muted)",
        }}
      >
        {label}
        <ChevronDown
          size={14}
          style={{
            transition: `transform var(--duration-fast) var(--ease-out)`,
            transform: open ? "rotate(180deg)" : "rotate(0deg)",
          }}
        />
      </button>

      <AnimatePresence>
        {open && (
          <motion.div
            initial={{ opacity: 0, y: 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 4 }}
            transition={{ duration: 0.15 }}
            style={{
              position: "absolute",
              top: "calc(100% + 8px)",
              left: 0,
              minWidth: "200px",
              borderRadius: "var(--radius-lg)",
              background: "var(--color-background-card)",
              border: "1px solid var(--color-border)",
              padding: "0.5rem",
              boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
            }}
          >
            {items.map((item) => (
              <a key={item.href} href={item.href} style={{
                display: "block",
                padding: "0.625rem 0.75rem",
                borderRadius: "var(--radius-md)",
                fontSize: "0.875rem",
                color: "var(--color-foreground)",
                textDecoration: "none",
              }}>
                {item.label}
              </a>
            ))}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

The aria-expanded and aria-haspopup attributes make the dropdown accessible to keyboard and screen reader users. Add role="menu" to the dropdown container and role="menuitem" to each link for full ARIA compliance.

Performance Notes

A few things that commonly hurt navbar performance:

  • Avoid re-renders on every scroll event. Use a threshold check (scrollY > 16) so state only changes at the boundary, not continuously.
  • Use CSS transitions, not JS animations, for the scroll-activated blur. CSS transitions run on the compositor thread.
  • Lazy load the mobile menu content. If the menu contains heavy components (mega-menus, search), render them only when mobileOpen is true.

Pre-Built Navbar Components

If you need to ship fast, the Incubator navbar catalog includes 18+ production-ready variants: glassmorphism navbars, animated underline indicators, morphing pills, mega-menu layouts, sidebar-toggle navbars, and more — all built on the same token system and ready to copy into your Next.js project.

VA

Victor Aubague

Developer & creator of Incubator

Full-stack developer specialized in React, Next.js, and TypeScript. I built Incubator to help developers ship beautiful interfaces faster — all components are crafted from real client projects and production code.

LinkedIn
React Navbar Components: Patterns and Copy-Paste Examples — Incubator