Fluid Tabs
Composable tabs with a sliding pill indicator and manual keyboard selection.
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-reactRun 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.tsxPaste 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