Shift Tabs
Composable shift-style tabs with hover tilt and manual keyboard selection.
Installation
CLI
pnpm dlx shadcn@latest add https://animata.design/r/tabs/shift-tabs.json
The registry item installs shift-tabs.tsx and animata/tabs/shared.ts.
Manual
Run the following command
It will create a new file called shift-tabs.tsx inside the components/animata/tabs directory.
mkdir -p components/animata/tabs && touch components/animata/tabs/shared.ts components/animata/tabs/shift-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 {
Children,
type ComponentProps,
createContext,
type FocusEvent,
isValidElement,
type KeyboardEvent,
type ReactNode,
use,
} from "react";
import { cn } from "@/lib/utils";
import {
handleTabListFocusCapture,
handleTabListKeyDown,
tabFocusClass,
useTabSelection,
} from "./shared";
type ShiftTabsContextValue = {
activeIndex: number;
setActiveIndex: (index: number) => void;
focusedIndex: number;
setFocusedIndex: (index: number) => void;
};
const ShiftTabsContext = createContext<ShiftTabsContextValue | null>(null);
type ShiftTabSlotContextValue = {
index: number;
};
const ShiftTabSlotContext = createContext<ShiftTabSlotContextValue | null>(null);
function useShiftTabs() {
const context = use(ShiftTabsContext);
if (!context) {
throw new Error("ShiftTabs primitives must be used within <ShiftTabs>.");
}
return context;
}
function useShiftTabSlot() {
const context = use(ShiftTabSlotContext);
if (!context) {
throw new Error("ShiftTabs.Tab must be a direct child of <ShiftTabs.List>.");
}
return context;
}
type ShiftTabsRootProps = {
children: ReactNode;
defaultActiveIndex?: number;
activeIndex?: number;
onActiveIndexChange?: (index: number) => void;
className?: string;
};
function ShiftTabsRoot({
children,
defaultActiveIndex = 0,
activeIndex: activeIndexProp,
onActiveIndexChange,
className,
}: ShiftTabsRootProps) {
const { activeIndex, setActiveIndex, focusedIndex, setFocusedIndex } = useTabSelection({
defaultActiveIndex,
activeIndex: activeIndexProp,
onActiveIndexChange,
});
return (
<ShiftTabsContext.Provider
value={{ activeIndex, setActiveIndex, focusedIndex, setFocusedIndex }}
>
<div className={className}>{children}</div>
</ShiftTabsContext.Provider>
);
}
type ShiftTabsListProps = ComponentProps<"nav"> & {
"aria-label"?: string;
};
function ShiftTabsList({
className,
children,
"aria-label": ariaLabel = "Tabs",
onKeyDown,
onFocusCapture,
...props
}: ShiftTabsListProps) {
const { activeIndex, setActiveIndex, setFocusedIndex } = useShiftTabs();
const tabs = Children.toArray(children).filter(isValidElement);
const count = tabs.length;
return (
<nav aria-label={ariaLabel} className={cn("overflow-visible", 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 flex-wrap items-center justify-center gap-3 sm:gap-4"
>
{tabs.map((tab, index) => (
<ShiftTabSlotContext.Provider key={tab.key ?? index} value={{ index }}>
{tab}
</ShiftTabSlotContext.Provider>
))}
</div>
</nav>
);
}
function ShiftTabsLabel({ className, ...props }: ComponentProps<"span">) {
return (
<span
className={cn("select-none px-1 text-center font-mono text-sm font-medium", className)}
{...props}
/>
);
}
type ShiftTabsTabProps = ComponentProps<"button"> & {
label?: string;
};
function ShiftTabsTab({
className,
children,
label,
onClick,
onFocus,
...props
}: ShiftTabsTabProps) {
const { activeIndex, setActiveIndex, setFocusedIndex } = useShiftTabs();
const { index } = useShiftTabSlot();
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-lg"),
"transition-colors duration-200",
"active:scale-[0.97] motion-reduce:active:scale-100",
isSelected
? "bg-foreground border-b-2 border-b-accent"
: "bg-transparent hover:bg-foreground",
className,
)}
{...props}
>
<span
className={cn(
"flex h-10 items-center justify-center rounded-md border-2 bg-background px-4",
"transition-transform duration-200 ease-out motion-reduce:transition-none motion-reduce:hover:rotate-0",
isSelected
? "rotate-0 border-accent text-accent"
: "origin-top-right border-border text-foreground hover:rotate-6",
)}
>
{children}
</span>
</button>
);
}
const ShiftTabs = Object.assign(ShiftTabsRoot, {
List: ShiftTabsList,
Tab: ShiftTabsTab,
Label: ShiftTabsLabel,
});
export default ShiftTabs;
export { ShiftTabsLabel, ShiftTabsList, ShiftTabsRoot, ShiftTabsTab, useShiftTabs };Usage
Compose tabs with primitives. Wrap each label in ShiftTabs.Label, or pass plain text as Tab children. Structure: <nav>, inner role="tablist", and <button role="tab"> per item.
import ShiftTabs from "@/animata/tabs/shift-tabs";
<ShiftTabs defaultActiveIndex={0}>
<ShiftTabs.List aria-label="Repository">
<ShiftTabs.Tab label="Issues">
<ShiftTabs.Label>Issues</ShiftTabs.Label>
</ShiftTabs.Tab>
<ShiftTabs.Tab label="Pull Requests">
<ShiftTabs.Label>Pull Requests</ShiftTabs.Label>
</ShiftTabs.Tab>
</ShiftTabs.List>
</ShiftTabs>Keyboard (manual activation): Tab visits every tab; arrow keys move focus; Enter or Space selects. Click selects immediately.
Use activeIndex and onActiveIndexChange for controlled mode. Only the selected tab keeps the foreground frame; inactive tabs show it on hover. Focus ring uses ring-offset-2 with rounded-lg on the tab control.
Credits
Built by Bibek Bhattarai