VibeUI
ComponentsDocs
VibeUI

Components with taste, that you own. Copy the source, retint the tokens, keep every line.

Library

  • All components
  • Signature pieces
  • Text effects
  • Footers

Docs

  • Introduction
  • Installation
  • Theming
  • CLI

Project

  • GitHub
  • Agents and MCP
  • Issues
  • MIT license
© 2026 VibeUI · MIT licensed
  1. Components
  2. Navigation
  3. Floating Nav

Floating Nav

A pill-shaped navigation bar with a highlight that glides between items and settles on the current one.

NavigationClient component

Hover to preview · click to move

Install

bash
npx @agents-npm-packages/vibeui add floating-nav --dir ./src/components/ui

Import

tsx
import { FloatingNav } from "@/components/ui/floating-nav"

API

Generated from the component's TypeScript types, so it always matches the source. * marks a required prop.

<FloatingNav>

also accepts every HTMLAttributes<HTMLElement> attribute

PropTypeDefaultDescription
items*FloatingNavItem[]—The items to render.
valuestring—Controlled active item.
defaultValuestring—Initial value when uncontrolled.
onChange((value: string) => void)—Called when the value changes.
hideOnScrollbooleanfalseSlide out of view while scrolling down.

Source

src/components/ui/floating-nav.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export type FloatingNavItem = {
  value: string;
  label: string;
  icon?: React.ReactNode;
  href?: string;
};

/**
 * A pill-shaped navigation bar with a highlight that glides between items and settles on the current one.
 *
 * The hover highlight slides ahead of the pointer and returns to the active item when you leave.
 * Turn on `hideOnScroll` and it tucks away as you scroll down and comes back as you scroll up.
 * Position it yourself, for example `fixed left-1/2 top-4 -translate-x-1/2`.
 */
export function FloatingNav({
  items,
  value,
  defaultValue,
  onChange,
  hideOnScroll = false,
  className,
  ...props
}: Omit<React.HTMLAttributes<HTMLElement>, "onChange" | "defaultValue"> & {
  items: FloatingNavItem[];
  /** Controlled active item. */
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  /** Slide out of view while scrolling down. */
  hideOnScroll?: boolean;
}) {
  const [own, setOwn] = React.useState(defaultValue ?? items[0]?.value);
  const active = value ?? own;
  const [hover, setHover] = React.useState<string | null>(null);
  const [hidden, setHidden] = React.useState(false);
  const [rects, setRects] = React.useState<Record<string, { x: number; w: number }>>({});
  const rail = React.useRef<HTMLDivElement>(null);
  const cells = React.useRef<Record<string, HTMLElement | null>>({});

  React.useEffect(() => {
    const el = rail.current;
    if (!el) return;
    const measure = () => {
      const next: Record<string, { x: number; w: number }> = {};
      for (const [k, c] of Object.entries(cells.current))
        if (c) next[k] = { x: c.offsetLeft, w: c.offsetWidth };
      setRects(next);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [items]);

  React.useEffect(() => {
    if (!hideOnScroll) return;
    let last = window.scrollY;
    const onScroll = () => {
      const y = window.scrollY;
      if (Math.abs(y - last) < 6) return;
      setHidden(y > last && y > 80);
      last = y;
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, [hideOnScroll]);

  const pick = (v: string) => {
    if (value === undefined) setOwn(v);
    onChange?.(v);
  };
  const lit = hover ?? active;
  const r = rects[lit];
  const a = rects[active];
  return (
    <nav
      aria-label="Primary"
      {...props}
      className={cn(
        "inline-flex rounded-full border border-line bg-surface/80 p-1.5 shadow-lift backdrop-blur-xl transition-transform duration-500 ease-[cubic-bezier(0.3,1.2,0.4,1)]",
        hidden && "-translate-y-[160%]",
        className,
      )}
      onPointerLeave={() => setHover(null)}
    >
      <div ref={rail} className="relative flex items-center">
        {/* the solid pill marks where you are; the soft one follows the pointer */}
        {a && (
          <span
            aria-hidden
            className="absolute inset-y-0 rounded-full bg-ink shadow-quiet transition-[left,width] duration-500 ease-[cubic-bezier(0.3,1.25,0.4,1)]"
            style={{ left: a.x, width: a.w }}
          />
        )}
        {r && hover && hover !== active && (
          <span
            aria-hidden
            className="absolute inset-y-0 rounded-full bg-surface-muted transition-[left,width] duration-300 ease-[cubic-bezier(0.3,1.2,0.4,1)]"
            style={{ left: r.x, width: r.w }}
          />
        )}
        {items.map((it) => {
          const on = it.value === active;
          const Tag = it.href ? "a" : "button";
          return (
            <Tag
              key={it.value}
              ref={(el: HTMLElement | null) => {
                cells.current[it.value] = el;
              }}
              {...(it.href ? { href: it.href } : { type: "button" as const })}
              aria-current={on ? "page" : undefined}
              onClick={() => pick(it.value)}
              onPointerEnter={() => setHover(it.value)}
              onFocus={() => setHover(it.value)}
              onBlur={() => setHover(null)}
              className={cn(
                "relative z-10 inline-flex h-9 items-center gap-1.5 rounded-full px-4 text-sm font-medium outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-ring",
                on ? "text-surface" : "text-ink-soft hover:text-ink",
              )}
            >
              {it.icon != null && <span className="grid [&_svg]:size-4">{it.icon}</span>}
              {it.label}
            </Tag>
          );
        })}
      </div>
    </nav>
  );
}

Exports FloatingNav. Peer dependencies: clsx, tailwind-merge.

PreviousBreadcrumbNextPagination