Search docs

Jump to any table example

Discord—

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.json

Amara Dubois

amara.dubois@acme.co

Product Designer

Design

On leaveLondon, UKTomás Vidal

Jan 1, 2016

9y 8m

$119,600

Liam Kowalski

liam.kowalski@acme.co

Controller

Finance

ActiveSan Francisco, USMei Lin

Aug 12, 2021

4y 1m

$100,970

Noor Rossi

noor.rossi@acme.co

Backend Engineer

Engineering

ActiveToronto, CAPriya Raman

Mar 23, 2017

8y 6m

$139,940

Mateo Adeyemi

mateo.adeyemi@acme.co

Content Strategist

Marketing

ActiveBerlin, DEIdris Okafor

Oct 7, 2022

2y 11m

$93,310

Yuki Haddad

yuki.haddad@acme.co

Customer Success Manager

Support

RemoteNew York, USDaniel Osei

May 18, 2018

7y 4m

$86,280

Chloe Oliveira

chloe.oliveira@acme.co

Sales Director

Sales

ActiveSingapore, SGHannah Becker

Dec 2, 2023

1y 9m

$97,650

Omar Varga

omar.varga@acme.co

Recruiter

People Ops

ActiveLisbon, PTSofia Marchetti

Jul 13, 2019

6y 2m

$100,620

Elena Ellison

elena.ellison@acme.co

Design Lead

Design

ProbationLondon, UKTomás Vidal

Feb 24, 2024

1y 7m

$100,990

Kofi Lindqvist

kofi.lindqvist@acme.co

Payroll Specialist

Finance

RemoteSan Francisco, USMei Lin

Sep 8, 2020

5y 0m

$103,960

Isla Sato

isla.sato@acme.co

Frontend Engineer

Engineering

ActiveToronto, CAPriya Raman

Apr 19, 2016

9y 5m

$142,930

Showing 1–10 of 48 employees

Page 1 of 5

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.

src/components/crm-employees/columns.tsx
{
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.

src/components/crm-employees/data-table.tsx
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.

src/components/crm-employees/data-table.tsx
const employeeSearch: FilterFn<Employee> = (row, _columnId, value) => {
const query = String(value).trim().toLowerCase()
if (!query) return true
const { name, email, role, department, location } = row.original
return [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.

src/components/crm-employees/columns.tsx
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>