Skip to content
Docs
Fluid Tabs

Fluid Tabs

Composable tabs with a sliding pill indicator and manual keyboard selection.

requires interactionclick
Loading...

Installation

CLI

pnpm dlx shadcn@latest add https://animata.design/r/tabs/fluid-tabs.json

The registry item installs fluid-tabs.tsx and animata/tabs/shared.ts (keyboard/focus helpers used by Fluid, Shift, and Gooey tabs).

Manual

Install dependencies

npm install motion lucide-react

Run the following command

It will create a new file fluid-tabs.tsx inside the components/animata/tabs directory.

mkdir -p components/animata/tabs && touch components/animata/tabs/shared.ts components/animata/tabs/fluid-tabs.tsx

Paste the shared tab helpers

import { type FocusEvent, type KeyboardEvent, useEffect, useState } from "react";
 
import { cn } from "@/lib/utils";
 
/** Visible focus ring — use with a matching border-radius class. */
export function tabFocusClass(radiusClass: string) {
  return cn(
    radiusClass,
    "outline-none focus-visible:z-10",
    "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
  );
}
 
export function useTabSelection({
  defaultActiveIndex = 0,
  activeIndex: activeIndexProp,
  onActiveIndexChange,
}: {
  defaultActiveIndex?: number;
  activeIndex?: number;
  onActiveIndexChange?: (index: number) => void;
}) {
  const [uncontrolledIndex, setUncontrolledIndex] = useState(defaultActiveIndex);
  const [focusedIndex, setFocusedIndex] = useState(
    defaultActiveIndex >= 0 ? defaultActiveIndex : 0,
  );
  const activeIndex = activeIndexProp ?? uncontrolledIndex;
 
  const setActiveIndex = (index: number) => {
    onActiveIndexChange?.(index);
    if (activeIndexProp === undefined) {
      setUncontrolledIndex(index);
    }
    setFocusedIndex(index);
  };
 
  useEffect(() => {
    if (activeIndex >= 0) {
      setFocusedIndex(activeIndex);
    }
  }, [activeIndex]);
 
  return { activeIndex, setActiveIndex, focusedIndex, setFocusedIndex };
}
 
export function focusTabInList(tablist: HTMLElement, index: number) {
  queueMicrotask(() => {
    tablist.querySelectorAll<HTMLElement>('[role="tab"]')[index]?.focus();
  });
}
 
export function handleTabListFocusCapture(
  event: FocusEvent<HTMLElement>,
  activeIndex: number,
  setFocusedIndex: (index: number) => void,
) {
  const related = event.relatedTarget as Node | null;
  if (related && event.currentTarget.contains(related)) {
    return;
  }
  const index = activeIndex >= 0 ? activeIndex : 0;
  setFocusedIndex(index);
  focusTabInList(event.currentTarget, index);
}
 
/** Manual activation: arrows move focus; Enter / Space select the focused tab. */
export function handleTabListKeyDown(
  event: KeyboardEvent<HTMLElement>,
  count: number,
  setActiveIndex: (index: number) => void,
  setFocusedIndex: (index: number) => void,
) {
  if (count < 1) {
    return;
  }
 
  const tablist = event.currentTarget;
  const tabs = tablist.querySelectorAll<HTMLElement>('[role="tab"]');
  const target = event.target as HTMLElement;
  const currentTab = target.closest<HTMLElement>('[role="tab"]');
 
  if (!currentTab || !tablist.contains(currentTab)) {
    return;
  }
 
  const currentIndex = Array.from(tabs).indexOf(currentTab);
  if (currentIndex === -1) {
    return;
  }
 
  if (event.key === "Enter" || event.key === " ") {
    event.preventDefault();
    setActiveIndex(currentIndex);
    setFocusedIndex(currentIndex);
    return;
  }
 
  if (count < 2) {
    return;
  }
 
  let next: number | null = null;
  switch (event.key) {
    case "ArrowRight":
    case "ArrowDown":
      next = (currentIndex + 1) % count;
      break;
    case "ArrowLeft":
    case "ArrowUp":
      next = (currentIndex - 1 + count) % count;
      break;
    case "Home":
      next = 0;
      break;
    case "End":
      next = count - 1;
      break;
    default:
      return;
  }
 
  event.preventDefault();
  setFocusedIndex(next);
  focusTabInList(tablist, next);
}

