Components

Sliding Capsule Nav

A modern navigation component with fluid layout animations, intelligent active state detection, and z-indexed layering.

Fluid Navigation

A ghost tab appears when hovering over a tab, providing a smooth transition to the active state. And primary tab slides into place when you click the tab you want to navigate to with a spring animation.

'use client';import {NavTab,SlidingCapsuleNav,} from '@/components/satisui/sliding-capsule-nav';import { Book, Contact, DollarSign, Home, Newspaper } from 'lucide-react';import { useState } from 'react';const tabs: NavTab[] = [  { title: 'Home', url: '/home', icon: <Home size={14} /> },  { title: 'Pricing', url: '/pricing', icon: <DollarSign size={14} /> },  { title: 'Docs', url: '/docs', icon: <Book size={14} /> },  { title: 'Blog', url: '/blog', icon: <Newspaper size={14} /> },  { title: 'Contact', url: '/contact', icon: <Contact size={14} /> },];export const DemoSlidingCapsuleNav = () => {const [activeTab, setActiveTab] = useState('/home');return (  <div className='flex w-full flex-col items-center justify-center p-12 py-24'>    <div className='flex flex-col items-center gap-4 bg-secondary p-1.5 rounded-full'>      <SlidingCapsuleNav        tabs={tabs}        currentTab={activeTab}        onChange={setActiveTab}        className='p-2'      />    </div>  </div>);};

Installation

command to install
npx shadcn@latest add https://satisui.xyz/r/sliding-capsule-nav.json

Manual

  1. Install the required dependencies:
install motion
npm install motion
  1. Copy the source code into components/satisui/sliding-capsule-nav.tsx:
components/ui/sliding-capsule-nav.tsx
'use client';

import * as React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';

export interface NavTab {
  title: string;
  url: string;
  icon?: React.ReactNode;
}

interface SlidingCapsuleNavProps {
  tabs: NavTab[];
  className?: string;
  activeTabClassName?: string;
  tabClassName?: string;
  layoutId?: string;
  currentTab?: string;
  onChange?: (url: string) => void;
}

export const SlidingCapsuleNav = ({
  tabs,
  className,
  activeTabClassName,
  tabClassName,
  layoutId = 'capsule-nav',
  currentTab,
  onChange,
}: SlidingCapsuleNavProps) => {
  const pathname = usePathname();
  const [hoveredTab, setHoveredTab] = React.useState<string | null>(null);

  const activeTabId = React.useMemo(() => {
    if (currentTab) return currentTab;
    const sortedTabs = [...tabs].sort((a, b) => b.url.length - a.url.length);
    return sortedTabs.find((tab) => pathname?.startsWith(tab.url))?.url || null;
  }, [pathname, tabs, currentTab]);

  const handleLinkClick = (e: React.MouseEvent, url: string) => {
    if (onChange) {
      e.preventDefault();
      onChange(url);
    }
  };

  return (
    <nav
      className={cn(
        'relative flex items-center gap-1 rounded-full border bg-background p-1 shadow-sm',
        className,
      )}
      onMouseLeave={() => setHoveredTab(null)}
    >
      {tabs.map((tab) => {
        const isActive = activeTabId === tab.url;
        const isHovered = hoveredTab === tab.url;

        return (
          <Link
            key={tab.url}
            href={tab.url}
            onClick={(e) => handleLinkClick(e, tab.url)}
            onMouseEnter={() => setHoveredTab(tab.url)}
            className={cn(
              'relative z-10 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium transition-colors duration-200',
              'rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
              isActive
                ? 'text-primary-foreground'
                : 'text-muted-foreground hover:text-foreground',
              tabClassName,
            )}
            aria-current={isActive ? 'page' : undefined}
          >
            {isActive && (
              <motion.div
                layoutId={`${layoutId}-active`}
                transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
                className={cn(
                  'absolute inset-0 z-10 rounded-full bg-primary shadow-md',
                  activeTabClassName,
                )}
              />
            )}

            {isHovered && (
              <motion.div
                layoutId={`${layoutId}-hover`}
                transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
                className='absolute inset-0 z-0 rounded-full bg-muted'
              />
            )}

            <span className='relative z-20 flex items-center gap-2'>
              {tab.icon}
              <span>{tab.title}</span>
            </span>
          </Link>
        );
      })}
    </nav>
  );
};

Usage

The component is designed to work automatically with Next.js routing (usePathname). Simply define your tabs and drop the component into your layout.

usage
import {
  SlidingCapsuleNav,
  type NavTab,
} from '@/components/ui/sliding-capsule-nav';
import { Home, Settings, User } from 'lucide-react';

const tabs: NavTab[] = [
  { title: 'Home', url: '/', icon: <Home size={16} /> },
  { title: 'Profile', url: '/profile', icon: <User size={16} /> },
  { title: 'Settings', url: '/settings', icon: <Settings size={16} /> },
];

export function Navbar() {
  return (
    <div className='flex justify-center py-4'>
      <SlidingCapsuleNav tabs={tabs} />
    </div>
  );
}

Props

PropTypeDefaultDescription
tabsNavTab[]RequiredArray of navigation items { title, url, icon }.
classNamestringundefinedClassName for the outer nav container.
tabClassNamestringundefinedClassName for individual tab items.
activeTabClassNamestringundefinedClassName for the active capsule indicator (background).
layoutIdstring'capsule-nav'Unique ID for Framer Motion layout animations.
currentTabstringundefinedControlled Mode: Override the active tab URL manually.
onChange(url: string) => voidundefinedControlled Mode: Callback when a tab is clicked. Prevents navigation.