Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:35Get Lifetime →
loopkit
freeNew287 lines · framer-motion

Navbar Tabs Underline

Links that behave like tabs, with a line that always knows where you are. A bordered top nav bar whose main links act as tabs: a soft hover pill and a separate signal-coloured underline bar both glide between links as shared elements, so hover and selection read as two distinct states.

Use it for
App shells, docs sites or dashboards with a handful of top-level sections where the visitor should always see which section is open.
Themes
Light + dark
License
Commercial use
Live
Workflows

Everything the team is working on, in one list.

Owners, status and the next step sit next to each other, so nothing needs a separate check-in.

Requests

New items land here before anyone is assigned.

Approvals

Waiting on a decision from someone on the team.

History

Everything that has already shipped or closed.

Settings

Roles, notifications and workspace defaults.

tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

/**
 * Straight, bordered nav bar whose main links behave like tabs: hovering any
 * link slides a soft highlight pill behind it, while the selected link keeps
 * its own signal-coloured underline bar — both are shared `layoutId` elements
 * so they glide between links instead of popping. Collapses to a hamburger
 * sheet (a plain active-state list) under 640px.
 */

export interface NavTab {
  label: string;
  href?: string;
}

export interface NavbarTabsUnderlineProps {
  brand?: string;
  tabs?: NavTab[];
  /** Index of the selected tab. */
  activeIndex?: number;
  ctaLabel?: string;
  showCta?: boolean;
  onTabChange?: (index: number) => void;
  onCtaClick?: () => void;
  /** Headline shown behind the bar. */
  headline?: string;
  /** Supporting line under the headline. */
  subcopy?: string;
  /** Render the page content behind the bar. Turn off to render just the bar. */
  showBackdrop?: boolean;
  className?: string;
}

const DEFAULT_TABS: NavTab[] = [
  { label: "Overview" },
  { label: "Workflows" },
  { label: "Pricing" },
  { label: "Changelog" },
];

function Logomark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 28 28" className={className} fill="none" aria-hidden>
      <path d="M14 2 25 14 14 26 3 14Z" className="fill-signal" />
      <path d="M14 8.5 19.5 14 14 19.5 8.5 14Z" className="fill-background" />
    </svg>
  );
}

function useInView<T extends HTMLElement>() {
  const ref = React.useRef<T>(null);
  const [inView, setInView] = React.useState(true);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return { ref, inView };
}

