Tab Switcher

Tabs-based variant switcher utility.


Installation

CLI

npx shadcn@latest add "https://ui.xaclabs.dev/r/tab-switcher.json"

Usage

import { TabSwitcher } from "@/registry/components/tab-switcher"
View component source
tab-switcher.tsx
tsx
"use client"

import { AnimatePresence, motion } from "motion/react"
import * as React from "react"

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

interface TabSwitcherItem {
  value: string
  label: string
  content: React.ReactNode
}

export interface TabSwitcherProps {
  queryKey?: string
  items: TabSwitcherItem[]
  defaultValue?: string
  className?: string
}

function getQueryParam(key: string): string | null {
  if (typeof window === "undefined") return null
  const params = new URLSearchParams(window.location.search)
  return params.get(key)
}

function setQueryParam(key: string, value: string) {
  const url = new URL(window.location.href)
  url.searchParams.set(key, value)
  window.history.replaceState({}, "", url.toString())
}

export function TabSwitcher({
  queryKey = "tab",
  items,
  defaultValue,
  className,
}: TabSwitcherProps) {
  const fallback = defaultValue || items[0]?.value || ""
  const [value, setValue] = React.useState(fallback)

  React.useEffect(() => {
    const fromUrl = getQueryParam(queryKey)
    if (fromUrl && items.some((item) => item.value === fromUrl)) {
      setValue(fromUrl)
    }
  }, [queryKey, items])

  React.useEffect(() => {
    const handler = () => {
      const fromUrl = getQueryParam(queryKey)
      if (fromUrl && items.some((item) => item.value === fromUrl)) {
        setValue(fromUrl)
      }
    }
    window.addEventListener("tab-switcher-sync", handler)
    return () => window.removeEventListener("tab-switcher-sync", handler)
  }, [queryKey, items])

  const handleChange = React.useCallback(
    (newValue: string) => {
      setValue(newValue)
      setQueryParam(queryKey, newValue)
      window.dispatchEvent(new CustomEvent("tab-switcher-sync"))
    },
    [queryKey],
  )

  return (
    <Tabs value={value} onValueChange={handleChange} className={className}>
      <TabsList>
        {items.map((item) => (
          <TabsTrigger key={item.value} value={item.value} className="relative">
            {item.label}
            {value === item.value && (
              <motion.div
                layoutId="active-tab-indicator"
                className="absolute bottom-0 left-0 h-[2px] w-full bg-foreground"
                transition={{
                  type: "spring",
                  stiffness: 500,
                  damping: 30,
                }}
              />
            )}
          </TabsTrigger>
        ))}
      </TabsList>
      {items.map((item) => (
        <TabsContent key={item.value} value={item.value}>
          <AnimatePresence mode="wait">
            <motion.div
              key={item.value}
              initial={{ opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -10 }}
              transition={{ duration: 0.2 }}
            >
              {item.content}
            </motion.div>
          </AnimatePresence>
        </TabsContent>
      ))}
    </Tabs>
  )
}