Shadcn CRM Employees Table
A data-dense employee directory of the kind found in a CRM or HR tool — stacked name/email, role and department, status badges, performance bars, tenure and salary — with search, department and status filters, sorting, row selection, pagination and a row density toggle. Pick how the filters behave.
Department and status are single selects: pick one value, or All. The simplest setup, and a good fit when each filter has only a handful of options.
$ npx shadcn add https://www.shad-table.dev/r/crm-employees-table.jsonAmara Dubois amara.dubois@acme.co | Product Designer Design | On leave | London, UK | Tomás Vidal | Jan 1, 2016 9y 8m | $119,600 | ||
Liam Kowalski liam.kowalski@acme.co | Controller Finance | Active | San Francisco, US | Mei Lin | Aug 12, 2021 4y 1m | $100,970 | ||
Noor Rossi noor.rossi@acme.co | Backend Engineer Engineering | Active | Toronto, CA | Priya Raman | Mar 23, 2017 8y 6m | $139,940 | ||
Mateo Adeyemi mateo.adeyemi@acme.co | Content Strategist Marketing | Active | Berlin, DE | Idris Okafor | Oct 7, 2022 2y 11m | $93,310 | ||
Yuki Haddad yuki.haddad@acme.co | Customer Success Manager Support | Remote | New York, US | Daniel Osei | May 18, 2018 7y 4m | $86,280 | ||
Chloe Oliveira chloe.oliveira@acme.co | Sales Director Sales | Active | Singapore, SG | Hannah Becker | Dec 2, 2023 1y 9m | $97,650 | ||
Omar Varga omar.varga@acme.co | Recruiter People Ops | Active | Lisbon, PT | Sofia Marchetti | Jul 13, 2019 6y 2m | $100,620 | ||
Elena Ellison elena.ellison@acme.co | Design Lead Design | Probation | London, UK | Tomás Vidal | Feb 24, 2024 1y 7m | $100,990 | ||
Kofi Lindqvist kofi.lindqvist@acme.co | Payroll Specialist Finance | Remote | San Francisco, US | Mei Lin | Sep 8, 2020 5y 0m | $103,960 | ||
Isla Sato isla.sato@acme.co | Frontend Engineer Engineering | Active | Toronto, CA | Priya Raman | Apr 19, 2016 9y 5m | $142,930 |
Showing 1–10 of 48 employees
How it works
1.Two lines of text in one cell, not two columns
A CRM row carries a lot of fields, and giving each its own column makes the table wider than any screen. Related fields share a cell instead: the Employee column stacks name over email beside an initials avatar, Role stacks title over a department dot, and Hired stacks the date over a tenure. Each column stays individually sortable because it is still backed by one accessor.
{accessorKey: 'role',header: 'Role',cell: ({ row }) => (<div><p>{row.original.role}</p><p className="flex items-center gap-1.5 text-xs text-muted-foreground"><span className={cn('size-1.5 rounded-full', departmentDot[row.original.department])} />{row.original.department}</p></div>),}
2.A hidden column can still be a filter
Department is already shown under Role, so repeating it as its own column would waste a full column of width. It stays in the column definitions anyway and is hidden through columnVisibility, which keeps table.getColumn('department') available for the toolbar's select. Both selects use equalsString so filtering on 'Design' can never match a longer value that merely contains it.
initialState: {pagination: { pageSize: 10 },columnVisibility: { department: false },},
3.Search the fields people actually type
The built-in global filter only checks columns whose value is a string, so it would miss the email (which isn't a column) and stop matching the moment a column is hidden. A custom globalFilterFn reads straight from row.original and checks name, email, role, department and location, which is what makes 'berlin' or an email fragment find the right person.
const employeeSearch: FilterFn<Employee> = (row, _columnId, value) => {const query = String(value).trim().toLowerCase()if (!query) return trueconst { name, email, role, department, location } = row.originalreturn [name, email, role, department, location].some((field) =>field.toLowerCase().includes(query),)}
4.Format once, align numbers right
Salary and dates go through module-level Intl formatters instead of being rebuilt in every cell render, and the date formatter is pinned to UTC so a server render and a browser in another timezone cannot disagree about the day. Salary is right-aligned with tabular-nums so the digits line up down the column, and the header is right-aligned to match.
const currency = new Intl.NumberFormat('en-US', {style: 'currency',currency: 'USD',maximumFractionDigits: 0,})<div className="text-right font-medium tabular-nums">{currency.format(row.original.salary)}</div>