v2.0 is here! New components and themes are coming soon! Stay tuned.
TWJ Labs UI

Tabs

Switch between different views, content, or sub-pages

Organize content into separate views where only one view is visible at a time.

The Tabs component is a layered component used to organize content into distinct sections. It is useful for:

  • settings pages
  • profile views
  • switching between data tables
  • toggleable forms

It supports themes, keyboard navigation, and automatic theme propagation to nested triggers and content.

TL;DR: Tabs allows users to navigate between multiple panels of content within the same context. It includes a list of triggers (buttons) and corresponding content panels.

Installation

Using CLI

if you're using twjlabs/ui cli, it should be pretty easy for you

npx @twjlabs/ui add tabs
pnpm dlx @twjlabs/ui add tabs 

Manual Installation

if you're not using the cli, you would need to install it manually.

Now make sure you have twj-lib folder with necessary utility functions and types in your project. If not, you can refer here to set it up.

Finally, copy and paste the following code in your components/ui/tabs.tsx folder

components/ui/tabs.tsx
"use client"

import { useAIControl } from "@/contexts/ai-context"
import { ThemeProvider, useTheme } from "@/contexts/ui-theme-context"
import { fontApplier } from "@/twj-lib/font-applier"
import { cn } from "@/twj-lib/tw"
import { Theme, TWJAIComponentsProps, TWJComponentsProps } from "@/twj-lib/types"
import { AnimatePresence, motion } from "motion/react"
import { createContext, useContext, useEffect, useState } from "react"
import { useCallback, useMemo } from "react"


// 1. Context Definition
type TabsContextType = {
    selectedTab: string
    changeSelectedTab: (tab: string) => void
    theme: Theme

    registerTab: (tab: string) => void
    unregisterTab: (tab: string) => void
}

const TabsContext = createContext<TabsContextType | undefined>(undefined)

export const useTabsContext = () => {
    const tabsContext = useContext(TabsContext)
    if (!tabsContext) {
        throw new Error("useTabsContext must be used within a TabsProvider")
    }
    return tabsContext
}

// 2. Main Tabs Component (Acts as the Provider)
interface TabsProps extends TWJAIComponentsProps {
    children: React.ReactNode
    defaultValue: string
    className?: string
    onTabChange?: (tab: string) => void // ✅ Now implemented
}

export const Tabs = ({ children, theme, defaultValue, className, onTabChange, aiDescription, aiID }: TabsProps) => {
    const [selectedTab, setSelectedTab] = useState(defaultValue)
    const { theme: contextTheme } = useTheme()
    const [mounted, setMounted] = useState(false)

    const [availableTabs, setAvailableTabs] = useState<string[]>([])

   
    useEffect(() => {
        const id = setTimeout(() => setMounted(true), 0)
        return () => clearTimeout(id)
    }, [])

    const activeTheme = theme || contextTheme || "modern"
    const appliedTheme = mounted ? activeTheme : "modern"
    
    const fontClass = fontApplier(appliedTheme)
    const themeClass = `theme-${appliedTheme}`

    // ✅ Logic Update: Wrap the state setter to trigger onTabChange
    const handleTabChange = (tab: string) => {
        setSelectedTab(tab)
        if (onTabChange) {
            onTabChange(tab)
        }
    }

    const registerTab = useCallback((tab: string) => {
        setAvailableTabs(prev => {
            if (prev.includes(tab)) return prev;
            return [...prev, tab];
        });
    }, []);

    const unregisterTab = useCallback((tab: string) => {
        setAvailableTabs(prev => prev.filter(t => t !== tab));
    }, []);

    useAIControl({
        id: aiID || '',
        // dynamically inject the discovered tabs into the description
        description: `${aiDescription || 'Switch between tabs.'} Available tabs: [${availableTabs.join(', ')}].`,
        actions: {
            switchTab: async (rawInput: any) => {
                const query = (rawInput === undefined || rawInput === null) ? "" : String(rawInput);
                console.log(`[AI Tabs] Request: "${query}" | Available: ${availableTabs.join(', ')}`);

                // Simple fuzzy match against the AUTOMATIC list
                const match = availableTabs.find(t => 
                    t.toLowerCase() === query.toLowerCase() || 
                    query.toLowerCase().includes(t.toLowerCase())
                );

                if (match) {
                    handleTabChange(match);
                } else {
                    // Fallback
                    handleTabChange(query);
                }
            }
        }
    });

    const contextValue = {
        selectedTab,
        changeSelectedTab: handleTabChange, // Pass the wrapper instead of raw setter
        theme: appliedTheme,
        registerTab,    // Pass down
        unregisterTab   // Pass down
    }

    return (
        <TabsContext.Provider value={contextValue}>
            <ThemeProvider initialTheme={appliedTheme} key={appliedTheme}>
                <div className={cn(
                    fontClass, 
                    themeClass, 
                    "flex flex-col w-full",
                    className
                )}>
                    {children}
                </div>
            </ThemeProvider>
        </TabsContext.Provider>
    )
}

