This document outlines the steps to create Table
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// scroll-area.tsx
2import clsx from "clsx";
3import debounce from "lodash/debounce";
4import { forwardRef, useCallback, useEffect, useRef } from "react";
5
6interface Props extends React.HTMLAttributes<HTMLDivElement> {
7 wrapperClassname?: string;
8}
9
10const ScrollArea = forwardRef<HTMLDivElement, Props>(
11 ({ className, wrapperClassname, children, ...props }, ref) => {
12 const contentRef = useRef<HTMLDivElement>(null);
13 const scrollbarRef = useRef<HTMLDivElement>(null);
14 const scrollTrackRef = useRef<HTMLDivElement>(null);
15 const scrollThumbRef = useRef<HTMLDivElement>(null);
16 const observer = useRef<ResizeObserver | null>(null);
17
18 const thumbHeight = useRef<number>(20);
19 const scrollStartPosition = useRef<number>(0);
20 const initialScrollTop = useRef<number>(0);
21 const isDragging = useRef<boolean>(false);
22 const isHovering = useRef<boolean>(false);
23
24 const handleResize = (ref: HTMLDivElement, trackSize: number) => {
25 const { clientHeight, scrollHeight } = ref;
26 thumbHeight.current = Math.max(
27 (clientHeight / scrollHeight) * trackSize,
28 20,
29 );
30 scrollThumbRef.current!.style.height = `${thumbHeight.current}px`;
31 };
32
33 const handleTrackClick = useCallback(
34 (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
35 e.preventDefault();
36 e.stopPropagation();
37
38 const { clientY } = e;
39 const target = e.target as HTMLDivElement;
40 const rect = target.getBoundingClientRect();
41 const trackTop = rect.top;
42 const thumbOffset = thumbHeight.current / 2;
43 const clickRatio =
44 (clientY - trackTop - thumbOffset) /
45 scrollTrackRef.current!.clientHeight;
46 const scrollAmount = Math.floor(
47 clickRatio * contentRef.current!.scrollHeight,
48 );
49 contentRef.current!.scrollTo({
50 top: scrollAmount,
51 behavior: "smooth",
52 });
53 },
54 [],
55 );
56
57 const handleThumbMousedown = (
58 e: React.MouseEvent<HTMLDivElement, MouseEvent>,
59 ) => {
60 e.preventDefault();
61 e.stopPropagation();
62
63 scrollStartPosition.current = e.clientY;
64 initialScrollTop.current = contentRef.current!.scrollTop;
65 isDragging.current = true;
66 };
67
68 useEffect(() => {
69 const handleThumbMouseup = (e: MouseEvent) => {
70 e.preventDefault();
71 e.stopPropagation();
72 isDragging.current = false;
73 };
74
75 const handleThumbMousemove = (e: MouseEvent) => {
76 e.preventDefault();
77 e.stopPropagation();
78
79 if (!isDragging.current) return;
80
81 const {
82 scrollHeight: contentScrollHeight,
83 offsetHeight: contentOffsetHeight,
84 } = contentRef.current!;
85
86 const deltaY =
87 (e.clientY - scrollStartPosition.current) *
88 (contentOffsetHeight / thumbHeight.current);
89 const newScrollTop = Math.min(
90 initialScrollTop.current + deltaY,
91 contentScrollHeight - contentOffsetHeight,
92 );
93
94 contentRef.current!.scrollTop = newScrollTop;
95 };
96
97 document.addEventListener("mousemove", handleThumbMousemove);
98 document.addEventListener("mouseup", handleThumbMouseup);
99 document.addEventListener("mouseleave", handleThumbMouseup);
100
101 return () => {
102 document.removeEventListener("mousemove", handleThumbMousemove);
103 document.removeEventListener("mouseup", handleThumbMouseup);
104 document.removeEventListener("mouseleave", handleThumbMouseup);
105 };
106 }, []);
107
108 useEffect(() => {
109 const ref = contentRef.current!;
110 const { clientHeight: trackSize } = scrollTrackRef.current!;
111
112 handleResize(ref, trackSize);
113
114 observer.current = new ResizeObserver(() => {
115 handleResize(ref, trackSize);
116 });
117
118 observer.current.observe(ref);
119
120 return () => {
121 observer.current?.unobserve(ref);
122 };
123 }, []);
124
125 useEffect(() => {
126 const ref = contentRef.current!;
127
128 const handleThumbPosition = () => {
129 const { scrollTop: contentTop, scrollHeight: contentHeight } =
130 contentRef.current!;
131 const { clientHeight: trackHeight } = scrollTrackRef.current!;
132 let newTop = (+contentTop / +contentHeight) * trackHeight;
133 newTop = Math.min(newTop, trackHeight - thumbHeight.current);
134 const thumb = scrollThumbRef.current!;
135 thumb.style.top = `${newTop}px`;
136 };
137
138 ref.addEventListener("scroll", handleThumbPosition);
139
140 return () => {
141 ref.removeEventListener("scroll", handleThumbPosition);
142 };
143 }, []);
144
145 useEffect(() => {
146 const ref = contentRef.current!;
147
148 const handleMouseLeaveDebounce = debounce(() => {
149 if (isDragging.current || isHovering.current) return;
150 scrollbarRef.current!.style.visibility = "hidden";
151 }, 1000);
152
153 const handleMouseEnter = () => {
154 isHovering.current = true;
155 const element = contentRef.current!;
156 if (element.scrollHeight > element.offsetHeight)
157 scrollbarRef.current!.style.visibility = "visible";
158 };
159
160 const handleMouseLeave = () => {
161 isHovering.current = false;
162 };
163
164 ref.addEventListener("mouseleave", handleMouseLeaveDebounce);
165 ref.addEventListener("mouseleave", handleMouseLeave);
166 ref.addEventListener("mouseenter", handleMouseEnter);
167 document.addEventListener("mouseup", handleMouseLeaveDebounce);
168 document.addEventListener("mouseleave", handleMouseLeaveDebounce);
169
170 return () => {
171 ref.removeEventListener("mouseleave", handleMouseLeaveDebounce);
172 ref.removeEventListener("mouseleave", handleMouseLeave);
173 ref.removeEventListener("mouseenter", handleMouseEnter);
174 document.removeEventListener("mouseup", handleMouseLeaveDebounce);
175 document.removeEventListener("mouseleave", handleMouseLeaveDebounce);
176 };
177 }, []);
178
179 return (
180 <div
181 ref={ref}
182 className={clsx("relative h-full w-full", className)}
183 {...props}
184 >
185 <div
186 ref={contentRef}
187 className={clsx(
188 "h-full w-full overflow-auto scrollbar-hide",
189 wrapperClassname,
190 )}
191 >
192 {children}
193 </div>
194 <div
195 ref={scrollbarRef}
196 className="absolute inset-y-0 right-0 w-2 rounded-md"
197 >
198 <div
199 ref={scrollTrackRef}
200 className="absolute inset-0 rounded-md bg-neutral-200"
201 onClick={handleTrackClick}
202 />
203 <div
204 ref={scrollThumbRef}
205 className="absolute inset-x-0 rounded-md bg-neutral-400"
206 onMouseDown={handleThumbMousedown}
207 />
208 </div>
209 </div>
210 );
211 },
212);
213
214ScrollArea.displayName = "ScrollArea";
215
216export default ScrollArea;
217
1// checkbox.tsx
2import clsx from "clsx";
3import { cva, VariantProps } from "class-variance-authority";
4import { CheckIcon, MinusIcon } from "lucide-react";
5
6const colorVariants = cva("", {
7 variants: {
8 color: {
9 primary: "border-blue-500 bg-blue-500 text-white",
10 secondary: "border-gray-300 bg-gray-300 text-neutral-700",
11 success: "border-green-500 bg-green-500 text-white",
12 danger: "border-red-500 bg-red-500 text-white",
13 warning: "border-yellow-500 bg-yellow-500 text-white",
14 info: "border-cyan-500 bg-cyan-500 text-white",
15 light: "border-background bg-background text-foreground",
16 dark: "border-neutral-900 bg-neutral-900 text-white",
17 },
18 },
19});
20
21type ColorVariant = VariantProps<typeof colorVariants>;
22
23interface Props {
24 color: ColorVariant["color"];
25 type: "checked" | "indeterminate" | "unchecked";
26 disabled?: boolean;
27 onClick?: () => void;
28}
29
30const Checkbox: React.FC<Props> = ({ color, type, disabled, onClick }) => {
31 return (
32 <div
33 className={clsx(
34 "mx-2 flex h-5 w-5 select-none items-center justify-center rounded-md border border-neutral-300",
35 type !== "unchecked" && colorVariants({ color }),
36 { "cursor-pointer": !disabled },
37 )}
38 onClick={!disabled ? onClick : undefined}
39 >
40 {type === "checked" ? (
41 <CheckIcon className="h-4 w-4" />
42 ) : type === "indeterminate" ? (
43 <MinusIcon className="h-4 w-4" />
44 ) : null}
45 </div>
46 );
47};
48
49export default Checkbox;
50
1// table.helpers.ts
2import { cva } from "class-variance-authority";
3
4export const tableVariants = cva("w-full", {
5 variants: {
6 color: {
7 primary: "bg-blue-100",
8 secondary: "bg-gray-100",
9 success: "bg-green-100",
10 danger: "bg-red-100",
11 warning: "bg-yellow-100",
12 info: "bg-cyan-100",
13 light: "bg-background",
14 dark: "bg-neutral-700",
15 },
16 },
17});
18
19export const headerVariants = cva("", {
20 variants: {
21 color: {
22 primary: "bg-blue-500 text-blue-100",
23 secondary: "bg-gray-500 text-gray-100",
24 success: "bg-green-500 text-green-100",
25 danger: "bg-red-500 text-red-100",
26 warning: "bg-yellow-500 text-yellow-100",
27 info: "bg-cyan-500 text-cyan-100",
28 light: "bg-background text-foreground",
29 dark: "bg-neutral-900 text-white",
30 },
31 },
32});
33
34export const unHoverRowVariants = cva("", {
35 variants: {
36 color: {
37 primary: "bg-blue-200 text-blue-700",
38 secondary: "bg-gray-200 text-gray-700",
39 success: "bg-green-200 text-green-700",
40 danger: "bg-red-200 text-red-700",
41 warning: "bg-yellow-200 text-yellow-700",
42 info: "bg-cyan-200 text-cyan-700",
43 light: "bg-neutral-100 text-foreground",
44 dark: "bg-neutral-900 text-white",
45 },
46 },
47});
48
49export const hoverRowVariants = cva("rounded", {
50 variants: {
51 color: {
52 primary: "hover:bg-blue-100",
53 secondary: "hover:bg-gray-100",
54 success: "hover:bg-green-100",
55 danger: "hover:bg-red-100",
56 warning: "hover:bg-yellow-100",
57 info: "hover:bg-cyan-100",
58 light: "hover:bg-neutral-100",
59 dark: "hover:bg-neutral-500",
60 },
61 },
62});
63
64export const borderVariants = cva("", {
65 variants: {
66 color: {
67 primary: "border-blue-300",
68 secondary: "border-gray-300",
69 success: "border-green-300",
70 danger: "border-red-300",
71 warning: "border-yellow-300",
72 info: "border-cyan-300",
73 light: "border-neutral-300",
74 dark: "border-neutral-700",
75 },
76 },
77});
78
1// table.types.ts
2import type { VariantProps } from "class-variance-authority";
3import { tableVariants } from "./table.helpers";
4
5type ColorVariant = VariantProps<typeof tableVariants>;
6
7export interface TableColumn {
8 key: string;
9 label: string;
10 width?: string | number;
11 sorter?: boolean;
12}
13
14interface TableData {
15 [key: string]: React.ReactNode;
16}
17
18type SelectionProps =
19 | {
20 selectionMode?: "single" | "multiple";
21 selections: string[];
22 onChangeSelections: (selections: string[]) => void;
23 }
24 | {
25 selectionMode?: "none";
26 selections?: string[];
27 onChangeSelections?: (selections: string[]) => void;
28 };
29
30export type TableProps = {
31 keyField: string;
32 header: TableColumn[];
33 data: TableData[];
34 stickyHeader?: boolean;
35 variant?: "outline" | "underline" | "bordered";
36 color?: ColorVariant["color"];
37 className?: string;
38} & SelectionProps;
39
1// table.component.tsx
2import clsx from "clsx";
3import orderBy from "lodash/orderBy";
4import {
5 ChevronDownIcon,
6 ChevronsUpDownIcon,
7 ChevronUpIcon,
8} from "lucide-react";
9import { useEffect, useMemo, useState } from "react";
10import { ScrollArea } from "../scroll-area";
11import Checkbox from "./checkbox";
12import {
13 borderVariants,
14 headerVariants,
15 hoverRowVariants,
16 tableVariants,
17 unHoverRowVariants,
18} from "./table.helpers";
19import type { TableProps } from "./table.types";
20
21const Table: React.FC<TableProps> = ({
22 keyField,
23 header,
24 data,
25 stickyHeader,
26 variant = "outline",
27 selectionMode = "none",
28 color = "primary",
29 className,
30 selections = [],
31 onChangeSelections = () => {},
32}) => {
33 const [sortKey, setSortKey] = useState<{
34 [key: string]: "asc" | "desc" | "none";
35 }>({});
36
37 const sortedData = useMemo(() => {
38 const sortedKey = Object.keys(sortKey).reduce(
39 (acc: { [key: string]: "asc" | "desc" }, key) => {
40 if (sortKey[key] !== "none") acc[key] = sortKey[key];
41 return acc;
42 },
43 {},
44 );
45
46 return Object.keys(sortedKey).length
47 ? orderBy(data, Object.keys(sortedKey), Object.values(sortedKey))
48 : data;
49 }, [data, sortKey]);
50
51 useEffect(() => {
52 const sortKey: { [key: string]: "asc" | "desc" | "none" } = {};
53 header.forEach((item) => {
54 if (item.sorter) sortKey[item.key] = "none";
55 });
56 setSortKey(sortKey);
57 }, [header]);
58
59 const handleClickRow = (key: string) => {
60 if (selectionMode === "none") return;
61
62 if (selectionMode === "single") {
63 onChangeSelections([key]);
64 } else {
65 if (selections.includes(key)) {
66 onChangeSelections(selections.filter((item) => item !== key));
67 } else {
68 onChangeSelections([...selections, key]);
69 }
70 }
71 };
72
73 const handleClickAll = () => {
74 if (selections.length === data.length) onChangeSelections([]);
75 else onChangeSelections(data.map((item) => item[keyField] as string));
76 };
77
78 const handleSort = (key: string) => {
79 if (sortKey[key] === "none") {
80 setSortKey({ ...sortKey, [key]: "asc" });
81 } else if (sortKey[key] === "asc") {
82 setSortKey({ ...sortKey, [key]: "desc" });
83 } else {
84 setSortKey({ ...sortKey, [key]: "none" });
85 }
86 };
87
88 return (
89 <ScrollArea
90 className={clsx(
91 "rounded-md",
92 variant === "bordered" && ["border", borderVariants({ color })],
93 className,
94 )}
95 wrapperClassname="rounded-md"
96 >
97 <table className={clsx(tableVariants({ color }))}>
98 <thead>
99 <tr
100 className={clsx(headerVariants({ color }), {
101 "sticky top-0": stickyHeader,
102 })}
103 >
104 {selectionMode === "multiple" && (
105 <th>
106 <Checkbox
107 color={color}
108 type={
109 selections.length === data.length
110 ? "checked"
111 : selections.length
112 ? "indeterminate"
113 : "unchecked"
114 }
115 onClick={handleClickAll}
116 />
117 </th>
118 )}
119 {header.map((column) => (
120 <th
121 key={column.key}
122 style={{ width: column.width || "auto" }}
123 className={clsx(
124 "group px-3 py-2 text-left",
125 variant === "bordered"
126 ? "first:rounded-tl-md last:rounded-tr-md"
127 : "first:rounded-l-md last:rounded-r-md",
128 { "cursor-pointer": column.sorter },
129 )}
130 onClick={() => column.sorter && handleSort(column.key)}
131 >
132 <div className="flex items-center gap-2">
133 <span className="select-none">{column.label}</span>
134 {column.sorter && (
135 <span
136 className={clsx(
137 "invisible ml-1 group-hover:visible",
138 color === "light"
139 ? "text-neutral-700"
140 : "text-neutral-100",
141 )}
142 >
143 {sortKey[column.key] === "asc" ? (
144 <ChevronDownIcon className="h-4 w-4" />
145 ) : sortKey[column.key] === "desc" ? (
146 <ChevronUpIcon className="h-4 w-4" />
147 ) : (
148 <ChevronsUpDownIcon className="h-4 w-4" />
149 )}
150 </span>
151 )}
152 </div>
153 </th>
154 ))}
155 </tr>
156 </thead>
157 <tbody
158 className={clsx(
159 "text-sm before:block before:leading-[0.5rem] before:opacity-0 before:content-['\\200C']",
160 color === "dark" ? "text-neutral-200" : "text-neutral-600",
161 )}
162 >
163 {sortedData.map((item) => (
164 <tr
165 key={item[keyField] as string}
166 className={clsx(
167 variant !== "outline" && [
168 "[&:not(:last-child)]:border-b",
169 borderVariants({ color }),
170 ],
171 selectionMode !== "none" && [
172 "cursor-default",
173 selections.includes(item[keyField] as string)
174 ? unHoverRowVariants({ color })
175 : hoverRowVariants({ color }),
176 ],
177 )}
178 onClick={() => handleClickRow(item[keyField] as string)}
179 >
180 {selectionMode === "multiple" && (
181 <th>
182 <Checkbox
183 color={color}
184 type={
185 selections.includes(item[keyField] as string)
186 ? "checked"
187 : "unchecked"
188 }
189 />
190 </th>
191 )}
192 {header.map((column) => (
193 <td
194 key={column.key}
195 className={clsx("px-3 py-2 text-left", {
196 "first:rounded-l-md last:rounded-r-md":
197 variant === "outline" && selectionMode !== "multiple",
198 })}
199 >
200 {item[column.key]}
201 </td>
202 ))}
203 </tr>
204 ))}
205 </tbody>
206 </table>
207 </ScrollArea>
208 );
209};
210
211export default Table;
212