Navigation
FloatNav
A floating pill bar. The active pill slides between links, and the bar slips away while you scroll down and comes back when you scroll up.
Download
- React
- Tailwind CSS v4
- motion
components/ui/float-nav.tsx
"use client";
import { useId, useState, type MouseEvent, type RefObject } from "react";
import { motion, useMotionValueEvent, useReducedMotion, useScroll } from "motion/react";
export type FloatNavItem = { label: string; href: string };
type FloatNavProps = {
items: FloatNavItem[];
/** 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: FloatNavItem, e: MouseEvent<HTMLAnchorElement>) => void;
/** Slip away while scrolling down, come back on scroll up. */
hideOnScroll?: boolean;
/** Scrolling box to watch. Leave out to watch the page. */
container?: RefObject<HTMLElement | null>;
/** Colour of the sliding pill. */
color?: string;
/** Text colour on the pill. */
activeText?: string;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
/* FloatNav: a floating pill bar. The active pill slides between links; the bar slips away on scroll down. */
export function FloatNav({
items,
active,
onSelect,
hideOnScroll = true,
container,
color = "#a78bfa",
activeText = "#0b0a14",
label = "Main",
className = "rounded-full border border-white/10 bg-[#16132a]/80 p-1.5 text-sm text-white/70 shadow-lg shadow-black/40 backdrop-blur-md",
}: FloatNavProps) {
const reduce = useReducedMotion();
const id = useId();
const [current, setCurrent] = useState(active ?? items[0]?.href);
const [hidden, setHidden] = useState(false);
const { scrollY } = useScroll({ container });
useMotionValueEvent(scrollY, "change", (y) => {
if (!hideOnScroll) return;
const prev = scrollY.getPrevious() ?? 0;
setHidden(y > prev && y > 80);
});
const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 34 };
return (
<motion.nav
aria-label={label}
onFocus={() => setHidden(false)}
animate={{ y: hidden ? "-160%" : "0%", opacity: hidden ? 0 : 1 }}
transition={spring}
className={`inline-flex ${className}`}
>
<ul className="flex items-center gap-1">
{items.map((item) => {
const on = item.href === current;
return (
<li key={item.href} className="relative">
{on && (
<motion.span
layoutId={`${id}-pill`}
aria-hidden
className="absolute inset-0 rounded-full"
style={{ background: color }}
transition={spring}
/>
)}
<a
href={item.href}
aria-current={on ? "page" : undefined}
onClick={(e) => {
setCurrent(item.href);
onSelect?.(item, e);
}}
className="relative block whitespace-nowrap rounded-full px-4 py-2 outline-none transition-colors hover:text-white focus-visible:ring-2 focus-visible:ring-white/70"
style={on ? { color: activeText, fontWeight: 500 } : undefined}
>
{item.label}
</a>
</li>
);
})}
</ul>
</motion.nav>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/float-nav.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/float-nav-demo.tsx
"use client"; import { useRef } from "react"; import { FloatNav } from "@/components/ui/float-nav"; const ITEMS = [ { label: "Home", href: "#home" }, { label: "Work", href: "#work" }, { label: "About", href: "#about" }, { label: "Contact", href: "#contact" }, ]; export default function FloatNavExample() { // The preview scrolls inside its own box. On a real page, leave out `container`. const box = useRef<HTMLDivElement>(null); return ( <div ref={box} className="relative h-full overflow-y-auto bg-[#0b0a14] px-4"> <div className="sticky top-4 z-10 flex justify-center"> <FloatNav items={ITEMS} container={box} onSelect={(_, e) => e.preventDefault()} /> </div> <p className="mt-10 text-center text-xs text-white/40">Scroll down: the bar slips away. Scroll up: it comes back.</p> <div className="mx-auto mt-6 grid max-w-md gap-3 pb-6"> {Array.from({ length: 8 }, (_, i) => ( <div key={i} className="h-16 rounded-xl border border-white/5 bg-white/[0.03]" /> ))} </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href }[] | โ | The links. |
| active | string | first href | href of the active link at start. |
| onSelect | (item, e) => void | โ | Click handler. Call e.preventDefault() to stay on the page. |
| hideOnScroll | boolean | true | Slip away on scroll down, back on scroll up. |
| container | RefObject<HTMLElement> | โ | Scrolling box to watch. Leave out to watch the page. |
| color | string | "#a78bfa" | Colour of the sliding pill. |
| activeText | string | "#0b0a14" | Text colour on the pill. |
| label | string | "Main" | Name read out by screen readers. |
| className | string | dark glass pill | Replaces the bar look. Place it yourself (e.g. fixed top-4). |