HR team directory
Employee directory with avatars, status badges, group by team / location / status.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Industry Templates). See the SvGrid documentation for the full API.
About this example
An HR employee directory in the Svelte 5 data grid: about 80 people with avatar and name, title, level, team, manager, location, start date, a derived tenure column, status and compensation. It groups by team, location or status through columnGroupingFeature with a default group-by, renders the avatar cell and status badge with renderSnippet, computes tenure with fieldFn rather than a stored field, and folds group rows to a summary line.
A directory of ~80 employees with the columns an HR app actually needs: person (avatar + name), title, level, team, manager, location, start date, tenure, status, comp.
Showcases:
columnGroupingFeaturewith a default group-by ("team")renderSnippetfor the avatar cell and status badge- A derived
tenurecolumn (nofield-fieldFn) - Sort by any column; group rows fold to a summary line
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature, columnGroupingFeature, rowExpandingFeature
SvGridApi methods called: api.setGroupBy()
Frequently asked questions
How is the tenure column computed without a field?
The column has an id and a fieldFn that returns the tenure from the start date, so it sorts and filters like any other column while nothing is stored on the row.
How do I set a default grouping?
Call api.setGroupBy(['team']) in onApiReady. The demo does this after the API arrives, and the toolbar buttons switch to location or status the same way.
How are the avatar and badge rendered?
Each is a snippet returned from the column's cell function with renderSnippet. The person column combines the avatar and the name; the status column maps the value to a coloured badge.
Source code (12-hr-team.svelte)
<script lang="ts">
/**
* 12. HR team directory
* ---------------------
* A directory of ~80 employees with the columns an HR app actually needs:
* person (avatar + name), title, level, team, manager, location, start
* date, tenure, status, comp.
*
* Showcases:
* - `columnGroupingFeature` with a default group-by ("team")
* - `renderSnippet` for the avatar cell and status badge
* - A derived `tenure` column (no `field` - `fieldFn`)
* - Sort by any column; group rows fold to a summary line
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
columnGroupingFeature,
rowExpandingFeature,
renderSnippet,
type ColumnDef,
type SvGridApi,
} from '@svgrid/grid'
const features = tableFeatures({
rowSortingFeature,
columnFilteringFeature,
columnGroupingFeature,
rowExpandingFeature,
})
type Status = 'Active' | 'On leave' | 'Contractor'
type Person = {
id: string
firstName: string
lastName: string
title: string
level: string
team: string
manager: string
location: string
startDate: string // ISO date
status: Status
salary: number
bonusPct: number
}
// ------------------------------------------------------------------ data
const TEAMS = [
'Engineering', 'Design', 'Product', 'Data', 'Marketing',
'Sales', 'Support', 'Operations', 'Finance', 'People',
] as const
type Team = typeof TEAMS[number]
const TITLES: Record<Team, string[]> = {
Engineering: ['Software Engineer', 'Senior Engineer', 'Staff Engineer', 'Engineering Manager', 'Director of Engineering'],
Design: ['Product Designer', 'Senior Designer', 'Design Lead', 'Director of Design'],
Product: ['Associate PM', 'Product Manager', 'Senior PM', 'Group PM', 'Director of Product'],
Data: ['Data Analyst', 'Data Scientist', 'ML Engineer', 'Analytics Manager'],
Marketing: ['Marketing Specialist', 'Growth Manager', 'Brand Lead', 'CMO'],
Sales: ['SDR', 'AE', 'Senior AE', 'Sales Manager', 'VP Sales'],
Support: ['Support Engineer', 'Senior Support', 'Support Lead'],
Operations: ['Ops Analyst', 'Operations Manager', 'COO'],
Finance: ['Accountant', 'Finance Analyst', 'Controller', 'CFO'],
People: ['HR Generalist', 'Recruiter', 'People Partner', 'CHRO'],
}
const LEVELS = ['IC2', 'IC3', 'IC4', 'IC5', 'IC6', 'M2', 'M3', 'M4', 'M5']
const LOCATIONS = [
'San Francisco, US', 'New York, US', 'Austin, US', 'London, UK', 'Berlin, DE',
'Amsterdam, NL', 'Toronto, CA', 'Sydney, AU', 'Tokyo, JP', 'Singapore, SG',
'Remote - EMEA', 'Remote - Americas',
]
const FIRST_NAMES = [
'Aiden', 'Bea', 'Cara', 'Dev', 'Elena', 'Felix', 'Grace', 'Hannah', 'Ivan',
'Jules', 'Kai', 'Lena', 'Marco', 'Nina', 'Omar', 'Priya', 'Quinn', 'Rosa',
'Sven', 'Tess', 'Uma', 'Victor', 'Wren', 'Xio', 'Yael', 'Zane', 'Anika',
'Beck', 'Cyrus', 'Dara', 'Eitan', 'Fiona', 'Gus', 'Henri', 'Ines', 'Jonas',
'Kira', 'Luca', 'Mara', 'Nico', 'Olin', 'Pia', 'Quentin', 'Reyna', 'Sami',
]
const LAST_NAMES = [
'Ortega', 'Nakamura', 'Schmidt', 'Hassan', 'Petrov', 'Yamada', 'Hughes',
'Marchetti', 'Anand', 'Lefevre', 'Olsen', 'Carmona', 'Tan', 'Beck',
'Mustafa', 'Andersson', 'Vogel', 'Romero', 'Kowalski', 'Mendoza',
'Sokolova', 'Voss', 'Holm', 'Park', 'Khoury', 'Bauer', 'Russo',
]
// Seeded PRNG so the demo is reproducible across reloads.
let prngState = 0x9E3779B1
function rand(): number {
prngState = (prngState * 1664525 + 1013904223) >>> 0
return prngState / 0xFFFFFFFF
}
function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }
function range(lo: number, hi: number): number { return lo + Math.floor(rand() * (hi - lo + 1)) }
function isoDate(daysAgo: number): string {
const ms = Date.now() - daysAgo * 86_400_000
return new Date(ms).toISOString().slice(0, 10)
}
function makePeople(): Person[] {
const out: Person[] = []
// 1. Pick one manager per team first so other rows can reference them.
const managerByTeam: Record<string, string> = {}
for (const team of TEAMS) {
const first = pick(FIRST_NAMES)
const last = pick(LAST_NAMES)
const titles = TITLES[team]
const title = titles[titles.length - 1]! // most senior title for the team's head
out.push({
id: `E${(out.length + 1).toString().padStart(3, '0')}`,
firstName: first,
lastName: last,
title,
level: pick(['M3', 'M4', 'M5']),
team,
manager: 'Sasha Lin (CEO)',
location: pick(LOCATIONS),
startDate: isoDate(range(1500, 4200)),
status: 'Active',
salary: range(180, 360) * 1000,
bonusPct: range(15, 30),
})
managerByTeam[team] = `${first} ${last}`
}
// 2. Fill out 6–10 ICs per team reporting to that team's head.
for (const team of TEAMS) {
const headcount = range(6, 10)
const icTitles = TITLES[team].slice(0, -1) // drop the manager title
for (let i = 0; i < headcount; i += 1) {
out.push({
id: `E${(out.length + 1).toString().padStart(3, '0')}`,
firstName: pick(FIRST_NAMES),
lastName: pick(LAST_NAMES),
title: pick(icTitles),
level: pick(LEVELS.slice(0, 6)), // IC-only levels
team,
manager: managerByTeam[team]!,
location: pick(LOCATIONS),
startDate: isoDate(range(60, 2400)),
status: pick<Status>(['Active', 'Active', 'Active', 'Active', 'On leave', 'Contractor']),
salary: range(85, 220) * 1000,
bonusPct: range(5, 18),
})
}
}
return out
}
const rows = makePeople()
// Tenure in years, derived from startDate; used by an `fieldFn` column.
function tenureYears(p: Person): number {
const start = new Date(p.startDate).getTime()
const now = Date.now()
return Math.round(((now - start) / (365.25 * 86_400_000)) * 10) / 10
}
function initials(p: Person): string {
return `${p.firstName[0] ?? ''}${p.lastName[0] ?? ''}`.toUpperCase()
}
// Stable avatar tint per team - keeps the directory feeling like one company
// not a rainbow soup.
const TEAM_COLORS: Record<string, string> = {
Engineering: '#2563eb', Design: '#db2777', Product: '#7c3aed',
Data: '#0891b2', Marketing: '#db2516', Sales: '#16a34a',
Support: '#ca8a04', Operations: '#9333ea', Finance: '#0d9488',
People: '#e11d48',
}
let api = $state<SvGridApi<typeof features, Person> | null>(null)
let groupBy = $state<string[]>(['team'])
function applyGroup(by: string[]) {
groupBy = by
api?.setGroupBy(by)
}
</script>
{#snippet PersonCell(props: { row: Person })}
<span class="hr-person">
<span class="hr-avatar" style="background:{TEAM_COLORS[props.row.team] ?? '#475569'}">
{initials(props.row)}
</span>
<span class="hr-person-text">
<span class="hr-name">{props.row.firstName} {props.row.lastName}</span>
<span class="hr-id">{props.row.id}</span>
</span>
</span>
{/snippet}
{#snippet StatusBadge(props: { value: Status })}
<span class="hr-badge hr-status-{props.value.replace(/\s+/g, '').toLowerCase()}">{props.value}</span>
{/snippet}
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="flex flex-wrap items-center gap-2 text-sm shrink-0">
<span class="font-medium">Group by:</span>
<button
onclick={() => applyGroup([])}
class="rounded border px-3 py-1 hr-gbtn {groupBy.length === 0 ? 'hr-gbtn-on' : ''}"
>None</button>
<button
onclick={() => applyGroup(['team'])}
class="rounded border px-3 py-1 hr-gbtn {groupBy.join() === 'team' ? 'hr-gbtn-on' : ''}"
>Team</button>
<button
onclick={() => applyGroup(['location'])}
class="rounded border px-3 py-1 hr-gbtn {groupBy.join() === 'location' ? 'hr-gbtn-on' : ''}"
>Location</button>
<button
onclick={() => applyGroup(['status'])}
class="rounded border px-3 py-1 hr-gbtn {groupBy.join() === 'status' ? 'hr-gbtn-on' : ''}"
>Status</button>
<span class="ml-auto hr-meta">{rows.length} employees</span>
</div>
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
columnResize
data={rows}
columns={[
{
id: 'person',
header: 'Person',
fieldFn: (row) => `${row.firstName} ${row.lastName}`,
cell: (ctx) => renderSnippet(PersonCell, { row: ctx.row.original }),
width: 240,
},
{ field: 'title', header: 'Title', editorType: 'text', width: 200 },
{ field: 'level', header: 'Level', editorType: 'text', width: 80 },
{ field: 'team', header: 'Team', editorType: 'text', width: 130 },
{ field: 'manager', header: 'Manager', editorType: 'text', width: 180 },
{ field: 'location', header: 'Location', editorType: 'text', width: 180 },
{
field: 'startDate', header: 'Start date', editorType: 'date', width: 120,
format: { type: 'date', pattern: 'y-m-d' },
},
{
id: 'tenure', header: 'Tenure', editorType: 'number',
fieldFn: (row) => tenureYears(row),
width: 100,
cell: (ctx) => `${(ctx.getValue() as number).toFixed(1)}y`,
},
{
field: 'status', header: 'Status', editorType: 'text', width: 120,
cell: (ctx) => renderSnippet(StatusBadge, { value: ctx.row.original.status }),
},
{
field: 'salary', header: 'Salary', editorType: 'number', width: 120,
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },
},
{
field: 'bonusPct', header: 'Bonus %', editorType: 'number', width: 100,
cell: (ctx) => `${ctx.row.original.bonusPct}%`,
},
] satisfies ColumnDef<typeof features, Person>[]}
features={features}
filterMode="menu"
selectionMode="cell"
showRowNumbers={true}
enableInlineEditing={false}
enableCellSelection={true}
enableRowSummaries={true}
rowHeight={44}
containerHeight="100%"
onApiReady={(next) => {
api = next
next.setGroupBy(groupBy)
// Expand the first group on mount so the demo lands on data,
// not on a row of collapsed group headers.
queueMicrotask(() => next.setRowExpanded(`team:${TEAMS[0]}`, true))
}}
/>
</div>
</section>
<style>
/* Toolbar chrome follows the active grid theme via --sg-* tokens. */
.hr-gbtn {
border-color: var(--sg-border, #cbd5e1);
background: var(--sg-bg, transparent);
color: var(--sg-fg, inherit);
}
.hr-gbtn:not(.hr-gbtn-on):hover { background: var(--sg-row-hover-bg, transparent); }
.hr-gbtn-on { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); }
.hr-meta { color: var(--sg-muted, #64748b); }
.hr-person {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.hr-avatar {
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 50%;
color: #fff;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
letter-spacing: 0.5px;
}
.hr-person-text {
display: inline-flex;
flex-direction: column;
line-height: 1.15;
min-width: 0;
}
.hr-name { font-weight: 600; }
.hr-id { font-size: 11px; color: var(--sg-muted, #94a3b8); }
.hr-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
}
.hr-status-active { background: #dcfce7; color: #166534; }
.hr-status-onleave { background: #fef3c7; color: #92400e; }
.hr-status-contractor { background: #e0e7ff; color: #4338ca; }
:global([data-theme='dark']) .hr-status-active { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
:global([data-theme='dark']) .hr-status-onleave { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }
:global([data-theme='dark']) .hr-status-contractor { background: rgba(99, 102, 241, 0.18); color: #a5b4fc; }
</style>More Industry Templates examples
- Finances - ledger - Account ledger with running balance, currency formatting, category chips, paging.
- Manufacturing operations - Plant-floor view: KPI cards + active-runs grid with progress bars, status pills, live 5-second tick.
- Permissions, audit & history - Role-based cell editing, PII masking, full audit log with one-click revert, per-cell history popover.
- Print + boardroom export - Quarterly P&L print pack: cover page, repeat-on-page headers, page-size + orientation, CSV / HTML download, browser-native PDF.
- Healthcare EMR - inpatient - ICU census with vitals sparkline, risk score, code status, allergy chips, role-based cell editing (viewer / nurse / physician / admin).