A pill-shaped navigation that floats over the page, with an indicator that glides between items on a spring and optional hide-on-scroll.
Design that gets out of the way
Scroll down and the bar slips away; scroll up and it returns. Click an item and the highlight glides to it.
"use client";
import * as React from "react";
import { Compass, CreditCard, Home, MessageCircle } from "lucide-react";
import { FloatingNav } from "@/components/ballmac/floating-nav";
const items = [
{ value: "home", label: "Home", href: "#home", icon: <Home /> },
{ value: "explore", label: "Explore", href: "#explore", icon: <Compass /> },
{ value: "pricing", label: "Pricing", href: "#pricing", icon: <CreditCard /> },
{ value: "contact", label: "Contact", href: "#contact", icon: <MessageCircle /> },
];
export default function FloatingNavDemo() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div className="relative h-72 w-full max-w-2xl overflow-hidden rounded-xl border bg-background shadow-sm">
<FloatingNav items={items} autoHide scrollContainer={scroller} className="absolute" />
<div ref={scroller} role="region" tabIndex={0} aria-label="Page content" className="outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 h-full overflow-auto px-6 pt-20 pb-6">
<h2 className="text-2xl font-semibold tracking-tight">Design that gets out of the way</h2>
<p className="mt-2 max-w-prose text-sm text-muted-foreground">Scroll down and the bar slips away; scroll up and it returns. Click an item and the highlight glides to it.</p>
<div className="mt-5 grid gap-3">
{["One", "Two", "Three", "Four", "Five"].map((t) => (
<div key={t} className="rounded-xl border bg-card p-5 text-sm">Section {t}</div>
))}
</div>
</div>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/floating-navInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/scroll @ballmac/motion-presets @ballmac/i18nCopy the source into your project.
components/ballmac/floating-nav.tsx// Ballmac UI: Floating Nav. https://ui.ballmac.com/components/floating-nav "use client"; import * as React from "react"; import { motion, useReducedMotion } from "motion/react"; import { useScrollDirection, useScrolled, type ScrollContainer } from "@/lib/ballmac/scroll"; import { spring } from "@/lib/ballmac/motion"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; type FloatingNavItem = { /** Unique value; used for `value` and `onValueChange`. */ value: string; /** Visible label (hidden on small screens when an icon is given, but still the accessible name). */ label: string; /** Link destination. */ href?: string; /** Icon shown before the label. */ icon?: React.ReactNode; }; type FloatingNavProps = Omit<React.ComponentProps<"nav">, "onChange" | "defaultValue"> & { /** Links in the bar. */ items: FloatingNavItem[]; /** Controlled active item. */ value?: string; /** Initial active item when uncontrolled. */ defaultValue?: string; /** Called when an item is chosen. */ onValueChange?: (value: string) => void; /** Edge of the screen the bar floats from. */ position?: "top" | "bottom"; /** Slide away when scrolling down (top) or up (bottom), and return on the opposite scroll. Stays while it holds focus. */ autoHide?: boolean; /** Accessible name of the navigation landmark. */ label?: string; /** A scrollable element to watch for auto-hide instead of the page. */ scrollContainer?: ScrollContainer; }; /** * A pill-shaped navigation that floats over the page, with an active indicator that glides between items on a spring. * Positioned with `fixed`; add `absolute` in `className` to place it inside a positioned container. */ function FloatingNav({ items, value: valueProp, defaultValue, onValueChange, position = "top", autoHide = false, label, scrollContainer, className, ...props }: FloatingNavProps) { const msg = useMessages() label ??= msg("floating-nav.label", "Primary") const reduce = useReducedMotion(); const indicatorId = `floating-nav-${React.useId()}`; const [inner, setInner] = React.useState(defaultValue ?? items[0]?.value); const value = valueProp ?? inner; const scrolled = useScrolled(24, scrollContainer); const direction = useScrollDirection(12, scrollContainer); const hidden = autoHide && scrolled && (position === "top" ? direction === "down" : direction === "up"); return ( <nav aria-label={label} data-slot="floating-nav" data-hidden={hidden || undefined} className={cn( "fixed left-1/2 z-40 max-w-[calc(100vw-1.5rem)] -translate-x-1/2 transition-[transform,opacity] duration-300 motion-reduce:transition-none", position === "top" ? "top-4" : "bottom-4", hidden && (position === "top" ? "-translate-y-[calc(100%+1.5rem)]" : "translate-y-[calc(100%+1.5rem)]"), hidden && "opacity-0 focus-within:translate-y-0 focus-within:opacity-100", className, )} {...props} > <ul className="flex items-center gap-0.5 rounded-full border bg-background/80 p-1 shadow-[0_8px_32px_-8px_rgb(0_0_0/0.25)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/60"> {items.map((item) => { const active = item.value === value; return ( <li key={item.value}> <a href={item.href} aria-current={active ? "page" : undefined} aria-label={item.icon ? item.label : undefined} onClick={() => { if (valueProp === undefined) setInner(item.value); onValueChange?.(item.value); }} className={cn( "relative inline-flex h-9 items-center gap-2 rounded-full px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4", active ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground", )} > {active && ( <motion.span layoutId={indicatorId} aria-hidden="true" className="absolute inset-0 rounded-full bg-primary shadow-sm" transition={reduce ? { duration: 0 } : spring.snappy} /> )} <span className="relative flex items-center gap-2"> {item.icon} <span className={cn(item.icon && "max-sm:sr-only")}>{item.label}</span> </span> </a> </li> ); })} </ul> </nav> ); } export { FloatingNav, type FloatingNavProps, type FloatingNavItem };Update the import paths to match your project setup.
Usage
import { FloatingNav } from "@/components/ballmac/floating-nav"The full example is in the Code tab above.
Examples
Bottom bar
A bottom bar for mobile-style layouts. Labels collapse to icons on small screens.
"use client";
import { Bell, Home, Search, User } from "lucide-react";
import { FloatingNav } from "@/components/ballmac/floating-nav";
const items = [
{ value: "home", label: "Home", icon: <Home /> },
{ value: "search", label: "Search", icon: <Search /> },
{ value: "alerts", label: "Alerts", icon: <Bell /> },
{ value: "me", label: "Profile", icon: <User /> },
];
export default function FloatingNavStates() {
return (
<div className="relative h-40 w-full max-w-sm overflow-hidden rounded-xl border bg-muted/40">
<p className="p-4 text-sm text-muted-foreground">A bottom bar for mobile-style layouts. Labels collapse to icons on small screens.</p>
<FloatingNav items={items} position="bottom" defaultValue="search" label="App" className="absolute" />
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
items*Links in the bar. | FloatingNavItem[] | — |
valueControlled active item. | string | — |
defaultValueInitial active item when uncontrolled. | string | — |
onValueChangeCalled when an item is chosen. | (value: string) => void | — |
positionEdge of the screen the bar floats from. | "top" | "bottom" | "top" |
autoHideSlide away when scrolling down (top) or up (bottom), and return on the opposite scroll. Stays while it holds focus. | boolean | false |
labelAccessible name of the navigation landmark. | string | — |
scrollContainerA scrollable element to watch for auto-hide instead of the page. | ScrollContainer | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Moves through the links |
| Screen readers | Active item has aria-current; icon-only items keep their label |
| Reduced motion | The indicator jumps instead of gliding; hiding uses no transition |
Use with AI
items: {value,label,href,icon}. position top or bottom; autoHide slides it away on scroll; value/defaultValue/onValueChange pick the active item. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Floating Nav (@ballmac/floating-nav) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Landing pages and portfolios
- App-style bottom navigation on small screens
Not for
- Sites that need nested menus; use navbar with mega-menu
- Long lists of links
Registry JSON: https://ui.ballmac.com/r/floating-nav.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
Navbar
A responsive site header that gains a border and shadow on scroll, can hide while scrolling down, and turns its links into a sheet menu on small screens.
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Animated Tabs
Radix Tabs with a pill or underline indicator that slides between triggers on a spring, and panels that fade in. Keeps full keyboard support and ARIA; controlled or uncontrolled.
Back To Top
A floating button that appears after scrolling, draws a progress ring, scrolls smoothly (instantly under reduced motion) and moves focus to the content.