HR & People

Nine screens from a people-management app — a manager dashboard, the employee directory and the profile behind a row, a recruiting board with its interview scorecard, the steps a new hire’s record goes through, the leave queue, an attendance week and a payroll run. Almost none of it is new markup: it is the composites wired together, and the wiring is the part worth copying.

Team overview

Four StatCards over the same dataset the directory below reads. Note the third one: more open leave requests is bad news, so its delta declares polarity: 'positive-down' and the upward arrow renders red. Colouring by arrow direction alone would have the dashboard congratulating a manager on a growing backlog.

Headcount

14

+2vs. last quarter

Working today

13

1 on leave · 2 onboarding

Leave to review

4

+2since Monday

Monthly payroll

$54,200

+4.1%vs. August

Runs on the 25th

Employee directory

Ten fields a person, and the questions HR asks combine them — who in Engineering started after 2022, who reports to Trần Quốc Bảo and is still onboarding — so the directory is a sheet rather than a list: DataGrid. The Filter menu joins conditions with and, the Sort menu stacks keys (department, then start date), and the quick filters above the grid write the very conditions the Filter menu lists: the status FilterChips set “Has any of” on Status, the department Combobox sets “Is” on Department. Their counts come from TanStack’s faceting, which counts under every filter except a column’s own, so each one answers “how many will I get”. To look a person up, add “Contains” on Employee — or press Ctrl+F to find text anywhere in the grid. Drag across cells and Ctrl+C copies the range as tab-separated text that a spreadsheet pastes as rows. Tick people and an ActionBar floats up to start their offboarding; move to a row — click it, or arrow to it — and the profile below follows.

14 of 14 people

The grid is readOnly. A directory is read, filtered and exported; a person’s title, team or manager changes through a form and an approval — the onboarding steps further down are one — not by typing over a cell in passing. So no cell opens an editor, and paste, cut and the add-row line are off, while sorting, filtering, pinning, find and copy all keep working. The checkboxes stay, because offboarding works on a selection: the select column is getDataGridSelectColumn({ enableRowMarkers: true }), where { readOnly: true } would leave plain row numbers. There is no pager and no search box: the grid virtualises its rows inside a box of fixed height, a person is found from the Filter menu or with Ctrl+F, and the View menu is the Columns toggle — Leave balance starts hidden there. The filters and the sort are the app’s state (state plus onColumnFiltersChange and onSortingChange), which is what lets the chips write them — and what would let a directory too big for the browser hand them to a server with manualFiltering and manualSorting.

Vietnamese names need nothing extra. The grid sorts text and select columns in the alphabet of its own language, so Đặng and Đỗ come before Vũ and Ngô before Nguyễn; its text operators and Ctrl+F fold marks and case on both sides, so “Contains duc thanh” finds Nguyễn Đức Thành — the same match as the package’s Combobox (both since 3.1.0). The one column with its own sortingFn is Status, which sorts in lifecycle order, read off EMPLOYMENT_STATUSES.

There is no row menu — a cell is one of the grid’s nine kinds, not a place for buttons — so one person is offboarded the way several are: tick, then Start offboarding on the bar. The bar follows the grid’s selection — open while rows are ticked, onOpenChange(false) calls table.resetRowSelection() — and is portalled into the directory’s own element through portalContainer, so Tab reaches it right after the grid, and anchor="container" centres it on the directory rather than the window. Nothing on the way up sets contain or a transform: the grid anchors its right-click menu with position: fixed too, and a containing block there would pull the menu away from the pointer — and the anchored bar off the directory’s centre. Start offboarding calls preventDefault() in onSelect to keep the bar and the selection while a ConfirmDialog asks — by name, with the leave to be paid out, when it is one person. The dialog is not inside the bar, and it stays mounted — only open moves. The bar unmounts the moment the selection clears, and an alert dialog unmounted in the same commit that confirms it never gets its open→closed transition: the pointer-events: none Radix puts on <body> is never lifted, and the page quietly stops accepting clicks. Staying mounted is also why the people it asks about are captured when it opens rather than read live: confirming changes the grid underneath while the dialog is still fading out on top of it. The result — “2 people moved to notice period.” — goes to a role="status" line, because the bar has gone by then.

