Web InnoventixFreeCode

Nav Underline Hover Effect

Original · free

A nav bar with animated underline indicators on hover.

byWeb InnoventixReact + Tailwind
hovernavunderline
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/hover-nav-underline.json
hover-nav-underline.tsx
"use client";

import { useRef, useState, type KeyboardEvent } from "react";

type NavItem = {
  label: string;
  href: string;
};

const NAV_ITEMS: NavItem[] = [
  { label: "Overview", href: "#overview" },
  { label: "Features", href: "#features" },
  { label: "Pricing", href: "#pricing" },
  { label: "Changelog", href: "#changelog" },
  { label: "Docs", href: "#docs" },
];

export default function HoverNavUnderline() {
  const [activeHref, setActiveHref] = useState<string>(NAV_ITEMS[0].href);
  const [hoverHref, setHoverHref] = useState<string | null>(null);
  const linkRefs = useRef<Record<string, HTMLAnchorElement | null>>({});

  const highlighted = hoverHref ?? activeHref;

  const onKeyNav = (e: KeyboardEvent, index: number) => {
    if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
    e.preventDefault();
    const dir = e.key === "ArrowRight" ? 1 : -1;
    const next = (index + dir + NAV_ITEMS.length) % NAV_ITEMS.length;
    const nextHref = NAV_ITEMS[next].href;
    linkRefs.current[nextHref]?.focus();
    setHoverHref(nextHref);
  };

  return (
    <section className="relative w-full bg-white px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28">
      <style>{`
        @keyframes hnu_fade_up {
          from { opacity: 0; transform: translateY(6px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .hnu-underline { transition: none !important; }
          .hnu-fade { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto flex max-w-4xl flex-col items-center gap-12">
        <div className="hnu-fade text-center [animation:hnu_fade_up_0.5s_ease-out]">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Component
          </p>
          <h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">
            Hover Nav Underline
          </h2>
          <p className="mx-auto mt-3 max-w-md text-sm text-slate-600 dark:text-slate-400">
            A single sliding indicator tracks your cursor, then settles back on
            the active link when you leave.
          </p>
        </div>

        <nav
          aria-label="Primary"
          className="relative w-full max-w-2xl"
          onMouseLeave={() => setHoverHref(null)}
        >
          <ul className="relative flex flex-wrap items-center justify-center gap-x-2 gap-y-3 rounded-2xl border border-slate-200 bg-slate-50/60 px-3 py-2.5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 sm:justify-between">
            {NAV_ITEMS.map((item, i) => {
              const isActive = activeHref === item.href;
              const isHot = highlighted === item.href;
              return (
                <li key={item.href} className="relative">
                  <a
                    ref={(el) => {
                      linkRefs.current[item.href] = el;
                    }}
                    href={item.href}
                    aria-current={isActive ? "page" : undefined}
                    onMouseEnter={() => setHoverHref(item.href)}
                    onFocus={() => setHoverHref(item.href)}
                    onKeyDown={(e) => onKeyNav(e, i)}
                    onClick={(e) => {
                      e.preventDefault();
                      setActiveHref(item.href);
                    }}
                    className={`relative block rounded-lg px-4 py-2 text-sm font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-900 ${
                      isHot
                        ? "text-slate-900 dark:text-white"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    {item.label}
                    <span
                      aria-hidden="true"
                      className={`hnu-underline pointer-events-none absolute inset-x-3 -bottom-0.5 h-0.5 origin-center rounded-full bg-indigo-500 transition-[transform,opacity] duration-300 ease-out dark:bg-indigo-400 ${
                        isHot
                          ? "scale-x-100 opacity-100"
                          : "scale-x-0 opacity-0"
                      }`}
                    />
                  </a>
                </li>
              );
            })}
          </ul>
        </nav>

        <div
          role="status"
          aria-live="polite"
          className="text-xs text-slate-500 dark:text-slate-500"
        >
          Active section:{" "}
          <span className="font-semibold text-indigo-600 dark:text-indigo-400">
            {NAV_ITEMS.find((n) => n.href === activeHref)?.label}
          </span>
        </div>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →