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
Leave to review
4
+2since Monday
Monthly payroll
$54,200
+4.1%vs. August
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-014 | Bùi Thu Trang[email protected] | QA Engineer | Engineering | Onboarding | Trần Quốc Bảo | Sep 1, 2026 | ||
| CMT-021 | Chu Bảo Ngọc[email protected] | Content Designer | Design | Onboarding | Phạm Anh Đào | Sep 8, 2026 | ||
| CMT-012 | Đặng Văn Hải[email protected] | Backend Engineer | Engineering | Active | Trần Quốc Bảo | May 22, 2023 | ||
| CMT-018 | Đỗ Khánh Linh[email protected] | Recruiter | People | Active | Vũ Thị Hạnh | Oct 1, 2024 | ||
| CMT-009 | Hoàng Minh Phúc[email protected] | Product Manager | Product | On leave | Vũ Thị Hạnh | Feb 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.
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.
Annual leave · Sep 1, 2026 – Sep 5, 2026 · 5 days · submitted
Sick leave · Sep 4, 2026 · 1 day · submitted
Annual leave · Sep 14, 2026 – Sep 18, 2026 · 5 days · submitted
Unpaid leave · Sep 7, 2026 – Sep 8, 2026 · 2 days · submitted
Parental leave · Sep 21, 2026 – Oct 16, 2026 · 20 days · submitted
Annual leave · Sep 2, 2026 – Sep 3, 2026 · 2 days · submitted
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
Clocked out
—
Worked today
4h 08m
| Day | Clock in / out | Worked | Where | Source |
|---|---|---|---|---|
| Mon Aug 31 | 09:02 – 18:05 | 9h 03m | In the office | Badge entry, Hà Nội office |
| Tue Sep 1 | 08:45 – 17:30 | 8h 45m | Remote | Approved work-from-home day |
| Wed Sep 2 | 09:10 – 17:55 | 8h 45m | In the office | Badge entry, Hà Nội office |
| Thu Sep 3 | 09:06 – … | 4h 08m | In the office | Today |
| Fri Sep 4 | — | — | Scheduled | Nothing 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.
| Employee | Department | Gross | Deductions | Net pay | Status |
|---|---|---|---|---|---|
| Trần Quốc Bảo | Engineering | $5,400 | −$999 | $4,401 | Awaiting run |
| Nguyễn Đức Thành | Engineering | $4,200 | −$777 | $3,423 | Awaiting run |
| Lê Thị Mai | Design | $3,400 | −$629 | $2,771 | Awaiting run |
| Phạm Anh Đào | Design | $4,800 | −$888 | $3,912 | Awaiting run |
| Hoàng Minh Phúc | Product | $4,100 | −$759 | $3,341 | Awaiting run |
| Vũ Thị Hạnh | People | $4,600 | −$851 | $3,749 | Awaiting run |
| Đặng Văn Hải | Engineering | $3,800 | −$703 | $3,097 | Awaiting run |
| Ngô Gia Huy | Product | $3,100 | −$574 | $2,526 | Awaiting run |
| Lý Thanh Trúc | Finance | $3,000 | −$555 | $2,445 | Awaiting run |
| Đỗ Khánh Linh | People | $2,700 | −$500 | $2,200 | Awaiting run |
| Trịnh Nam Sơn | Engineering | $4,400 | −$814 | $3,586 | On hold |
| Mai Tuấn Kiệt | Finance | $5,200 | −$962 | $4,238 | Awaiting run |
- Gross
- $48,700
- Deductions
- −$9,011
- On hold
- −$3,586
- Net pay
- $36,103
Best practices
Do
- Declare one
StatusConfigtable per domain — employment, leave, attendance, payroll — and letStatusPill,FilterChipsand sorting all read it. - Give every composite that takes
labelsan 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 ownlabelorerrorprops inside aFormField; the field draws both already. - Do not give a
DatePickera 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
polarityso 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
stripedon even rows. - Do not run payroll without a review step and a visible net total.
Accessibility
LetterAvataralways carries the person's name assr-onlytext, so next to a visible name it needsaria-hidden— otherwise the name is announced twice.- Every status here is a word as well as a colour:
StatusPillrenders 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
FormFieldcontrol 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
DataTablecolumn setsaria-sorton 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:
labelfor the Columns menu, and ansr-onlyheading for the table itself.