export function NavbarTabsUnderline({
  brand = "Hexlane",
  tabs = DEFAULT_TABS,
  activeIndex = 1,
  ctaLabel = "Request access",
  showCta = true,
  onTabChange,
  onCtaClick,
  headline = "Everything the team is working on, in one list.",
  subcopy = "Owners, status and the next step sit next to each other, so nothing needs a separate check-in.",
  showBackdrop = true,
  className,
}: NavbarTabsUnderlineProps) {
  const reduce = useReducedMotionSafe();
  const { ref: sectionRef } = useInView<HTMLElement>();
  const [active, setActive] = React.useState(() => Math.min(Math.max(activeIndex, 0), tabs.length - 1));
  const [hovered, setHovered] = React.useState<number | null>(null);
  const [menuOpen, setMenuOpen] = React.useState(false);

  React.useEffect(() => {
    setActive(Math.min(Math.max(activeIndex, 0), tabs.length - 1));
  }, [activeIndex, tabs.length]);

  const selectTab = (i: number) => {
    setActive(i);
    onTabChange?.(i);
  };

  return (
    <section
      ref={sectionRef}
      className={cn("relative isolate overflow-hidden bg-background", className)}
    >
      {/* nav wrapper — relative ancestor for the mobile sheet */}
      <div className="relative z-20 border-b border-border bg-background/95 backdrop-blur-sm">
        <nav
          aria-label="Primary"
          className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-4 px-4 sm:px-6"
        >
          <a
            href="#"
            onClick={(e) => e.preventDefault()}
            className="flex shrink-0 items-center gap-2 font-display text-[15px] font-bold tracking-tight"
          >
            <Logomark className="size-7" />
            {brand}
          </a>

          <ul
            role="tablist"
            aria-label={`${brand} sections`}
            className="relative hidden items-center gap-1 md:flex"
            onMouseLeave={() => setHovered(null)}
          >
            {tabs.map((tab, i) => {
              const isActive = active === i;
              return (
                <li key={tab.label} className="relative">
                  {hovered === i && (
                    <motion.span
                      layoutId="tabs-underline-hover"
                      className="absolute inset-0 rounded-lg bg-muted"
                      transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 480, damping: 34 }}
                    />
                  )}
                  <a
                    role="tab"
                    aria-selected={isActive}
                    href={tab.href ?? "#"}
                    onClick={(e) => {
                      e.preventDefault();
                      selectTab(i);
                    }}
                    onMouseEnter={() => setHovered(i)}
                    onFocus={() => setHovered(i)}
                    onBlur={() => setHovered(null)}
                    className={cn(
                      "relative z-10 block rounded-lg px-3.5 py-2 text-sm font-medium transition-colors",
                      isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground"
                    )}
                  >
                    {tab.label}
                  </a>
                  {isActive && (
                    <motion.span
                      layoutId="tabs-underline-active"
                      className="absolute inset-x-2 -bottom-[1px] h-[2.5px] rounded-full bg-signal"
                      transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 36 }}
                    />
                  )}
                </li>
              );
            })}
          </ul>

          <div className="flex shrink-0 items-center gap-2">
            {showCta && (
              <a
                href="#"
                onClick={(e) => {
                  e.preventDefault();
                  onCtaClick?.();
                }}
                className="hidden h-10 items-center rounded-full bg-primary px-5 text-[14px] font-semibold text-primary-foreground transition hover:opacity-90 sm:inline-flex"
              >
                {ctaLabel}
              </a>
            )}
            <button
              type="button"
              aria-label={menuOpen ? "Close menu" : "Open menu"}
              aria-expanded={menuOpen}
              onClick={() => setMenuOpen((v) => !v)}
              className="inline-flex size-10 items-center justify-center rounded-lg text-foreground transition hover:bg-muted md:hidden"
            >
              <motion.svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
                <motion.path animate={menuOpen ? { d: "M6 6L18 18" } : { d: "M4 7H20" }} transition={{ duration: 0.2 }} />
                <motion.path animate={menuOpen ? { opacity: 0 } : { opacity: 1, d: "M4 12H20" }} transition={{ duration: 0.2 }} />
                <motion.path animate={menuOpen ? { d: "M6 18L18 6" } : { d: "M4 17H20" }} transition={{ duration: 0.2 }} />
              </motion.svg>
            </button>
          </div>
        </nav>

        <AnimatePresence>
          {menuOpen && (
            <motion.div
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
              transition={{ duration: 0.18, ease: "easeOut" }}
              className="absolute inset-x-0 top-16 z-30 border-b border-border bg-card p-2 shadow-xl md:hidden"
            >
              <ul className="flex flex-col" role="list">
                {tabs.map((tab, i) => (
                  <li key={tab.label}>
                    <a
                      href={tab.href ?? "#"}
                      onClick={(e) => {
                        e.preventDefault();
                        selectTab(i);
                        setMenuOpen(false);
                      }}
                      aria-current={active === i ? "page" : undefined}
                      className={cn(
                        "flex items-center justify-between rounded-xl px-3.5 py-2.5 text-sm font-medium transition",
                        active === i ? "bg-muted text-foreground" : "text-foreground/85 hover:bg-muted hover:text-foreground"
                      )}
                    >
                      {tab.label}
                      {active === i && <span className="size-1.5 rounded-full bg-signal" aria-hidden />}
                    </a>
                  </li>
                ))}
              </ul>
              {showCta && (
                <div className="mt-1 border-t border-border p-1.5">
                  <a
                    href="#"
                    onClick={(e) => {
                      e.preventDefault();
                      onCtaClick?.();
                      setMenuOpen(false);
                    }}
                    className="flex h-11 items-center justify-center rounded-xl bg-primary text-[14px] font-semibold text-primary-foreground transition hover:opacity-90"
                  >
                    {ctaLabel}
                  </a>
                </div>
              )}
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      {/* page content beneath the bar */}
      {showBackdrop && (
        <div className="relative z-10 mx-auto max-w-6xl px-4 py-14 sm:px-6 sm:py-16">
          <div className="grid grid-cols-1 gap-6 md:grid-cols-[1.1fr_1fr] md:items-center">
            <div>
              <span className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
                <span className="size-1.5 rounded-full bg-signal" aria-hidden />
                {tabs[active]?.label ?? "Overview"}
              </span>
              <h1 className="mt-5 max-w-lg font-display text-[clamp(1.8rem,4vw,2.75rem)] font-extrabold leading-[1.05] tracking-[-0.03em]">
                {headline}
              </h1>
              <p className="mt-4 max-w-md text-base leading-relaxed text-muted-foreground">
                {subcopy}
              </p>
            </div>
            <div className="grid grid-cols-2 gap-3">
              {[
                { label: "Requests", tone: "bg-signal", body: "New items land here before anyone is assigned." },
                { label: "Approvals", tone: "bg-info", body: "Waiting on a decision from someone on the team." },
                { label: "History", tone: "bg-ok", body: "Everything that has already shipped or closed." },
                { label: "Settings", tone: "bg-lime", body: "Roles, notifications and workspace defaults." },
              ].map((card) => (
                <div key={card.label} className="flex flex-col gap-2.5 rounded-2xl border border-border bg-card p-4">
                  <span className={cn("size-6 rounded-md", card.tone)} aria-hidden />
                  <p className="text-sm font-semibold">{card.label}</p>
                  <p className="text-xs leading-relaxed text-muted-foreground">{card.body}</p>
                </div>
              ))}
            </div>
          </div>
        </div>
      )}
    </section>
  );
}

export default NavbarTabsUnderline;

How it behaves

  • →Each link sits in its own relative list item; the hovered link gets a motion.span with layoutId="tabs-underline-hover" (a rounded bg pill) and the selected link gets a separate motion.span with layoutId="tabs-underline-active" (a 2.5px signal-coloured bar under the label) — framer-motion animates both between positions instead of swapping classes.
  • →Clicking a tab calls onTabChange and updates the active index immediately; the activeIndex prop can also drive it from outside.
  • →Under 768px the tab row and its underline are replaced by a hamburger button (SVG bars morph into an X) that opens a dropdown sheet positioned absolute inside the nav's own relative wrapper, listing the same links as a plain active-state list with a small dot marking the current one.
  • →The page content below the bar mirrors the active tab's label in its eyebrow pill, so the whole block reads like a real page, not just a nav strip. showBackdrop can turn it off to render just the bar.
  • →prefers-reduced-motion sets the shared-element transitions to zero duration so the hover pill and underline snap instead of springing.

Accessibility

  • ✓Desktop tabs use `role="tablist"`/`role="tab"` with `aria-selected` on the active link.
  • ✓Mobile sheet items use `aria-current="page"` on the active link and the hamburger button has a dynamic `aria-label` plus `aria-expanded`.
  • ✓Every link is a real <a> that preventDefaults instead of navigating, so focus order, keyboard activation and screen-reader semantics stay native.
  • ✓Honours prefers-reduced-motion.