Tab list with a spring-driven sliding indicator and proximity hover across every tab — the item nearest the cursor previews with a subtle wash before it's clicked.
Standalone
Three tabs with the indicator sliding between them.
Icons
A tab per icon+label pair — the indicator resizes to fit each one.
Install
Install the packages this component imports.
bun add motion @base-ui/reactAlso requires lib/utils.ts, lib/springs.ts, lib/font-weight.ts, hooks/use-proximity-hover.ts (see Installation).
Source
Copy and paste the following code into your project.
"use client";
import {
Children,
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactElement,
} from "react";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";
import { fontWeights } from "@/lib/font-weight";
import {
useProximityHover,
proximityHoverWashClassName,
proximityHoverWashOpacity,
type ItemRect,
} from "@/hooks/use-proximity-hover";
// ─── Contexts ────────────────────────────────────────────────────────────────
// Base UI doesn't expose a public hook for "which value is active" to
// arbitrary descendants (only its own Root/List/Tab/Panel/Indicator
// components can see that internally) — TabsList needs it to resolve the
// selected tab's index into `itemRects`, so Tabs tracks it itself instead.
interface TabsValueOrderContextValue {
valueOrder: string[];
setValueOrder: (order: string[]) => void;
selectedValue: string | undefined;
}
const TabsValueOrderContext = createContext<TabsValueOrderContextValue | null>(null);
interface TabsListContextValue {
registerTab: (index: number, element: HTMLElement | null) => void;
hoveredIndex: number | null;
selectedValue: string | undefined;
/** Optimistically set on click so the indicator jumps immediately, without waiting for the controlled value to round-trip back. */
setOptimisticIndex: (index: number) => void;
}
const TabsListContext = createContext<TabsListContextValue | null>(null);
function useTabsListContext() {
const ctx = useContext(TabsListContext);
if (!ctx) throw new Error("TabsTrigger must be used within a TabsList");
return ctx;
}
// ─── Tabs ────────────────────────────────────────────────────────────────────
function Tabs({
value,
onValueChange,
defaultValue,
children,
className,
...props
}: TabsPrimitive.Root.Props) {
const [valueOrder, setValueOrder] = useState<string[]>([]);
const [uncontrolledValue, setUncontrolledValue] = useState<unknown>(defaultValue);
const updateValueOrder = useCallback((order: string[]) => {
setValueOrder((current) =>
current.length === order.length && current.every((v, i) => v === order[i]) ? current : order,
);
}, []);
const resolvedValue = value ?? uncontrolledValue ?? valueOrder[0];
// Base UI passes (value, eventDetails) — only the value matters here.
const handleValueChange = useCallback(
(newValue: unknown, eventDetails: unknown) => {
if (value === undefined) setUncontrolledValue(newValue);
(onValueChange as ((v: unknown, e: unknown) => void) | undefined)?.(newValue, eventDetails);
},
[onValueChange, value],
);
return (
<TabsValueOrderContext.Provider
value={{
valueOrder,
setValueOrder: updateValueOrder,
selectedValue: resolvedValue as string | undefined,
}}
>
{/*
Always controlled: Base UI's useControlled warns in dev when value
flips undefined → defined. valueOrder is empty on the first commit,
so fall back to an empty-string sentinel — TabsList's layout effect
populates valueOrder pre-paint, so the corrected value lands before
anything is visible.
*/}
<TabsPrimitive.Root
data-slot="tabs"
value={resolvedValue ?? ""}
onValueChange={handleValueChange}
className={cn("flex flex-col gap-2", className)}
{...props}
>
{children}
</TabsPrimitive.Root>
</TabsValueOrderContext.Provider>
);
}
// ─── TabsList ────────────────────────────────────────────────────────────────
// Owns the sliding "selected" pill and the proximity hover pill — the same
// measured-rect pattern Accordion's item-highlight uses, applied along the x
// axis since tabs lay out horizontally.
function TabsList({ children, className, ...props }: TabsPrimitive.List.Props) {
const containerRef = useRef<HTMLDivElement>(null);
const isMouseInsideRef = useRef(false);
const valueOrderCtx = useContext(TabsValueOrderContext);
const [optimisticIndex, setOptimisticIndex] = useState<number | null>(null);
const values = Children.toArray(children)
.filter(isValidElement)
.map((child) => (child.props as { value?: string }).value)
.filter((v): v is string => typeof v === "string");
const valueOrderKey = values.join(",");
const setValueOrder = valueOrderCtx?.setValueOrder;
useLayoutEffect(() => {
setValueOrder?.(values);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setValueOrder, valueOrderKey]);
const {
activeIndex: hoveredIndex,
setActiveIndex: setHoveredIndex,
itemRects,
handlers,
registerItem,
measureItems,
} = useProximityHover(containerRef, { axis: "x" });
useEffect(() => {
measureItems();
}, [measureItems, children]);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
isMouseInsideRef.current = true;
handlers.onMouseMove(e);
},
[handlers],
);
const handleMouseLeave = useCallback(() => {
isMouseInsideRef.current = false;
handlers.onMouseLeave();
}, [handlers]);
const selectedValue = valueOrderCtx?.selectedValue;
const selectedIndex = selectedValue !== undefined ? values.indexOf(selectedValue) : -1;
useEffect(() => {
setOptimisticIndex(selectedIndex >= 0 ? selectedIndex : null);
}, [selectedIndex]);
const selectedRect: ItemRect | null =
optimisticIndex !== null ? (itemRects[optimisticIndex] ?? null) : null;
const hoverRect: ItemRect | null =
hoveredIndex !== null ? (itemRects[hoveredIndex] ?? null) : null;
const isHoveringSelected = hoveredIndex === optimisticIndex;
const isHovering = hoveredIndex !== null && !isHoveringSelected;
// Auto-index children so callers never hand-thread an index just for
// proximity hover/rect tracking — mirrors Accordion's indexedChildren.
const indexedChildren = Children.map(children, (child, index) => {
if (!isValidElement(child)) return child;
return cloneElement(child as ReactElement<{ _index?: number }>, { _index: index });
});
return (
<TabsListContext.Provider
value={{
registerTab: registerItem,
hoveredIndex,
selectedValue,
setOptimisticIndex,
}}
>
<TabsPrimitive.List
data-slot="tabs-list"
ref={(node: HTMLDivElement | null) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
onMouseMove={handleMouseMove}
onMouseEnter={handlers.onMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={(e: React.FocusEvent) => {
const trigger = (e.target as HTMLElement).closest("[data-proximity-index]");
const indexAttr = trigger?.getAttribute("data-proximity-index");
if (indexAttr != null) setHoveredIndex(Number(indexAttr));
}}
onBlur={(e: React.FocusEvent) => {
if (containerRef.current?.contains(e.relatedTarget as Node)) return;
if (!isMouseInsideRef.current) setHoveredIndex(null);
}}
className={cn(
"relative inline-flex w-fit items-center gap-0.5 rounded-lg bg-background p-1 text-muted-foreground shadow-well",
className,
)}
{...props}
>
{selectedRect && (
<motion.div
className="pointer-events-none absolute rounded-md bg-card shadow-bevel"
initial={false}
animate={{
left: selectedRect.left,
top: selectedRect.top,
width: selectedRect.width,
height: selectedRect.height,
opacity: isHovering ? 0.85 : 1,
}}
transition={{ ...spring.moderate.enter, opacity: { duration: 0.08 } }}
/>
)}
<AnimatePresence>
{hoverRect && !isHoveringSelected && selectedRect && (
<motion.div
className={cn("pointer-events-none absolute rounded-md", proximityHoverWashClassName)}
initial={{
left: selectedRect.left,
top: selectedRect.top,
width: selectedRect.width,
height: selectedRect.height,
opacity: 0,
}}
animate={{
left: hoverRect.left,
top: hoverRect.top,
width: hoverRect.width,
height: hoverRect.height,
opacity: proximityHoverWashOpacity,
}}
exit={{ opacity: 0, transition: spring.fast.exit }}
transition={spring.fast.enter}
/>
)}
</AnimatePresence>
{indexedChildren}
</TabsPrimitive.List>
</TabsListContext.Provider>
);
}
// ─── TabsTrigger ─────────────────────────────────────────────────────────────
function TabsTrigger({
className,
children,
onClick,
_index = 0,
...props
}: TabsPrimitive.Tab.Props & { _index?: number }) {
const { registerTab, hoveredIndex, selectedValue, setOptimisticIndex } = useTabsListContext();
const ref = useRef<HTMLElement>(null);
useEffect(() => {
registerTab(_index, ref.current);
return () => registerTab(_index, null);
}, [_index, registerTab]);
const isSelected = selectedValue === props.value;
const isActive = hoveredIndex === _index || isSelected;
return (
<TabsPrimitive.Tab
ref={ref}
data-slot="tabs-trigger"
data-proximity-index={_index}
// Composed, not spread-overridable: a consumer onClick must not
// replace the optimistic indicator jump.
onClick={(e) => {
setOptimisticIndex(_index);
onClick?.(e);
}}
className={cn(
"relative z-10 inline-flex h-8 items-center justify-center rounded-md px-3 whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
>
{/* Ghost-span: an invisible copy at the heaviest weight reserves the
width so the visible copy's weight can animate without reflowing
the tab. Each stacked copy is its own flex row (not the outer Tab)
so an icon + label child pair still lays out side by side within
each copy. */}
<span className="col-start-1 row-start-1 grid text-control">
<span
className="invisible col-start-1 row-start-1 inline-flex items-center gap-1.5"
style={{ fontVariationSettings: fontWeights.medium }}
aria-hidden="true"
>
{children}
</span>
<span
className={cn(
"col-start-1 row-start-1 inline-flex items-center gap-1.5 transition-colors duration-80",
isActive ? "text-foreground" : "text-muted-foreground",
)}
style={{ fontVariationSettings: isSelected ? fontWeights.medium : fontWeights.normal }}
>
{children}
</span>
</span>
</TabsPrimitive.Tab>
);
}
// ─── TabsContent ─────────────────────────────────────────────────────────────
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };API Reference
TabsdefaultValue | unknown Initially active tab value, uncontrolled. |
value | unknown Controlled active tab value. |
onValueChange | (value) => void Called when the active tab changes. |
orientation"horizontal" | "horizontal" | "vertical" Layout and keyboard navigation axis. |
API Reference
TabsListchildren | ReactNode One TabsTrigger per tab. The sliding indicator and the proximity hover pill render automatically — no separate indicator component to place. |
API Reference
TabsTriggervalue | unknown Identifies which TabsContent this trigger activates. |
disabledfalse | boolean Whether this tab is disabled. |
children | ReactNode Tab label — an icon plus text lays out side by side automatically. |
API Reference
TabsContentvalue | unknown Matches the TabsTrigger value this panel belongs to. |
children | ReactNode Panel content, shown while its tab is active. |