Paste the component

"use client";
 
import { motion } from "motion/react";
import {
  Children,
  type ComponentProps,
  createContext,
  type FocusEvent,
  isValidElement,
  type KeyboardEvent,
  type ReactNode,
  use,
  useId,
} from "react";
import { cn } from "@/lib/utils";
import {
  handleTabListFocusCapture,
  handleTabListKeyDown,
  tabFocusClass,
  useTabSelection,
} from "./shared";
 
const INDICATOR_SPRING = {
  type: "spring" as const,
  stiffness: 380,
  damping: 34,
  mass: 0.75,
};
 
const LABEL_TRANSITION = {
  duration: 0.28,
  ease: [0.32, 0.72, 0, 1] as const,
};
 
type FluidTabsContextValue = {
  activeIndex: number;
  setActiveIndex: (index: number) => void;
  focusedIndex: number;
  setFocusedIndex: (index: number) => void;
  indicatorLayoutId: string;
};
 
const FluidTabsContext = createContext<FluidTabsContextValue | null>(null);
 
type FluidTabSlotContextValue = {
  index: number;
};
 
const FluidTabSlotContext = createContext<FluidTabSlotContextValue | null>(null);
 
function useFluidTabs() {
  const context = use(FluidTabsContext);
  if (!context) {
    throw new Error("FluidTabs primitives must be used within <FluidTabs>.");
  }
  return context;
}
 
function useFluidTabSlot() {
  const context = use(FluidTabSlotContext);
  if (!context) {
    throw new Error("FluidTabs.Tab must be a direct child of <FluidTabs.List>.");
  }
  return context;
}
 
type FluidTabsRootProps = {
  children: ReactNode;
  defaultActiveIndex?: number;
  activeIndex?: number;
  onActiveIndexChange?: (index: number) => void;
  className?: string;
};
 
function FluidTabsRoot({
  children,
  defaultActiveIndex = 0,
  activeIndex: activeIndexProp,
  onActiveIndexChange,
  className,
}: FluidTabsRootProps) {
  const { activeIndex, setActiveIndex, focusedIndex, setFocusedIndex } = useTabSelection({
    defaultActiveIndex,
    activeIndex: activeIndexProp,
    onActiveIndexChange,
  });
  const indicatorLayoutId = `fluid-tab-indicator-${useId().replace(/:/g, "")}`;
 
  return (
    <FluidTabsContext.Provider
      value={{
        activeIndex,
        setActiveIndex,
        focusedIndex,
        setFocusedIndex,
        indicatorLayoutId,
      }}
    >
      <div className={cn("flex w-full max-w-md items-center justify-center", className)}>
        {children}
      </div>
    </FluidTabsContext.Provider>
  );
}
 
type FluidTabsListProps = ComponentProps<"nav"> & {
  "aria-label"?: string;
};
 
function FluidTabsList({
  className,
  children,
  "aria-label": ariaLabel = "Tabs",
  onKeyDown,
  onFocusCapture,
  ...props
}: FluidTabsListProps) {
  const { activeIndex, setActiveIndex, setFocusedIndex } = useFluidTabs();
  const tabs = Children.toArray(children).filter(isValidElement);
  const count = tabs.length;
 
  return (
    <nav
      aria-label={ariaLabel}
      className={cn("relative overflow-visible rounded-full bg-muted p-1 shadow-sm", className)}
      {...props}
    >
      <div
        role="tablist"
        onFocusCapture={(event: FocusEvent<HTMLElement>) => {
          onFocusCapture?.(event);
          handleTabListFocusCapture(event, activeIndex, setFocusedIndex);
        }}
        onKeyDown={(event: KeyboardEvent<HTMLElement>) => {
          onKeyDown?.(event);
          if (!event.defaultPrevented) {
            handleTabListKeyDown(event, count, setActiveIndex, setFocusedIndex);
          }
        }}
        className="flex w-full gap-1"
      >
        {tabs.map((tab, index) => (
          <FluidTabSlotContext.Provider key={tab.key ?? index} value={{ index }}>
            {tab}
          </FluidTabSlotContext.Provider>
        ))}
      </div>
    </nav>
  );
}
 
