This document outlines the steps to create Tabs
component styled withTailwind CSS and using some npm dependency libraries.
Node.js
and npm
installed on your machine.Tailwind CSS
installed in your project.CVA(class-variance-authority)
is a utility for managing CSS class names based on various conditions.clsx
is a tiny utility for constructing className strings conditionally.1// tabs.component.tsx
2import clsx from "clsx";
3import { useEffect, useRef } from "react";
4
5export interface Tab {
6 title: string;
7 disabled?: boolean;
8}
9
10export interface TabsProps {
11 tabs: Tab[];
12 variant?: "solid" | "bordered" | "underlined" | "ghost";
13 size?: "small" | "medium" | "large";
14 radius?: "none" | "medium" | "full";
15 disabled?: boolean;
16 selectedIndex: number;
17 className?: string;
18 onChange: (index: number) => void;
19}
20
21const Tabs: React.FC<TabsProps> = ({
22 tabs,
23 variant = "solid",
24 size = "medium",
25 radius = "medium",
26 disabled,
27 selectedIndex,
28 className,
29 onChange,
30}) => {
31 const tabRef = useRef<HTMLDivElement>(null);
32 const animationRef = useRef<HTMLDivElement>(null);
33
34 const tabSizes = useRef<Array<{ width: number; left: number }>>([]);
35
36 const setTabPosition = (index: number) => {
37 if (!tabRef.current) return;
38 const size = tabSizes.current[index];
39 if (!size) return;
40 animationRef.current!.style.width = `${size.width}px`;
41 animationRef.current!.style.left = `${size.left}px`;
42 };
43
44 useEffect(() => {
45 const getTabSizes = () => {
46 if (!tabRef.current) return;
47 tabSizes.current = Array.from(tabRef.current.children).map((tab) => ({
48 width: tab.getBoundingClientRect().width,
49 left: 0,
50 }));
51 if (tabSizes.current.length === 0) return;
52 for (let i = 1; i < tabSizes.current.length; i++) {
53 tabSizes.current[i].left =
54 tabSizes.current[i - 1].left + tabSizes.current[i - 1].width;
55 }
56 };
57
58 getTabSizes();
59 setTabPosition(selectedIndex);
60
61 const observer = new ResizeObserver(getTabSizes);
62 observer.observe(tabRef.current!);
63
64 return () => {
65 observer.disconnect();
66 };
67
68 // eslint-disable-next-line react-hooks/exhaustive-deps
69 }, [variant, size, radius]);
70
71 const handleChange = (tab: Tab, index: number) => {
72 if (disabled || tab.disabled) return;
73 setTabPosition(index);
74 onChange(index);
75 };
76
77 return (
78 <div
79 className={clsx(
80 "relative z-10 flex overflow-hidden py-1",
81 {
82 "rounded-md": radius === "medium",
83 "rounded-full": radius === "full",
84 },
85 {
86 "border border-neutral-400": variant === "bordered",
87 "opacity-40": disabled,
88 },
89 className,
90 )}
91 >
92 {variant === "solid" && (
93 <div className="absolute inset-x-0 inset-y-0 -z-10 bg-neutral-200 dark:bg-neutral-700" />
94 )}
95 <div
96 ref={animationRef}
97 className="absolute inset-y-0 -z-10 p-1 transition-all duration-150"
98 >
99 {variant === "underlined" ? (
100 <div className="h-full w-full border-b-2 border-neutral-500" />
101 ) : (
102 <div
103 className={clsx(
104 "h-full w-full border border-neutral-200 bg-white shadow-sm dark:border-neutral-500 dark:bg-neutral-500",
105 {
106 "rounded-md": radius === "medium",
107 "rounded-full": radius === "full",
108 },
109 )}
110 />
111 )}
112 </div>
113 <div ref={tabRef} className="flex">
114 {tabs.map((tab, index) => (
115 <div
116 key={index}
117 className={clsx(
118 "select-none bg-transparent py-1.5",
119 {
120 "text-xs": size === "small",
121 "text-sm": size === "medium",
122 "text-lg": size === "large",
123 },
124 variant === "underlined" ? "px-2.5" : "px-4",
125 tab.disabled
126 ? "cursor-not-allowed text-neutral-300 dark:text-neutral-500"
127 : "cursor-pointer text-neutral-600 dark:text-neutral-200",
128 )}
129 onClick={() => handleChange(tab, index)}
130 >
131 {tab.title}
132 </div>
133 ))}
134 </div>
135 </div>
136 );
137};
138
139export default Tabs;
140