my/ui

Command Palette

Search for a command to run...

All components

Animated Tabs

navigation

Ui-Layouts component.

responsive · 688px

Install

Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:

$npx shadcn@latest add https://your-domain/r/animated-tabs.json

Usage

"use client";
import { TabsProvider, TabsBtn, TabsContent } from "@/registry/ui-layouts/tab";

const TABS = [
  {
    value: "overview",
    label: "Overview",
    content:
      "An overview gives stakeholders a concise picture of scope, goals, and current status. Keep it scannable — bullet-points and short sentences outperform dense paragraphs every time.",
  },
  {
    value: "features",
    label: "Features",
    content:
      "Features are the building blocks of your product. Prioritise ruthlessly: ship the 20 % that delivers 80 % of value first, then iterate based on real usage data.",
  },
  {
    value: "pricing",
    label: "Pricing",
    content:
      "Simple, transparent pricing builds trust. Three tiers — free, pro, and enterprise — cover most SaaS businesses without overwhelming prospective customers with choice.",
  },
];

export default function Demo() {
  return (
    <div className="w-full h-full flex flex-col items-center justify-center p-8 gap-6">
      <TabsProvider defaultValue="overview" wobbly>
        {/* Tab bar */}
        <div className="flex gap-1 p-1 rounded-lg bg-muted w-fit">
          {TABS.map((tab) => (
            <TabsBtn key={tab.value} value={tab.value}>
              <span className="relative z-10 text-sm font-medium px-1 select-none">
                {tab.label}
              </span>
            </TabsBtn>
          ))}
        </div>

        {/* Tab panels */}
        <div className="relative w-full max-w-lg min-h-[120px]">
          {TABS.map((tab) => (
            <TabsContent key={tab.value} value={tab.value} yValue>
              <p className="text-sm text-muted-foreground leading-relaxed">{tab.content}</p>
            </TabsContent>
          ))}
        </div>
      </TabsProvider>
    </div>
  );
}

Component source

'use client';

import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'motion/react';
import React, {
  createContext,
  isValidElement,
  type ReactNode,
  useCallback,
  useContext,
  useMemo,
  useState,
} from 'react';

// Improved TypeScript interfaces with more specific types
interface TabContextType {
  activeTab: string;
  setActiveTab: (value: string) => void;
  wobbly: boolean;
  hover: boolean;
  defaultValue: string;
  prevIndex: number;
  setPrevIndex: (value: number) => void;
  tabsOrder: string[];
}

const TabContext = createContext<TabContextType | undefined>(undefined);

// Custom hook with memoization
export const useTabs = () => {
  const context = useContext(TabContext);
  if (!context) {
    throw new Error('useTabs must be used within a TabsProvider');
  }
  return context;
};

// Props interfaces with more specific types
interface TabsProviderProps {
  children: ReactNode;
  defaultValue: string;
  wobbly?: boolean;
  hover?: boolean;
}

interface TabsBtnProps {
  children: ReactNode;
  className?: string;
  value: string;
}

interface TabsContentProps {
  children: ReactNode;
  className?: string;
  value: string;
  yValue?: boolean;
}

export const TabsProvider: React.FC<TabsProviderProps> = React.memo(
  ({ children, defaultValue, wobbly = true, hover = false }) => {
    // Use useCallback to memoize state setters
    const [activeTab, setActiveTab] = useState(defaultValue);
    const [prevIndex, setPrevIndex] = useState(0);

    // Memoize tabs order to prevent unnecessary recalculations
    const tabsOrder = useMemo(() => {
      return React.Children.toArray(children)
        .filter((child) => isValidElement(child) && child.type === TabsContent)
        .map((child) => (child as React.ReactElement<any>).props.value);
    }, [children]);

    // Memoize context value to prevent unnecessary re-renders
    const contextValue = useMemo(
      () => ({
        activeTab,
        setActiveTab,
        wobbly,
        hover,
        defaultValue,
        setPrevIndex,
        prevIndex,
        tabsOrder,
      }),
      [activeTab, setActiveTab, wobbly, hover, defaultValue, prevIndex, tabsOrder]
    );

    return <TabContext.Provider value={contextValue}>{children}</TabContext.Provider>;
  }
);

// Memoized TabsBtn component
export const TabsBtn: React.FC<TabsBtnProps> = React.memo(({ children, className, value }) => {
  const { activeTab, setPrevIndex, setActiveTab, defaultValue, hover, wobbly, tabsOrder } =
    useTabs();

  // Use useCallback to memoize the click handler
  const handleClick = useCallback(() => {
    setPrevIndex(tabsOrder.indexOf(activeTab));
    setActiveTab(value);
  }, [setPrevIndex, tabsOrder, activeTab, setActiveTab, value]);

  return (
    <motion.div
      className={cn(`cursor-pointer 2xl:p-2 p-2 2xl:px-4 px-2 rounded-md relative`, className)}
      onFocus={() => hover && handleClick()}
      onMouseEnter={() => hover && handleClick()}
      onClick={handleClick}
    >
      {children}

      <AnimatePresence mode='wait'>
        {activeTab === value && (
          <>
            <motion.div
              transition={{
                layout: {
                  duration: 0.2,
                  ease: 'easeInOut',
                  delay: 0.2,
                },
              }}
              layoutId={defaultValue}
              className='absolute w-full h-full left-0 top-0 dark:bg-primary-base bg-white rounded-md z-1'
            />

            {wobbly && (
              <>
                <motion.div
                  transition={{
                    layout: {
                      duration: 0.4,
                      ease: 'easeInOut',
                      delay: 0.04,
                    },
                  }}
                  layoutId={defaultValue}
                  className='absolute w-full h-full left-0 top-0 dark:bg-primary-base bg-white rounded-md z-1 tab-shadow'
                />
                <motion.div
                  transition={{
                    layout: {
                      duration: 0.4,
                      ease: 'easeOut',
                      delay: 0.2,
                    },
                  }}
                  layoutId={`${defaultValue}b`}
                  className='absolute w-full h-full left-0 top-0 dark:bg-primary-base bg-white rounded-md z-1 tab-shadow'
                />
              </>
            )}
          </>
        )}
      </AnimatePresence>
    </motion.div>
  );
});

// Memoized TabsContent component
export const TabsContent: React.FC<TabsContentProps> = React.memo(
  ({ children, className, value, yValue }) => {
    const { activeTab, tabsOrder, prevIndex } = useTabs();

    // Memoize direction calculation
    const isForward = useMemo(
      () => tabsOrder.indexOf(activeTab) > prevIndex,
      [tabsOrder, activeTab, prevIndex]
    );

    return (
      <AnimatePresence mode='popLayout'>
        {activeTab === value && (
          <motion.div
            initial={{ opacity: 0, y: yValue ? (isForward ? 10 : -10) : 0 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: yValue ? (isForward ? -50 : 50) : 0 }}
            transition={{
              duration: 0.3,
              ease: 'easeInOut',
              delay: 0.5,
            }}
            className={cn('p-2 px-4 rounded-md relative', className)}
          >
            {children}
          </motion.div>
        )}
      </AnimatePresence>
    );
  }
);

// Add display names for better debugging
TabsProvider.displayName = 'TabsProvider';
TabsBtn.displayName = 'TabsBtn';
TabsContent.displayName = 'TabsContent';

Dependencies

motion

Source: Ui-Layouts