├── .gitignore
├── LICENSE
├── README.md
├── craco.config.js
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App.js
├── App.test.js
├── Table.js
├── index.css
├── index.js
├── reportWebVitals.js
├── setupTests.js
└── shared
│ ├── Button.js
│ ├── Icons.js
│ └── Utils.js
└── tailwind.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The BSD Zero Clause License (0BSD)
2 |
3 | Copyright (c) 2021 Samuel Liedtke
4 |
5 | Permission to use, copy, modify, and/or distribute this software for any
6 | purpose with or without fee is hereby granted.
7 |
8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
9 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
10 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
11 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
12 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
13 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
14 | PERFORMANCE OF THIS SOFTWARE.
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Table + Tailwind CSS = ❤️
2 |
3 | Source code for my tutorial on how to build customizable table component with React Table and Tailwind CSS.
4 |
5 | Both parts of the tutorial can be found on my [blog](https://www.samuelliedtke.com/):
6 |
7 | - [Part 1](https://www.samuelliedtke.com/blog/react-table-tutorial-part-1/): Build a fully featured table component step by step
8 | - [Part 2](https://www.samuelliedtke.com/blog/react-table-tutorial-part-2/): Style the table with Tailwind CSS
9 |
10 | Here is how the table component looks like:
11 |
12 | 
13 |
14 | ## 📖 Installation
15 | ```shell
16 | $ npm install
17 | $ npm start
18 | # Load the site at http://127.0.0.1:3000
19 | ```
20 |
21 | ## 🤝 Contributing
22 | Contributions, issues and feature requests are welcome!
23 |
24 | ## ⭐️ Support
25 | Give a ⭐️ if this project helped you!
26 |
--------------------------------------------------------------------------------
/craco.config.js:
--------------------------------------------------------------------------------
1 | // craco.config.js
2 | module.exports = {
3 | style: {
4 | postcss: {
5 | plugins: [
6 | require('tailwindcss'),
7 | require('autoprefixer'),
8 | ],
9 | },
10 | },
11 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-tailwind-table",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@craco/craco": "^6.1.2",
7 | "@heroicons/react": "^1.0.1",
8 | "@tailwindcss/forms": "^0.3.3",
9 | "@testing-library/jest-dom": "^5.14.1",
10 | "@testing-library/react": "^11.2.7",
11 | "@testing-library/user-event": "^12.8.3",
12 | "react": "^17.0.2",
13 | "react-dom": "^17.0.2",
14 | "react-scripts": "4.0.3",
15 | "react-table": "^7.7.0",
16 | "web-vitals": "^1.1.2"
17 | },
18 | "scripts": {
19 | "start": "craco start",
20 | "build": "craco build",
21 | "test": "craco test",
22 | "eject": "react-scripts eject"
23 | },
24 | "eslintConfig": {
25 | "extends": [
26 | "react-app",
27 | "react-app/jest"
28 | ]
29 | },
30 | "browserslist": {
31 | "production": [
32 | ">0.2%",
33 | "not dead",
34 | "not op_mini all"
35 | ],
36 | "development": [
37 | "last 1 chrome version",
38 | "last 1 firefox version",
39 | "last 1 safari version"
40 | ]
41 | },
42 | "devDependencies": {
43 | "autoprefixer": "^9.8.6",
44 | "postcss": "^7.0.36",
45 | "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jimmybutton/react-tailwind-table/b65ad5a97447094644390c88f97ddf2ade620610/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 | You need to enable JavaScript to run this app.
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jimmybutton/react-tailwind-table/b65ad5a97447094644390c88f97ddf2ade620610/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jimmybutton/react-tailwind-table/b65ad5a97447094644390c88f97ddf2ade620610/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Table, { AvatarCell, SelectColumnFilter, StatusPill } from './Table' // new
3 |
4 | const getData = () => {
5 | const data = [
6 | {
7 | name: 'Jane Cooper',
8 | email: 'jane.cooper@example.com',
9 | title: 'Regional Paradigm Technician',
10 | department: 'Optimization',
11 | status: 'Active',
12 | role: 'Admin',
13 | age: 27,
14 | imgUrl: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=4&w=256&h=256&q=60',
15 | },
16 | {
17 | name: 'Cody Fisher',
18 | email: 'cody.fisher@example.com',
19 | title: 'Product Directives Officer',
20 | department: 'Intranet',
21 | status: 'Inactive',
22 | role: 'Owner',
23 | age: 43,
24 | imgUrl: 'https://images.unsplash.com/photo-1570295999919-56ceb5ecca61?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=4&w=256&h=256&q=60',
25 | },
26 | {
27 | name: 'Esther Howard',
28 | email: 'esther.howard@example.com',
29 | title: 'Forward Response Developer',
30 | department: 'Directives',
31 | status: 'Active',
32 | role: 'Member',
33 | age: 32,
34 | imgUrl: 'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=4&w=256&h=256&q=60',
35 | },
36 | {
37 | name: 'Jenny Wilson',
38 | email: 'jenny.wilson@example.com',
39 | title: 'Central Security Manager',
40 | department: 'Program',
41 | status: 'Offline',
42 | role: 'Member',
43 | age: 29,
44 | imgUrl: 'https://images.unsplash.com/photo-1498551172505-8ee7ad69f235?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=4&w=256&h=256&q=60',
45 | },
46 | {
47 | name: 'Kristin Watson',
48 | email: 'kristin.watson@example.com',
49 | title: 'Lean Implementation Liaison',
50 | department: 'Mobility',
51 | status: 'Inactive',
52 | role: 'Admin',
53 | age: 36,
54 | imgUrl: 'https://images.unsplash.com/photo-1532417344469-368f9ae6d187?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=4&w=256&h=256&q=60',
55 | },
56 | {
57 | name: 'Cameron Williamson',
58 | email: 'cameron.williamson@example.com',
59 | title: 'Internal Applications Engineer',
60 | department: 'Security',
61 | status: 'Active',
62 | role: 'Member',
63 | age: 24,
64 | imgUrl: 'https://images.unsplash.com/photo-1566492031773-4f4e44671857?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=4&w=256&h=256&q=60',
65 | },
66 | ]
67 | return [...data, ...data, ...data]
68 | }
69 |
70 | function App() {
71 |
72 | const columns = React.useMemo(() => [
73 | {
74 | Header: "Name",
75 | accessor: 'name',
76 | Cell: AvatarCell,
77 | imgAccessor: "imgUrl",
78 | emailAccessor: "email",
79 | },
80 | {
81 | Header: "Title",
82 | accessor: 'title',
83 | },
84 | {
85 | Header: "Status",
86 | accessor: 'status',
87 | Cell: StatusPill,
88 | },
89 | {
90 | Header: "Age",
91 | accessor: 'age',
92 | },
93 | {
94 | Header: "Role",
95 | accessor: 'role',
96 | Filter: SelectColumnFilter, // new
97 | filter: 'includes',
98 | },
99 | ], [])
100 |
101 | const data = React.useMemo(() => getData(), [])
102 |
103 | return (
104 |
105 |
106 |
107 |
React Table + Tailwind CSS = ❤
108 |
109 |
112 |
113 |
114 | );
115 | }
116 |
117 | export default App;
118 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | test('renders learn react link', () => {
5 | render( );
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/src/Table.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTable, useFilters, useGlobalFilter, useAsyncDebounce, useSortBy, usePagination } from 'react-table'
3 | import { ChevronDoubleLeftIcon, ChevronLeftIcon, ChevronRightIcon, ChevronDoubleRightIcon } from '@heroicons/react/solid'
4 | import { Button, PageButton } from './shared/Button'
5 | import { classNames } from './shared/Utils'
6 | import { SortIcon, SortUpIcon, SortDownIcon } from './shared/Icons'
7 |
8 | // Define a default UI for filtering
9 | function GlobalFilter({
10 | preGlobalFilteredRows,
11 | globalFilter,
12 | setGlobalFilter,
13 | }) {
14 | const count = preGlobalFilteredRows.length
15 | const [value, setValue] = React.useState(globalFilter)
16 | const onChange = useAsyncDebounce(value => {
17 | setGlobalFilter(value || undefined)
18 | }, 200)
19 |
20 | return (
21 |
22 | Search:
23 | {
28 | setValue(e.target.value);
29 | onChange(e.target.value);
30 | }}
31 | placeholder={`${count} records...`}
32 | />
33 |
34 | )
35 | }
36 |
37 | // This is a custom filter UI for selecting
38 | // a unique option from a list
39 | export function SelectColumnFilter({
40 | column: { filterValue, setFilter, preFilteredRows, id, render },
41 | }) {
42 | // Calculate the options for filtering
43 | // using the preFilteredRows
44 | const options = React.useMemo(() => {
45 | const options = new Set()
46 | preFilteredRows.forEach(row => {
47 | options.add(row.values[id])
48 | })
49 | return [...options.values()]
50 | }, [id, preFilteredRows])
51 |
52 | // Render a multi-select box
53 | return (
54 |
55 | {render("Header")}:
56 | {
62 | setFilter(e.target.value || undefined)
63 | }}
64 | >
65 | All
66 | {options.map((option, i) => (
67 |
68 | {option}
69 |
70 | ))}
71 |
72 |
73 | )
74 | }
75 |
76 | export function StatusPill({ value }) {
77 | const status = value ? value.toLowerCase() : "unknown";
78 |
79 | return (
80 |
90 | {status}
91 |
92 | );
93 | };
94 |
95 | export function AvatarCell({ value, column, row }) {
96 | return (
97 |
98 |
99 |
100 |
101 |
102 |
{value}
103 |
{row.original[column.emailAccessor]}
104 |
105 |
106 | )
107 | }
108 |
109 | function Table({ columns, data }) {
110 | // Use the state and functions returned from useTable to build your UI
111 | const {
112 | getTableProps,
113 | getTableBodyProps,
114 | headerGroups,
115 | prepareRow,
116 | page, // Instead of using 'rows', we'll use page,
117 | // which has only the rows for the active page
118 |
119 | // The rest of these things are super handy, too ;)
120 | canPreviousPage,
121 | canNextPage,
122 | pageOptions,
123 | pageCount,
124 | gotoPage,
125 | nextPage,
126 | previousPage,
127 | setPageSize,
128 |
129 | state,
130 | preGlobalFilteredRows,
131 | setGlobalFilter,
132 | } = useTable({
133 | columns,
134 | data,
135 | },
136 | useFilters, // useFilters!
137 | useGlobalFilter,
138 | useSortBy,
139 | usePagination, // new
140 | )
141 |
142 | // Render the UI for your table
143 | return (
144 | <>
145 |
146 |
151 | {headerGroups.map((headerGroup) =>
152 | headerGroup.headers.map((column) =>
153 | column.Filter ? (
154 |
155 | {column.render("Filter")}
156 |
157 | ) : null
158 | )
159 | )}
160 |
161 | {/* table */}
162 |
163 |
164 |
165 |
166 |
167 |
168 | {headerGroups.map(headerGroup => (
169 |
170 | {headerGroup.headers.map(column => (
171 | // Add the sorting props to control sorting. For this example
172 | // we can add them into the header props
173 |
178 |
179 | {column.render('Header')}
180 | {/* Add a sort direction indicator */}
181 |
182 | {column.isSorted
183 | ? column.isSortedDesc
184 | ?
185 | :
186 | : (
187 |
188 | )}
189 |
190 |
191 |
192 | ))}
193 |
194 | ))}
195 |
196 |
200 | {page.map((row, i) => { // new
201 | prepareRow(row)
202 | return (
203 |
204 | {row.cells.map(cell => {
205 | return (
206 |
211 | {cell.column.Cell.name === "defaultRenderer"
212 | ? {cell.render('Cell')}
213 | : cell.render('Cell')
214 | }
215 |
216 | )
217 | })}
218 |
219 | )
220 | })}
221 |
222 |
223 |
224 |
225 |
226 |
227 | {/* Pagination */}
228 |
229 |
230 | previousPage()} disabled={!canPreviousPage}>Previous
231 | nextPage()} disabled={!canNextPage}>Next
232 |
233 |
234 |
235 |
236 | Page {state.pageIndex + 1} of {pageOptions.length}
237 |
238 |
239 | Items Per Page
240 | {
244 | setPageSize(Number(e.target.value))
245 | }}
246 | >
247 | {[5, 10, 20].map(pageSize => (
248 |
249 | Show {pageSize}
250 |
251 | ))}
252 |
253 |
254 |
255 |
256 |
257 | gotoPage(0)}
260 | disabled={!canPreviousPage}
261 | >
262 | First
263 |
264 |
265 | previousPage()}
267 | disabled={!canPreviousPage}
268 | >
269 | Previous
270 |
271 |
272 | nextPage()}
274 | disabled={!canNextPage
275 | }>
276 | Next
277 |
278 |
279 | gotoPage(pageCount - 1)}
282 | disabled={!canNextPage}
283 | >
284 | Last
285 |
286 |
287 |
288 |
289 |
290 |
291 | >
292 | )
293 | }
294 |
295 | export default Table;
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById('root')
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/shared/Button.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { classNames } from './Utils'
3 |
4 | export function Button({ children, className, ...rest }) {
5 | return (
6 |
15 | {children}
16 |
17 | )
18 | }
19 |
20 | export function PageButton({ children, className, ...rest }) {
21 | return (
22 |
31 | {children}
32 |
33 | )
34 | }
35 |
36 |
--------------------------------------------------------------------------------
/src/shared/Icons.js:
--------------------------------------------------------------------------------
1 | export function SortIcon({ className }) {
2 | return (
3 |
4 | )
5 | }
6 |
7 | export function SortUpIcon({ className }) {
8 | return (
9 |
10 | )
11 | }
12 |
13 | export function SortDownIcon({ className }) {
14 | return (
15 |
16 | )
17 | }
--------------------------------------------------------------------------------
/src/shared/Utils.js:
--------------------------------------------------------------------------------
1 | export function classNames(...classes) {
2 | return classes.filter(Boolean).join(" ");
3 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | purge: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'],
3 | darkMode: false, // or 'media' or 'class'
4 | theme: {
5 | extend: {},
6 | },
7 | variants: {
8 | extend: {},
9 | },
10 | plugins: [
11 | require('@tailwindcss/forms'),
12 | ],
13 | }
14 |
--------------------------------------------------------------------------------