Employee profile

What the directory’s focused row opens into — everything a grid cell cannot draw: LetterAvatar and StatusPill in the header, the facts HR looks up most, and the employment history as a Timeline. The history is derived from the record, never stored beside it: tick this person in the directory and start their offboarding from the bar, and the timeline gains a notice period and a last working day. Move to another row in the grid — click it, or arrow to it — and the panel follows.

Nguyễn Đức Thành

Active

Senior Frontend Engineer · Engineering

CMT-004 · Hà Nội

Reports to
Trần Quốc Bảo
Start date
Jun 13, 2022
Leave balance
8 days

Employment history

Joined as Frontend Engineer
Engineering, reporting to Trần Quốc Bảo.
Passed probation
Signed off by the manager of record.
Promoted to Senior Frontend Engineer
From Frontend Engineer.
Annual review
With the manager of record.

activeIndex is what makes this a track rather than a list. Entries before it render as completed — a dot and a line in the ink step, which in the default palette is the gold fill itself, 1.86:1 on white — the active one carries aria-current="step", and the ones after it stay pending: the review, the last working day, whatever is booked. The package draws active and completed alike, so the preview fills the active dot through the data-status attribute the dot exposes. All of it is in the server render — the first paint, before hydration — because the entries are written straight into the timeline’s JSX; rendered by a component of your own, they would all show as pending until hydration.

Recruiting pipeline

Kanban, with the hiring stages as columns: Applied, Screening, Interview, Offer, Hired. Drag a candidate by the handle — with a mouse, a finger, or the keyboard: Space lifts, the arrow keys carry them between stages, Space drops, Escape puts them back — or use the card’s menu, whose Move to stage group needs no drag at all. The two hires in the last column are the two people onboarding in the directory above.

Applied3 candidates

Sofia Lindgren

Data Analyst

Careers page

Marcus Chen

Frontend Engineer

Agency

Aiko Sato

Content Designer

Careers page

Screening2 candidates

Tomás Rivera

QA Engineer

Careers page

Hà Mỹ Linh

Recruiter

Referral

Interview3 candidates

Phan Ngọc Anh

Backend Engineer

Referral4.3 average interview score

Kavya Iyer

Product Designer

Job board3.5 average interview score

Lương Văn Tài

Backend Engineer

Job board3.3 average interview score

Offer1 candidate

Đinh Công Minh

DevOps Engineer

ITviec4.3 average interview score

Hired2 candidates

Bùi Thu Trang

QA Engineer

Referral4.3 average interview score

Chu Bảo Ngọc

Content Designer

Careers page4.3 average interview score

  • One list, derived columns. Kanban wants Record<column, card[]>; the page keeps one flat candidate list. The board builds its columns with useMemo, in the order sortByStatusPriority reads off the stage table, and onValueChange writes the stage and the order back — on every drag-over, so the column counts move while the card is still in the air. Escape hands back the value from before the drag, through the same callback.
  • A handle, not a whole-card drag. dnd-kit starts a drag on mousedown and swallows the click that follows, so a card that was its own handle could not hold a working menu. KanbanItemHandle sets touch-none itself, so a finger on the handle lifts the card instead of scrolling the page.
  • The announcements are the app’s. The built-in ones follow the provider, but they say “card” and read out the column’s key. labels takes functions that receive the position and that key, so these name the candidate and the stage instead — and, being the app’s sentences now, they are the app’s to translate.
  • Open scorecard on any card sends that candidate to the next section.

Interview scorecard

The interviewer’s side of the same board: one Rating per competency. Score a candidate here and the star average on their card above moves with it. Each Rating is a radiogroup — Tab lands on the chosen star, the arrow keys move and choose — and each star is named for what it means, “3 of 5, meets the bar”, through itemLabel, where the packaged name would only say “3 stars”.

Kavya Iyer

Product DesignerInterview

3.5 average · 2 of 3 scored

  • Craft

    Depth in the skills the role is hired for.

    Above the bar
  • Communication

    Explains a decision so that others can act on it.

    Meets the bar
  • Collaboration

    Works through the team, not around it.

    Not scored

