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.