// 3. Tabs List Container
export const TabsList = ({children, className}: {children: React.ReactNode; className?: string}) => {
    const {theme} = useTabsContext()
    return (
        <div className={cn(
            "tabs-list w-fit p-0.5",
            'flex gap-1 mb-4 overflow-hidden',
            'bg-surface dark:bg-surface-dark',
            'border rounded-theme border-foreground/20',
            theme === 'brutalist' && [
                'border-2 border-border dark:border-border-dark',
                'shadow-[4px_4px_0_0_rgb(0,0,0)] shadow-foreground dark:shadow-foreground-dark',
                'bg-surface'
            ],
            className
        )}>{children}</div>
    )
}

// 4. Individual Tab Button
// ✅ Updated props to extend HTMLButtonElement so 'onClick' works naturally
interface TabProps extends React.ComponentProps<"button"> {
    tab: string
    children?: React.ReactNode
    className?: string
    title?: string
}

export const Tab = ({ tab, children, className, title, onClick, ...props }: TabProps) => {
    const { selectedTab, changeSelectedTab, registerTab, unregisterTab } = useTabsContext()

    const isActive = selectedTab === tab

    // 🆕 Auto-Register this tab when component mounts
    useEffect(() => {
        registerTab(tab);
        // Clean up when unmounted
        return () => unregisterTab(tab);
    }, [tab, registerTab, unregisterTab]);

    const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
        // 1. Switch the tab in context
        changeSelectedTab(tab)
        
        // 2. Fire specific onClick handler if user provided one
        if (onClick) {
            onClick(e)
        }
    }

    return (
        <button
            type="button" // Good practice to prevent form submission
            className={cn(
                `tab`,
                'p-2 px-3 border rounded-theme',
                'transition ease-in-out',
                isActive ? 'bg-primary text-primary-foreground border-foreground/15' : 'bg-transparent text-foreground dark:text-foreground-dark border-transparent hover:bg-foreground/10',
                className
            )}
            onClick={handleClick} // ✅ Use the wrapper handler
            {...props} // ✅ Spread other props (disabled, id, etc.)
        >
            {children ? children : title}
        </button>
    )
}

// 5. Views
export const TabsView = ({children}: {children: React.ReactNode}) => {
    return <div className="tabs-view">{children}</div>
}

export const TabView = ({tab, children}: {tab: string; children: React.ReactNode}) => {
    const {selectedTab, theme} = useTabsContext()

    if (selectedTab !== tab) {
        return null
    }

    return (
        <ThemeProvider initialTheme={theme} key={theme}>
           <AnimatePresence initial={true}>
             <motion.div 
                initial={{ opacity: 0, transform: 'translateY(10px)' }}
                animate={{ opacity: 1, transform: 'translateY(0)' }}
                transition={{duration: 0.4}}
                exit={{ opacity: 0, transform: 'translateY(10px)' }}
                className={cn(
                    "tab-view", 
                    fontApplier(theme)
                )}
            >
                {children} 
            </motion.div>
           </AnimatePresence>
        </ThemeProvider>
    )
}

Usage

import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";

<Tabs defaultValue="account" className="w-[400px]">
  <TabsList>
    <TabsTrigger value="account">Account</TabsTrigger>
    <TabsTrigger value="password">Password</TabsTrigger>
  </TabsList>
  <TabsContent value="account">Make changes to your account here.</TabsContent>
  <TabsContent value="password">Change your password here.</TabsContent>
</Tabs>

Tabs Props

PropTypeDefaultDescription
defaultValuestring-The value of the tab to select by default
valuestring-The controlled value of the tab to select
onValueChange(value: string) => void-Event handler called when value changes
themeThememodernTheme override

On this page