Tabs
Tabs is a component that displays multiple tabs.
Code
import { AnimatePresence, motion } from "motion/react";
import React from "react";
export interface TabsContext {
selected: string;
onTrigger: (name: string) => void;
isFirstRender: boolean;
}
export const TabsContext = React.createContext<TabsContext>({} as TabsContext);
export function useTabs() {
return React.useContext(TabsContext);
}
export interface TabsProps {
defaultValue: string;
children: React.ReactNode;
onTrigger?: (name: string) => void;
}
export function Tabs({ children, defaultValue, onTrigger }: TabsProps) {
const [selected, setSelected] = React.useState(defaultValue);
const isFirstRender = React.useRef(true);
function handleTrigger(name: string) {
isFirstRender.current = false;
setSelected(name);
onTrigger?.(name);
}
return (
<TabsContext.Provider
value={{
isFirstRender: isFirstRender.current,
selected,
onTrigger: handleTrigger,
}}
>
{children}
</TabsContext.Provider>
);
}
export interface TabsPanelProps extends React.ComponentProps<"div"> {}
export function TabsPanel(props: TabsPanelProps) {
return <div {...props} />;
}
export interface TabTriggerProps extends React.ComponentProps<"button"> {
trigger: string;
}
export function TabTrigger(props: TabTriggerProps) {
const { onTrigger, selected } = useTabs();
return (
<button
{...props}
onClick={e => {
onTrigger(props.trigger);
props.onClick?.(e);
}}
data-selected={selected === props.trigger}
/>
);
}
export interface TabsContent extends React.ComponentProps<"div"> {}
export function TabsContent({ children, ...props }: TabsContent) {
const { selected, isFirstRender } = useTabs();
return (
<div {...props}>
<AnimatePresence mode="wait">
{React.Children.map(children, child => {
if (!React.isValidElement<TabItem>(child)) return null;
if (child.props.name === selected) {
return React.cloneElement(child, {
initial: isFirstRender ? false : child.props.initial,
});
}
return null;
})}
</AnimatePresence>
</div>
);
}
export interface TabItem extends React.ComponentProps<typeof motion.div> {
children: React.ReactNode;
name: string;
}
export function TabItem({ children, name, ...props }: TabItem) {
return <motion.div {...props}>{children}</motion.div>;
}
Example
Tab 1 content
import { motion } from "motion/react";
import {
TabItem,
Tabs,
TabsContent,
TabsPanel,
TabTrigger,
} from "./primitives/tabs";
import React from "react";
const TABS = [
{ name: "tab1", label: "Tab 1", content: <p>Tab 1 content</p> },
{ name: "tab2", label: "Tab 2", content: <p>Tab 2 content</p> },
{ name: "tab3", label: "Tab 3", content: <p>Tab 3 content</p> },
];
function TabsExample() {
const [selected, setSelected] = React.useState("tab1");
const animateTabPanel = `${
TABS.findIndex(tab => tab.name === selected) * 100
}%`;
return (
<Tabs
defaultValue={selected}
onTrigger={setSelected}
>
<TabsPanel className="rounded-md bg-gray-100 p-2 flex gap-4 mb-4 relative">
<motion.div
className="absolute bg-white rounded-md inset-2"
layoutId="indicator"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
style={{
width: `calc(${100 / 3}% - ${6}px)`,
}}
animate={{
x: animateTabPanel,
}}
/>
<TabTrigger
trigger="tab1"
className="flex-1 relative z-10"
>
Tab 1
</TabTrigger>
<TabTrigger
trigger="tab2"
className="flex-1 relative z-10"
>
Tab 2
</TabTrigger>
<TabTrigger
trigger="tab3"
className="flex-1 relative z-10"
>
Tab 3
</TabTrigger>
</TabsPanel>
<TabsContent className="overflow-hidden border border-gray-200 bg-white py-2 px-4 rounded-md">
{TABS.map(tab => (
<TabItem
key={tab.name}
name={tab.name}
initial={{ filter: "blur(2px)", x: "100%" }}
animate={{ filter: "blur(0px)", x: 0 }}
exit={{ filter: "blur(2px)", x: "-100%" }}
transition={{ type: "tween", duration: 0.3 }}
>
{tab.content}
</TabItem>
))}
</TabsContent>
</Tabs>
);
}