Left out on purpose: a half-star step. A scorecard asks for a decision, not a 3.5.

Onboarding a new hire

The employee record, in the order a new hire’s record is actually filled in: personal details, role and team, equipment and access, then a review. Stepper keeps the steps; each one is a FormSection whose FormField children sit on a twelve-column grid. Press Next with the first step empty: onValidate returns false, the step shows its errors, and focus moves to the first field that failed. Jumping ahead from the step bar is checked the same way — every step before the target has to pass, not just the one you are on.

The spread

FormField takes its children as a function and hands them the four attributes it owns: id, aria-invalid, aria-required and aria-describedby. Spread that object onto the control — {(control) => <Input {...control} />} — and the input is tied to its label, to its error text and to its required state without you naming an id twice. It works the same on a Radix trigger (<SelectTrigger {...control} className="w-full" />, since the trigger defaults to w-fit), on Combobox, DatePicker and MaskInput, which is why focusing the first invalid field works for every control here.

Three traps. Do not also pass Input's own label / error props inside a FormField — the field already draws both, and two owners of one string drift apart. When a control carries its own label, as Switch does, pass it as a plain node instead: the field is then only placing it on the grid, and nothing is wired for you. And PhoneInput is two controls in a group: the spread goes on PhoneInputField, the <input> the label points at, and the group gets an aria-label of its own.

Personal details

What appears on the contract and in the directory.

Step 1 of 4

  • Region, not language. The phone field gets defaultCountry="VN" from the workspace, so a local 090 123 4567 is stored as +84901234567 — the field’s hint shows the value it will save. Without a country, the same digits would be stored as +0901234567, a number that does not exist. The salary gets currency the same way; USD is only the package’s fallback, and the record stores MaskInput’s second argument — the bare number — not the $2,900 on screen.
  • No locale on the date. The DatePicker’s placeholder already shows the order the provider’s language types in — MM/dd/yyyy in English, dd/MM/yyyy in Vietnamese — so the hint under it no longer spells a format out. A hint written by hand is wrong the day the workspace switches language.
  • The server render knows the steps. They are written straight into the list, so Stepper reads them while it renders: the numbers, the separators, and Back and Next are right before hydration. A step rendered by a component of your own would leave the whole list at “0”, with Back and Next disabled, until then.
  • “Step 2 of 4” is a status line. Pressing Next moves no focus, so that line is what tells a screen reader where it landed.

Leave requests

A DateRangePicker drives the whole queue: pick a window and the list, the pending count and the AvatarGroup of who is away all follow. Approve or decline a request and its StatusPill changes; narrow the range until nothing overlaps and the EmptyState offers the way back. The rows themselves are the Item family rather than hand-written list markup — the same parts the mobile layout of a DataTable falls back to, so a record card looks the same wherever it turns up.

3 awaiting a decision
Hoàng Minh PhúcLR-118
Approved
Lê Thị MaiLR-122
Awaiting approval
Nguyễn Đức ThànhLR-121
Awaiting approval
Bùi Thu TrangLR-123
Awaiting approval
Đặng Văn HảiLR-124
Approved
Trịnh Nam SơnLR-125
Declined

The range picker needs nothing from the app to speak English: the calendar, the typing order and its screen-reader strings all follow the nearest ComitorLocaleProvider. What it still needs is the domain. DEFAULT_DATE_RANGE_PRESETS looks back — “Last 7 days”, “Last month”, and its “This month” stops at today — while a leave queue plans ahead, so the queue passes its own forward-looking presets. Presets the app builds show their label exactly as written, so a multilingual app translates them itself. One difference between the preview and the sample is deliberate: every RelativeTime in the preview takes a now, and the sample takes none. A live record is measured against the real clock; a frozen dataset measured against it drifts every day, and can print “in 2 hours” for a timestamp already in the past.

Attendance week

