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
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface AnimatedUnderlineProps {
containerRef: React.RefObject<HTMLElement | null>;
activeIndex: number;
itemSelector?: string;
className?: string;
}
function findTargetElement(
container: HTMLElement,
activeIndex: number,
itemSelector: string,
): HTMLElement | null {
const items = Array.from(
container.querySelectorAll<HTMLElement>(itemSelector),
).filter((el) => {
const style = window.getComputedStyle(el);
return style.display !== "none";
});
const target = items.find(
(el) =>
el.getAttribute("data-link-index") ===
String(activeIndex),
);
return target ?? null;
}
export function AnimatedUnderline({
containerRef,
activeIndex,
itemSelector = "li[data-link-index]",
className,
}: AnimatedUnderlineProps) {
const [style, setStyle] = useState<React.CSSProperties>({
opacity: 0,
});
useEffect(() => {
const container = containerRef.current;
if (!container || activeIndex < 0) {
setStyle({ opacity: 0 });
return;
}
const target = findTargetElement(
container,
activeIndex,
itemSelector,
);
if (!target) {
setStyle({ opacity: 0 });
return;
}
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
setStyle({
opacity: 1,
transform: `translateX(${targetRect.left - containerRect.left}px)`,
width: `${targetRect.width}px`,
});
}, [activeIndex, containerRef, itemSelector]);
return (
<div
className={cn(
"absolute h-full bg-primary transition-[transform,width,opacity] duration-200 ease-out motion-reduce:transition-none",
className,
)}
style={style}
aria-hidden={true}
/>
);
}Installation
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/animated-underline.json"1. Copy the component file
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface AnimatedUnderlineProps {
containerRef: React.RefObject<HTMLElement | null>;
activeIndex: number;
itemSelector?: string;
className?: string;
}
function findTargetElement(
container: HTMLElement,
activeIndex: number,
itemSelector: string,
): HTMLElement | null {
const items = Array.from(
container.querySelectorAll<HTMLElement>(itemSelector),
).filter((el) => {
const style = window.getComputedStyle(el);
return style.display !== "none";
});
const target = items.find(
(el) =>
el.getAttribute("data-link-index") ===
String(activeIndex),
);
return target ?? null;
}
export function AnimatedUnderline({
containerRef,
activeIndex,
itemSelector = "li[data-link-index]",
className,
}: AnimatedUnderlineProps) {
const [style, setStyle] = useState<React.CSSProperties>({
opacity: 0,
});
useEffect(() => {
const container = containerRef.current;
if (!container || activeIndex < 0) {
setStyle({ opacity: 0 });
return;
}
const target = findTargetElement(
container,
activeIndex,
itemSelector,
);
if (!target) {
setStyle({ opacity: 0 });
return;
}
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
setStyle({
opacity: 1,
transform: `translateX(${targetRect.left - containerRect.left}px)`,
width: `${targetRect.width}px`,
});
}, [activeIndex, containerRef, itemSelector]);
return (
<div
className={cn(
"absolute h-full bg-primary transition-[transform,width,opacity] duration-200 ease-out motion-reduce:transition-none",
className,
)}
style={style}
aria-hidden={true}
/>
);
}2. Import and use
import { AnimatedUnderline } from "@/components/ui/animated-underline";
<AnimatedUnderline
containerRef={menuRef}
activeIndex={activeIndex}
/>;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
| Prop | Type | Default | Description |
|---|---|---|---|
| containerRef | React.RefObject<HTMLElement | null> | — | Ref to the container element (ul or nav) that wraps the nav items. |
| activeIndex | number | — | Index of the item to underline. Use -1 to hide. |
| itemSelector | string | "li[data-link-index]" | CSS selector for finding items. Items must have data-link-index. |
| className | string | — | Additional 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>
);
}