Patterns

HR & People

Six screens from a people-management app — a manager dashboard, the employee directory, the record form, 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

The whole list screen: SearchFilterBar over two Combobox filters, a DataTable with a pinned ID column, sorting, row selection and a Columns menu, then TablePagination underneath. Everything is live — sort a column, select two people and start offboarding them, hide the job title. Search is diacritic-insensitive because matchesSearch does the matching: type duc thanh and Nguyễn Đức Thành comes back.

Actions
CMT-014Bùi Thu Trang[email protected]QA EngineerEngineeringOnboardingTrần Quốc BảoSep 1, 2026
CMT-021Chu Bảo Ngọc[email protected]Content DesignerDesignOnboardingPhạm Anh ĐàoSep 8, 2026
CMT-012Đặng Văn Hải[email protected]Backend EngineerEngineeringActiveTrần Quốc BảoMay 22, 2023
CMT-018Đỗ Khánh Linh[email protected]RecruiterPeopleActiveVũ Thị HạnhOct 1, 2024
CMT-009Hoàng Minh Phúc[email protected]Product ManagerProductOn leaveVũ Thị HạnhFeb 14, 2022

Two details worth stealing. The table is controlled — the package holds no data, sorts nothing and paginates nothing, so filtering, sorting and paging are the app's to wire, which is what lets the same component serve an in-memory list and a server-paginated one. And the row menu only opens the confirmation: Radix unmounts a DropdownMenu the moment an item is chosen, so a ConfirmDialog nested inside it disappears along with the menu. Both dialogs here live at the screen level instead, and both stay mounted — only open moves. That second half matters as much as the first: an alert dialog unmounted in the same commit that confirms it never gets its open→closed transition, and the pointer-events: none Radix puts on <body> is never lifted — the page keeps rendering and quietly stops accepting clicks. Which is why the subject of each dialog (the person, the selected count) is captured when it opens rather than read live: confirming changes the table underneath, and the dialog is still fading out on top of it.

Employee record form

Three FormSections — the last one collapsible — each holding a twelve-column grid that its FormField children position with colSpan. Submit it empty: validation runs from one pure function, the summary counts the failures, and focus moves to the first field that failed.

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) and on Combobox or DatePicker, which is why focusing the first invalid field works for every control here.

Two 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. And 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.

Personal details

What appears on the contract and in the directory.

Role and contract

Reporting line, start date and contracted hours.

hrs

Goes out at 9am on the start date.

Creates a task for the manager three months in.

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 three things from an English app, and they are three different props: locale={enUS} for the calendar and the typing order, labels for the screen-reader strings, and presets because DEFAULT_DATE_RANGE_PRESETS is Vietnamese. A locale carries date spelling, not interface strings, so passing it alone leaves the control half-translated. 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 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 a tinted row: a translucent background would leak through the pinned cells.

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 3109:02 – 18:059h 03mIn the officeBadge entry, Hà Nội office
Tue Sep 108:45 – 17:308h 45mRemoteApproved work-from-home day
Wed Sep 209:10 – 17:558h 45mIn the officeBadge entry, Hà Nội office
Thu Sep 309:06 – …4h 08mIn the officeToday
Fri Sep 4ScheduledNothing recorded yet

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, leave, attendance, payroll — and let StatusPill, FilterChips and sorting all read it.
  • Give every composite that takes labels an English set, defined once for the page. The package defaults are Vietnamese, including screen-reader text.
  • Reset to page 1 on every filter change, or a filtered list looks empty from page 3.
  • Keep approve and deny inline on the leave queue; a decision that needs a detail page will not get made today.
  • Put payroll, offboarding and anything else irreversible behind ConfirmDialog, with the amount and the headcount in the description.
  • Pin the identifying column and let the rest scroll — the table's own container scrolls, so the page never does.
  • Ship a default column layout that is narrower than the full set. Nine columns is a horizontal scroll on a laptop; the Columns menu is how the two people who need the leave balance 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 table. Sorting, selection, column visibility, skeletons and the empty state are already in DataTable.
  • Do not pass Input's own label or error props inside a FormField; the field draws both already.
  • Do not give a DatePicker a locale and stop there — the labels and the presets are separate props, and they stay Vietnamese.
  • 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 row-action 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.
  • Selection counts and the clock-out state sit in role="status" so the change is announced without stealing focus.
  • Sorting a DataTable column sets aria-sort on the header, and its icon stays at full contrast — a dimmed icon on a live control fails WCAG 1.4.11.
  • Submitting an invalid form moves focus to the first field that failed; a red summary at the top of a long form leaves keyboard users hunting.
  • An action column carries no visible header, so it is named twice over: label for the Columns menu, and an sr-only heading for the table itself.