Today’s clock summary in three StatCards over a compact table of the week. Clock out — with the button, or by typing the time the badge reader missed into the TimePicker — and the cards, the Thursday row and the status line all move together: one piece of state, three views. The table sets density="compact", which overrides the app-wide density axis for this table only, and marks today with isRowActive rather than its own rowClassName tint: the prop paints the opaque accent on each cell, so it stays solid under the pinned cells and wins over the stripes this table turns on.

Clocked in

9:06 AM

Badge · Hà Nội office

Clocked out

—

Thu, Sep 3

Worked today

4h 08m

Contracted 8h 00m

Thursday is still open.

DayClock in / outWorkedWhereSource
Mon Aug 319:02 AM – 6:05 PM9h 03mIn the officeBadge entry, Hà Nội office
Tue Sep 18:45 AM – 5:30 PM8h 45mRemoteApproved work-from-home day
Wed Sep 29:10 AM – 5:55 PM8h 45mIn the officeBadge entry, Hà Nội office
Thu Sep 39:06 AM – …4h 08mIn the officeToday
Fri Sep 4——ScheduledNothing recorded yet

TimePicker stores 24-hour "HH:mm" whatever it shows, and the clock it shows follows the provider: 12-hour with AM/PM in English, 24-hour in Vietnamese, where the AM/PM segment is simply not rendered. Everything the page prints from that value goes through Intl.DateTimeFormat with the provider’s locale, so the cards and the table always read the same clock as the picker. min and max hold the field to the working day: hours before the badge-in and after now are greyed out in the columns, and a time typed outside the window goes to its nearest edge. The window includes its bounds, so “clock-out comes after clock-in” stays the app’s own check — pick 9:06 AM to see the message under the field — as does any value the app loads itself, which the picker shows as given.

Payroll run

The register for a period, with the totals computed from the rows on screen rather than typed in. Switch to September and run it: the ConfirmDialog states the amount and the headcount before anything moves, then every line flips to paid — except the one held for a final settlement, because a real run never has a single uniform status. Money leaving the company is exactly the kind of irreversible action that gets asked about once, through the same component everywhere.

Pay date Sep 25, 2026 · 1 line on hold
EmployeeDepartmentGrossDeductionsNet payStatus
Trần Quốc BảoEngineering$5,400−$999$4,401Awaiting run
Nguyễn Đức ThànhEngineering$4,200−$777$3,423Awaiting run
Lê Thị MaiDesign$3,400−$629$2,771Awaiting run
Phạm Anh ĐàoDesign$4,800−$888$3,912Awaiting run
Hoàng Minh PhúcProduct$4,100−$759$3,341Awaiting run
Vũ Thị HạnhPeople$4,600−$851$3,749Awaiting run
Đặng Văn HảiEngineering$3,800−$703$3,097Awaiting run
Ngô Gia HuyProduct$3,100−$574$2,526Awaiting run
Lý Thanh TrúcFinance$3,000−$555$2,445Awaiting run
Đỗ Khánh LinhPeople$2,700−$500$2,200Awaiting run
Trịnh Nam SơnEngineering$4,400−$814$3,586On hold
Mai Tuấn KiệtFinance$5,200−$962$4,238Awaiting run
Gross
$48,700
Deductions
−$9,011
On hold
−$3,586
Net pay
$36,103

Best practices

Do

  • Declare one StatusConfig table per domain — employment, recruiting, leave, attendance, payroll — and let StatusPill, the board’s columns, the grid’s options and sort order, and FilterChips all read it.
  • Set the language once, with one ComitorLocaleProvider at the root, and pass the region explicitly — defaultCountry on PhoneInput, currency on MaskInput, both from the workspace settings.
  • Keep labels for the domain’s own words: what a count counts (“2 people selected”), whom a drag announcement is about.
  • Make a directory read-only (readOnly: true): it is read, filtered and exported, and a title or a manager changes through a form and an approval, not by typing over a cell.
  • Hold the grid’s filters and sort in app state as soon as anything outside it — chips, a department picker, a URL — reads or writes them, and count each quick filter against everything except its own column, which is what faceting does.
  • Give a column its own sortingFn only when its order is the domain’s, not the alphabet’s — Status by lifecycle. Names already sort and match the Vietnamese way.
  • Keep approve and deny inline on the leave queue; a decision that needs a detail page will not get made today.
  • Validate a step before it can be left, and on a blocked Next move focus to the first field that failed.
  • Give every drag a single-pointer twin — here, the card menu’s Move to stage.
  • Put payroll, offboarding and anything else irreversible behind ConfirmDialog, with the amount and the headcount in the description.
  • Pin the identifying columns and let the rest scroll — the grid and the tables scroll inside their own box, so the page never does.
  • Ship a default column set narrower than the full one. Leave balance starts hidden (initialState.columnVisibility), and the View menu is how the two people who need it get it back.

