Search

Ctrl + K

Tabs

This document outlines the steps to create Tabs component styled withTailwind CSS and using some npm dependency libraries.

Prerequisites

  • 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.
Step 1: Create Tabs component
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