Search

Ctrl + K

Table

This document outlines the steps to create Table 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 ScrollArea component
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
Step 2: Create Checkbox component
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
Step 3: Create helper functions
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
Step 4: Create TypeScript types
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
Step 5: Create Table component
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