Don't

  • Do not show salary, ID numbers or leave reasons to anyone whose role has not earned them — the directory and the payroll register are two different permissions.
  • Do not hand-roll the directory. DataGrid already filters and sorts on several conditions, pins and hides columns, finds, copies ranges and virtualises its rows; the short reports further down are DataTable.
  • Do not reach for a custom cell to show a pill or an avatar. A grid cell is one of nine kinds so that it can be filtered, sorted and copied; draw the rich view beside the grid, as the profile does.
  • Do not pass Input's own label or error props inside a FormField; the field draws both already.
  • Do not hand a DatePicker or a RelativeTime a locale to make it English. The provider already decides, and a per-component locale is how two widgets on one screen end up in two languages — keep the prop for a deliberate exception.
  • Do not trust TimePicker’s min and max to stop a bad time; check the window yourself.
  • Do not colour a delta by arrow direction alone; set polarity so a rising backlog does not read as good news.
  • Do not tint a whole table row to mark a state: a translucent background shows through pinned cells and loses to striped on even rows.
  • Do not run payroll without a review step and a visible net total.

Accessibility

  • LetterAvatar always carries the person's name as sr-only text, so next to a visible name it needs aria-hidden — otherwise the name is announced twice.
  • Every status here is a word as well as a colour: StatusPill renders the label, never a bare dot.
  • Approve, decline and card-menu buttons name the person (aria-label="Approve the leave request from Lê Thị Mai") — “Approve” on its own is seven identical buttons in a row.
  • Spreading the FormField control object is what associates each input with its label and its error text; nothing else on the page does it. On PhoneInput it goes on the number field, and the group gets its own aria-label; the country button names its value (“Country: Vietnam (+84)”).
  • Selection counts, the offboarding result, the clock-out state, the stepper’s “Step 2 of 4” and the scorecard average sit in role="status", so each change is announced without stealing focus.
  • The directory is a role="grid" with aria-rowcount, named by its aria-label — which the package puts on the element with the role, not on the wrapper around it — and a sorted column’s header carries aria-sort. The arrow keys move between cells, and Tab moves to the next cell while one is focused. Escape leaves the cell (while rows are ticked, the first Escape clears them); from there Tab walks the row checkboxes — Space ticks one — and on out of the grid. Ctrl+C copies a selected range and Ctrl+F opens find. The profile follows the focused row, so a person is one arrow key away rather than behind a click.
  • A blocked Next — or a blocked submit — moves focus to the first field that failed; a red summary at the top of a long form leaves keyboard users hunting.
  • The step bar is a tablist: the arrow keys move between steps, forward moves go through onValidate like Next does, and a finished step is announced as “Step 1, completed” in the provider’s language.
  • The bulk bar is a named toolbar (“Bulk actions”); the arrow keys move between its actions, its × is “Clear selection”, and Escape clears — unless a menu or dialog above it has already used that Escape. It is portalled into the directory, not to the end of the document, so Tab reaches it right after the grid.
  • The recruiting board works from the keyboard — Space or Enter on a handle, the arrow keys, Space to drop, Escape to cancel — speaks every step in words that name the candidate and the stage, and puts focus back on the handle after a drop.
  • Each Rating is a radiogroup named by its competency and described by its hint; exactly one star is aria-checked, and every star says what it means.
  • TimePicker’s hour and minute are named inputs inside a group that TimePickerLabel names; the error under it is tied to the group with aria-describedby.
  • The employment history is a list, and its current entry carries aria-current="step".