function FluidTabsIcon({ className, ...props }: ComponentProps<"span">) {
  return (
    <span
      aria-hidden
      className={cn("inline-flex shrink-0 empty:hidden [&_svg]:size-[18px]", className)}
      {...props}
    />
  );
}
 
function FluidTabsLabel({ className, ...props }: ComponentProps<"span">) {
  return <span className={cn("whitespace-nowrap", className)} {...props} />;
}
 
type FluidTabsTabProps = ComponentProps<"button"> & {
  label?: string;
};
 
function FluidTabsTab({
  className,
  children,
  label,
  onClick,
  onFocus,
  ...props
}: FluidTabsTabProps) {
  const { activeIndex, setActiveIndex, setFocusedIndex, indicatorLayoutId } = useFluidTabs();
  const { index } = useFluidTabSlot();
  const isSelected = activeIndex === index;
 
  return (
    <button
      type="button"
      role="tab"
      aria-selected={isSelected}
      {...(label ? { "aria-label": label } : {})}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) {
          setActiveIndex(index);
        }
      }}
      onFocus={(event: FocusEvent<HTMLButtonElement>) => {
        onFocus?.(event);
        if (!event.defaultPrevented) {
          setFocusedIndex(index);
        }
      }}
      className={cn(
        tabFocusClass("rounded-full"),
        "relative z-10 flex flex-1 items-center justify-center px-4 py-2.5 text-sm font-semibold",
        "motion-reduce:transition-none",
        className,
      )}
      {...props}
    >
      {isSelected ? (
        <motion.span
          layoutId={indicatorLayoutId}
          className="absolute inset-0 block rounded-full bg-background shadow-sm"
          transition={INDICATOR_SPRING}
          aria-hidden
        />
      ) : null}
      <motion.span
        className="relative z-10 inline-flex items-center justify-center gap-2"
        animate={{ scale: isSelected ? 1 : 0.98 }}
        transition={LABEL_TRANSITION}
      >
        {children}
      </motion.span>
    </button>
  );
}
 
const FluidTabs = Object.assign(FluidTabsRoot, {
  List: FluidTabsList,
  Tab: FluidTabsTab,
  Icon: FluidTabsIcon,
  Label: FluidTabsLabel,
});
 
export default FluidTabs;
export { FluidTabsIcon, FluidTabsLabel, FluidTabsList, FluidTabsRoot, FluidTabsTab, useFluidTabs };

Usage

Compose tabs with primitives. FluidTabs.Icon is optional — use FluidTabs.Label alone, icon alone, or both. The tab content row uses gap-2 between children (only visible when both icon and label are present).

import FluidTabs from "@/animata/tabs/fluid-tabs";
import { Landmark } from "lucide-react";
 
<FluidTabs defaultActiveIndex={0}>
  <FluidTabs.List aria-label="Accounts">
    <FluidTabs.Tab>
      <FluidTabs.Label>Overview</FluidTabs.Label>
    </FluidTabs.Tab>
    <FluidTabs.Tab label="Settings">
      <FluidTabs.Icon>
        <Landmark />
      </FluidTabs.Icon>
    </FluidTabs.Tab>
  </FluidTabs.List>
</FluidTabs>

Structure: <nav> wraps a role="tablist" container; each FluidTabs.Tab is a <button role="tab">. Icons are aria-hidden when a label is visible. For icon-only tabs, set the label prop on FluidTabs.Tab.

Keyboard (manual activation): Tab visits every tab; arrow keys move focus; Enter or Space selects the focused tab (updates aria-selected and the sliding pill). Click selects immediately.

Use activeIndex and onActiveIndexChange on the root for controlled mode. The pill uses a per-instance layoutId so multiple tab bars on one page do not clash. Focus uses a visible ring-2 with ring-offset-2 on the pill-shaped tab.

Credits

Built by Rudra Sankha Sinhamahapatra
Twitter Handle Rudra Sankha