Animated Underline

Animated underline that tracks hover/active state and slides beneath nav items. Add data-link-index to list items for correct positioning.

Component

Installation

pnpm dlx shadcn@latest add "https://pulkitxm.com/components/animated-underline.json"

Usage

AnimatedUnderline positions itself under the active nav item. You provide a container ref, the active index, and mark each item with data-link-index. The underline animates when the index changes.

1. Setup ref and state

Create a ref for the container and state for the active index. Update activeIndex when the user clicks or hovers.

import { useRef, useState } from "react";
import { AnimatedUnderline } from "@/components/ui/animated-underline";

const menuRef = useRef<HTMLUListElement>(null);
const [activeIndex, setActiveIndex] = useState(0);

2. Mark items with data-link-index

Render your nav items. Each item must have data-link-index matching its index so the underline can find it.

{
  ["Home", "About", "Contact"].map((title, i) => (
    <li key={title} data-link-index={i}>
      <button onClick={() => setActiveIndex(i)}>
        {title}
      </button>
    </li>
  ));
}

3. Add the underline

Place AnimatedUnderline in a relative container below the nav. Pass the container ref and active index.

<div className="relative mt-3 h-0.5 w-full">
  <AnimatedUnderline
    containerRef={menuRef}
    activeIndex={activeIndex}
  />
</div>;

Guidelines

  • Create a ref for the container (ul, nav, or div) that wraps your nav items.
  • Track which item is active (and optionally hovered) with useState. Pass hoveredIndex when hovering, activeIndex otherwise.
  • Add data-link-index={index} to each clickable item so the underline can find and position under it.
  • Place AnimatedUnderline inside a relative container (e.g. div with relative mt-3 h-0.5 w-full) directly below the nav.
  • Use activeIndex -1 when no item is selected to hide the underline.
  • For non-li elements (e.g. buttons), pass itemSelector='[data-link-index]' to override the default li[data-link-index].

Props

PropTypeDefaultDescription
containerRefReact.RefObject<HTMLElement | null>Ref to the container element (ul or nav) that wraps the nav items.
activeIndexnumberIndex of the item to underline. Use -1 to hide.
itemSelectorstring"li[data-link-index]"CSS selector for finding items. Items must have data-link-index.
classNamestringAdditional CSS classes for the underline bar.

Examples

Basic

Basic nav with underline that follows hover and active state.

import { useRef, useState } from "react";
import { AnimatedUnderline } from "@/components/ui/animated-underline";
import { cn } from "@/lib/utils";

const LINKS = [
  { title: "Home", url: "#" },
  { title: "About", url: "#" },
  { title: "Contact", url: "#" },
];

export function AnimatedUnderlineBasic() {
  const menuRef = useRef<HTMLUListElement>(null);
  const [hoveredIndex, setHoveredIndex] = useState(-1);
  const [activeIndex, setActiveIndex] = useState(0);
  const targetIndex =
    hoveredIndex !== -1 ? hoveredIndex : activeIndex;

  return (
    <nav className="w-full border-b border-border">
      <ul ref={menuRef} className="flex items-center gap-2">
        {LINKS.map((link, index) => (
          <li key={link.url} data-link-index={index}>
            <a
              href={link.url}
              onClick={() => setActiveIndex(index)}
              className={cn(
                "flex px-2 py-1 text-sm transition-colors",
                targetIndex === index
                  ? "text-foreground"
                  : "text-muted-foreground hover:text-foreground",
              )}
              onMouseEnter={() => setHoveredIndex(index)}
              onMouseLeave={() => setHoveredIndex(-1)}
            >
              {link.title}
            </a>
          </li>
        ))}
      </ul>
      <div className="relative mt-3 h-0.5 w-full">
        <AnimatedUnderline
          containerRef={menuRef}
          activeIndex={targetIndex}
        />
      </div>
    </nav>
  );
}

Tabs

Tabs with underline. Works with any element—use data-link-index and itemSelector.

import { useRef, useState } from "react";
import { AnimatedUnderline } from "@/components/ui/animated-underline";
import { cn } from "@/lib/utils";

const TABS = ["Overview", "API", "Changelog"];

export function AnimatedUnderlineTabs() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    <div className="w-full border-b border-border">
      <div ref={containerRef} className="flex gap-2">
        {TABS.map((tab, index) => (
          <button
            key={tab}
            type="button"
            data-link-index={index}
            onClick={() => setActiveIndex(index)}
            className={cn(
              "flex px-3 py-2 text-sm font-medium transition-colors",
              activeIndex === index
                ? "text-foreground"
                : "text-muted-foreground hover:text-foreground",
            )}
          >
            {tab}
          </button>
        ))}
      </div>
      <div className="relative mt-0 h-0.5 w-full">
        <AnimatedUnderline
          containerRef={containerRef}
          activeIndex={activeIndex}
          itemSelector="[data-link-index]"
        />
      </div>
    </div>
  );
}

Made with ❤️ by Pulkit

© 2026 Pulkit. All rights reserved

DMCA Verified

Last updated: