Skip to content
Docs
Shift Tabs

Shift Tabs

Composable shift-style tabs with hover tilt and manual keyboard selection.

requires interactionhoverclick
Loading...

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.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 {
  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