Tabs

PreviousNext

A set of layered sections of content—known as tab panels.

Docs
opticscomponent

Preview

Loading preview…
registry/optics/tabs.jsx
"use client";

import * as React from "react";
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import { motion, AnimatePresence } from "motion/react";

import {
	Highlight,
	HighlightItem,
} from "@/registry/optics/helpers/primitives/effects/highlight";
import { AutoHeight } from "@/registry/optics/helpers/primitives/effects/auto-height";
import { getStrictContext } from "@/registry/optics/lib/get-strict-context";
import { useControlledState } from "@/registry/optics/hooks/use-controlled-state";
import { cn } from "@/registry/optics/lib/utils";

// --- Internal Primitive Logic ---

const [TabsProvider, useTabs] = getStrictContext("TabsContext");

const [TabsVariantProvider, useTabsVariant] =
	getStrictContext("TabsVariantContext");

function PrimitiveTabs(props = {}) {
	const [value, setValue] = useControlledState({
		value: props?.value,
		defaultValue: props?.defaultValue,
		onChange: props?.onValueChange,
	});

	return (
		<TabsProvider value={{ value, setValue }}>
			<BaseTabs.Root data-slot="tabs" {...props} onValueChange={setValue} />
		</TabsProvider>
	);
}

function PrimitiveTabsHighlight({
	transition = { type: "spring", stiffness: 200, damping: 25 },
	...props
} = {}) {
	const { value } = useTabs();

	return (
		<Highlight
			data-slot="tabs-highlight"
			controlledItems
			value={value}
			transition={transition}
			click={false}
			{...props}
		/>
	);
}

function PrimitiveTabsList(props = {}) {
	return <BaseTabs.List data-slot="tabs-list" {...props} />;
}

function PrimitiveTabsHighlightItem(props = {}) {
	return <HighlightItem data-slot="tabs-highlight-item" {...props} />;
}

function PrimitiveTabsTab(props = {}) {
	return <BaseTabs.Tab data-slot="tabs-tab" {...props} />;
}

function PrimitiveTabsPanel({
	value = "",
	keepMounted = false,
	transition = { duration: 0.5, ease: "easeInOut" },
	...props
} = {}) {
	return (
		<AnimatePresence mode="wait">
			<BaseTabs.Panel
				render={
					<motion.div
						data-slot="tabs-panel"
						layout
						layoutDependency={value}
						initial={{ opacity: 0, filter: "blur(4px)" }}
						animate={{ opacity: 1, filter: "blur(0px)" }}
						exit={{ opacity: 0, filter: "blur(4px)" }}
						transition={transition}
						{...props}
					/>
				}
				keepMounted={keepMounted}
				value={value}
			/>
		</AnimatePresence>
	);
}

const defaultTransition = {
	type: "spring",
	stiffness: 200,
	damping: 30,
};

function isAutoMode(props = {}) {
	return !props.mode || props.mode === "auto-height";
}

function PrimitiveTabsPanels(props = {}) {
	const { value } = useTabs();

	if (isAutoMode(props)) {
		const {
			children = null,
			transition = defaultTransition,
			...autoProps
		} = props;

		return (
			<AutoHeight
				data-slot="tabs-panels"
				deps={[value]}
				transition={transition}
				{...autoProps}
			>
				<React.Fragment key={value}>{children}</React.Fragment>
			</AutoHeight>
		);
	}

	const {
		children = null,
		style = {},
		transition = defaultTransition,
		...layoutProps
	} = props;

	return (
		<motion.div
			data-slot="tabs-panels"
			layout="size"
			layoutDependency={value}
			transition={{ layout: transition }}
			style={{ overflow: "hidden", ...style }}
			{...layoutProps}
		>
			<React.Fragment key={value}>{children}</React.Fragment>
		</motion.div>
	);
}

// --- User-Facing Components ---

function Tabs({ className = "", ...props } = {}) {
	return (
		<PrimitiveTabs
			className={cn("flex flex-col gap-2", className)}
			{...props}
		/>
	);
}

function TabsList({ className = "", variant = "default", ...props } = {}) {
	const highlightStyles = {
		default:
			"absolute z-0 inset-0 border border-transparent rounded-md bg-background dark:border-input dark:bg-input/30 shadow-sm",
		outline:
			"absolute z-0 inset-0 border rounded-md dark:border-input dark:bg-input/30",
		underline: "absolute z-0 -bottom-0.25 left-0 right-0 h-0.5 bg-foreground",
	};

	const listStyles = {
		default:
			"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
		outline:
			"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
		underline:
			"inline-flex h-9 w-fit items-center justify-center gap-1 border-b border-border",
	};

	return (
		<TabsVariantProvider value={{ variant }}>
			<PrimitiveTabsHighlight className={highlightStyles[variant]}>
				<PrimitiveTabsList
					className={cn(listStyles[variant], className)}
					{...props}
				/>
			</PrimitiveTabsHighlight>
		</TabsVariantProvider>
	);
}

function TabsTab({ className = "", value = "", ...props } = {}) {
	const { variant } = useTabsVariant();

	const triggerStyles = {
		default:
			"data-[selected]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md w-full px-2 py-1 text-sm font-medium whitespace-nowrap transition-colors duration-500 ease-in-out focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
		outline:
			"data-[selected]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md w-full px-2 py-1 text-sm font-medium whitespace-nowrap transition-colors duration-500 ease-in-out focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
		underline:
			"data-[selected]:text-foreground text-muted-foreground inline-flex h-full flex-1 items-center justify-center gap-1.5 w-full px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
	};

	return (
		<PrimitiveTabsHighlightItem value={value} className="flex-1">
			<PrimitiveTabsTab
				value={value}
				className={cn(triggerStyles[variant], className)}
				{...props}
			/>
		</PrimitiveTabsHighlightItem>
	);
}

// Aliases for backward compatibility
const TabsTrigger = TabsTab;
const TabsPanels = PrimitiveTabsPanels; // Original name was TabsPanels in previous version, but let's keep it consistent
const TabsContents = PrimitiveTabsPanels;
const TabsPanel = ({ className = "", ...props } = {}) => (
	<PrimitiveTabsPanel
		className={cn("flex-1 outline-none", className)}
		{...props}
	/>
);
const TabsContent = TabsPanel;

export {
	Tabs,
	TabsList,
	TabsTab,
	TabsTrigger,
	TabsPanels,
	TabsContents,
	TabsPanel,
	TabsContent,
};

Installation

npx shadcn@latest add @optics/tabs

Usage

import { Tabs } from "@/components/tabs"
<Tabs />