Navigation
ExpandNav
Icon tabs. The one you pick opens up to show its name in its own colour, and the others slide over to make room.
Download
- React
- Tailwind CSS v4
- motion
components/ui/expand-nav.tsx
"use client";
import { useId, useState, type MouseEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type ExpandNavItem = {
label: string;
href: string;
/** Icon (an svg works best). */
icon: ReactNode;
/** Tint of this tab when it is open. */
color?: string;
};
type ExpandNavProps = {
items: ExpandNavItem[];
/** href of the active link at start. Defaults to the first item. */
active?: string;
/** Called on click. Call e.preventDefault() to stop the browser following the link. */
onSelect?: (item: ExpandNavItem, e: MouseEvent<HTMLAnchorElement>) => void;
/** Name read out by screen readers. */
label?: string;
/** Bar look. */
className?: string;
};
/* ExpandNav: icon tabs. The picked one opens up to show its name in its own colour, and the others make room. */
export function ExpandNav({
items,
active,
onSelect,
label = "Main",
className = "rounded-full border border-white/10 bg-[#141026] p-1.5 text-sm text-white/60",
}: ExpandNavProps) {
const reduce = useReducedMotion();
const uid = useId();
const [current, setCurrent] = useState(active ?? items[0]?.href);
const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 34 };
return (
<nav aria-label={label}>
<ul className={`inline-flex items-center gap-1 ${className}`}>
{items.map((item) => {
const on = item.href === current;
const c = item.color ?? "#a78bfa";
return (
<motion.li key={item.href} layout={!reduce} transition={spring} className="relative">
<a
href={item.href}
aria-current={on ? "page" : undefined}
aria-label={on ? undefined : item.label}
title={on ? undefined : item.label}
onClick={(e) => {
setCurrent(item.href);
onSelect?.(item, e);
}}
className="relative flex h-10 items-center rounded-full px-3 outline-none transition-colors duration-200 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70"
style={on ? { color: c } : undefined}
>
{on && (
<motion.span
layoutId={`${uid}-pill`}
aria-hidden
className="absolute inset-0 rounded-full"
style={{ background: `color-mix(in srgb, ${c} 16%, transparent)` }}
transition={spring}
/>
)}
<span aria-hidden className="relative grid size-5 shrink-0 place-items-center [&>svg]:size-full">
{item.icon}
</span>
<AnimatePresence initial={false}>
{on && (
<motion.span
key="label"
initial={{ width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={reduce ? { duration: 0 } : { width: spring, opacity: { duration: 0.2, delay: 0.05 } }}
className="relative overflow-hidden font-medium whitespace-nowrap"
>
<span className="block pl-2">{item.label}</span>
</motion.span>
)}
</AnimatePresence>
</a>
</motion.li>
);
})}
</ul>
</nav>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/expand-nav.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/expand-nav-demo.tsx
"use client"; import { Compass, House, MessageCircle, Settings } from "lucide-react"; import { ExpandNav } from "@/components/ui/expand-nav"; const ITEMS = [ { label: "Home", href: "#home", icon: <House />, color: "#a78bfa" }, { label: "Explore", href: "#explore", icon: <Compass />, color: "#22d3ee" }, { label: "Messages", href: "#messages", icon: <MessageCircle />, color: "#f472b6" }, { label: "Settings", href: "#settings", icon: <Settings />, color: "#fbbf24" }, ]; export default function ExpandNavExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <ExpandNav items={ITEMS} onSelect={(_, e) => e.preventDefault()} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href, icon, color? }[] | — | The tabs. icon = an svg; color = tint when open. |
| active | string | first href | href of the open tab at start. |
| onSelect | (item, e) => void | — | Click handler. Call e.preventDefault() to stay on the page. |
| label | string | "Main" | Name read out by screen readers. |
| className | string | dark pill bar | Bar look. |