Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Workforce dashboard

New

A time-tracking product’s analytics home: nine live cards (area, donut, stacked bars, a beating health heart, lines and bars) under a top bar with a running tracker and filters.

workforce-dashboard/workforce-dashboard.tsx
Live · interactive

This dashboard plays an automatic demo: a virtual cursor tries its controls and rearranges its cards. It pauses while you point at, touch or focus the dashboard, and resumes a few seconds after you leave.

Today02:10:37

Filters

  • Brazil
  • All subsidiary
  • Dec 01 - Dec 31 - 2022

Active employees

Period: Last 6 months | Frequency: Monthly view

Employees by Group

Period: Last 6 months | Frequency: Monthly view

Total time bank

Employees

Accumulated time bank

Accumulated time bank: 556:34:00, up 56:10:78 (+14%)

Company Health score

This score is considered to be Excellent.

Your score for Q1 2022

You saved 224:43h in extra hours

Time bank

Period: Last 6 months | Frequency: Monthly view

Monthly employee

This score is considered to be Excellent.

MS

Mateo Serrano

48 major tasks completed in the last month

Time bank by department

This score is considered to be Excellent.

Monthly time bank

This score is considered to be Excellent.

Positive time bank by employee

This score is considered to be Excellent.

Kiran, 140:41:02 of positive time bank

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • The home screen of an HR, payroll or time-tracking product, where managers check hours, balances and headcount at a glance.
  • A handful of metrics that belong together and should answer one shared filter: pick a colleague once and every card that knows them follows.
  • A screen that must look the same at every width: wide, the whole design scales as one; narrow, the cards stack and keep their own drawing.
  • A product tour or a landing section, where the virtual cursor shows the cards working before anyone touches them.

When not to use

  • A single metric or chart on its own. Take the one card you need, or use a stat card.
  • Data to read row by row, sort or export. Use a table, such as the records table.
  • Live operations that change by the second (queues, incidents). The cards draw a period, not a stream; use a monitoring view.
  • Inside another app’s chrome: the dashboard brings its own top bar and sidebar. Use its cards in your own layout instead.

You can add this component with the shadcn CLI or by hand:

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

team-page.tsx
import { WorkforceDashboard } from './workforce-dashboard';export function TeamPage() {  return (    <WorkforceDashboard      autoplay={false}      defaultPerson="mateo"      onPersonChange={(person) => console.log('Filtered to', person)}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

The analytics home of an invented time-tracking product. A dark top bar searches with ⌘K, runs a time tracker you can pause and opens notifications; an icon sidebar shows tooltips; filter chips come off and reset. Nine cards answer the pointer and the keyboard: active employees over the year, employees by group in a donut you can regroup, the time bank by month in stacked bars, the total time bank per colleague with a metric picker, a company health score in a beating heart filling with liquid, the monthly employee, the time bank by department with legend toggles, a monthly line and the positive time bank by employee. Picking a colleague filters every card that knows them.

  • The dashboard is one 1526 × 881 design whose regions place themselves by left and top. On the wide layout (880 px and up) it is scaled to the root’s width in CSS alone: transform: scale(tan(atan2(100cqw, 1548px))) turns a length over a length into a number, so it is the right size from the first paint, without a script, and never lays out again as the page resizes.
  • Under 880 px a container query reflows it: the frame becomes a grid of one column (two from 600 px), the top bar wraps into two rows, the sidebar becomes a drawer behind a menu button, and each card keeps its own design and is scaled to its column the same way. The time bank chart, twice as wide as the others, keeps a 420-unit window and scrolls sideways inside its card.
  • Charts are SVG and CSS: the area and line draw in with stroke-dashoffset on a pathLength of 1, the donut’s segments sweep in, bars grow by transform. Every chart maps the pointer through its own bounding box, so hover still lands on the right point at any scale. Arrow keys, Home and End walk the points of a focused chart.
  • The total time bank counts up with registered @property integers: the values transition and a CSS counter prints them, so no frame is rendered by script. The health heart beats with a CSS animation (paused off screen) while three waves of liquid rise inside a heart-shaped mask.
  • The colleague picked in the total time bank card is shared state: the departments, monthly and per-employee cards follow it, and clicking a colleague’s bar clears it again.
  • A virtual cursor plays the dashboard while it is on screen and untouched. It sends real pointer and mouse events at its coordinates and clicks real buttons, so every hover state and tooltip is the one a reader gets: it picks colleagues, sweeps the area chart, toggles a legend, hovers bars and rows, removes and resets the filters, then drags pairs of cards into each other’s places (FLIP, with the Web Animations API) and, over three cycles, back. On the narrow layout it skips the swaps and plays only the scenes on screen.
  • Any real pointer, touch, key or focus inside hands control to the reader, and anything the demo left half-done is undone; it resumes five seconds after they leave. It stops off screen, in a hidden tab, under reduced motion and with autoplay={false}.
  • Icons, logo, avatars and the heart are inline SVG or CSS, so the component needs no images. Every colour is a light-dark() token on the root, so it follows the page’s color-scheme or the colorScheme prop.
PropTypeDefaultDescription
autoplaybooleantruePlays the virtual-cursor demo while the dashboard is on screen and untouched. Never under reduced motion.
colorScheme'light' | 'dark' | 'system'—Forces a colour scheme. Without it the dashboard follows the page’s color-scheme.
defaultPerson'all' | 'mateo' | 'ava' | 'rosana''all'The colleague the dashboard opens filtered to.
onPersonChange(person: Person) => void—Called when a colleague is picked (by the reader or the demo), with ‘all’ when the filter is cleared.
defaultFiltersFilter[]initialFiltersThe filter chips ({ id, label }) it opens with: country, subsidiary and period by default; [] shows Reset filters. Giving it turns the demo off.
defaultNotificationsOpenbooleanfalseOpens with the notifications popover showing; true turns the demo off.
classNamestring—Added to the root, to size or place the dashboard.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

workforce-dashboard.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useState } from 'react';import { DashboardShell } from './shell';import { ActivityCards } from './activity-cards';import { TeamCards } from './team-cards';import { HoursCards } from './hours-cards';import { TrendCards } from './trend-cards';import { DashboardShowcase } from './showcase';import { initialFilters, type Filter, type Person } from './types';import styles from './workforce-dashboard.module.css';export type { Filter, Person } from './types';/* * Workforce dashboard: the analytics home of an invented time-tracking product ("Hourline"). A * dark top bar (search with ⌘K, a running time tracker, notifications), an icon sidebar, a * filter bar and nine cards: active employees (an area chart), employees by group (a donut), the * time bank by month (stacked bars), the total time bank per colleague, a company health score * (a beating heart filling with liquid), the monthly employee, the time bank by department, the * monthly time bank (a line) and the positive time bank by employee (bars). *
workforce-dashboard.module.css
/* Workforce dashboard (workforce-dashboard.tsx): the root, its colour tokens and its two layouts.   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop   sets (data-color-scheme). Every region's stylesheet uses only these tokens.   Wide (the viewport 880 px and up): the canvas is the 1548 × 903 design (the 1526 × 881 frame   and its 11-unit ring) scaled to the root's width by transform, so it never lays out again as   the page resizes. Narrow: a container query turns the frame into a grid of one or two columns   and scales each card to its column. *//* Inter as a variable font (300–700), so 300 and 600 are real weights, not synthesised. */@font-face {  font-family: WorkforceInter;  font-weight: 300 700;  font-style: normal;  font-display: swap;  src:    local('Inter Variable'),    url('/assets/inter-latin-wght-3100e775.woff2') format('woff2');}.root {  --wf-canvas: #f2f4f7;
shell.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useId,  useRef,  useState,  type MouseEvent,  type RefObject,} from 'react';import { initialFilters, PRODUCT, type Filter, type FilterProps } from './types';import {  BellIcon,  CalendarIcon,  CloseIcon,  Glyph,  LogoMark,  MenuIcon,  SearchIcon,  SwitchIcon,  type GlyphName,
shell.module.css
/* * Dashboard chrome (shell.tsx): top bar, sidebar, filter bar and the next row's placeholder * cards. On the wide layout every left/top is relative to the 1526×881 frame; the narrow layout * (the container query at the end) puts them in the frame's grid. * * Hover states match both :hover and [data-hover]; the latter is set from pointer/mouse * events (see trackHover) so script-dispatched events show them too. *//* ---------- Top bar ---------- */.topBar {  position: absolute;  /* Above the sidebar (20), which now follows it in the DOM. */  z-index: 21;  top: 0;  left: 0;  width: 1526px;  height: 58.02px;  color: color(display-p3 0.565 0.612 0.694);  background: var(--wf-topbar);}.topBar :focus-visible {  outline: 1.59px solid color(display-p3 0.592 0.729 1);  outline-offset: 1.59px;
activity-cards.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type FocusEvent,  type KeyboardEvent,  type ReactNode,  type RefObject,} from 'react';import { CornerArrow, CornerGrip, DotsIcon, TooltipTail } from './icons';import styles from './activity-cards.module.css';/* ------------------------------------------------------------------------------------------------ * Shared helpers * ---------------------------------------------------------------------------------------------- */type Phase = 'static' | 'armed' | 'played';
activity-cards.module.css
/* Active employees and Employees by Group; units are dashboard px.   Card roots carry no transform (the autoplay director animates `translate` on them).   Text set in the design font renders in Inter here; Inter's glyphs sit higher in the   same line box, so those runs are nudged down (measured against the design render). */.card {  position: absolute;  top: 136.704px;  width: 360.714px;  height: 209.533px;}.card:focus-within,.card:has([data-open]) {  z-index: 3;}.activeCard {  left: 89.017px;}.groupCard {  left: 457.688px;}.cardBox {  position: absolute;  top: -0.332px;  left: -0.332px;
team-cards.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type RefObject,} from 'react';import { useHydratedReducedMotion } from './use-reduced-motion';import type { Person, PersonProps } from './types';import { CheckBadge, CornerGrip, DotsIcon, Glyph, PagerArrow } from './icons';import { HeartPattern, HeartShadow, HeartSurface, HeartWave } from './heart';import styles from './team-cards.module.css';type Time = [hours: number, minutes: number, seconds: number];type MetricId = 'acumulado' | 'extras' | 'negativas';type Reading = {  value: Time;
team-cards.module.css
/* the design (Total time bank) and (Company Health score).   Every length is in the 1526×881 design space of frame . */@property --wf-team-vh {  syntax: '<integer>';  inherits: false;  initial-value: 0;}@property --wf-team-vm {  syntax: '<integer>';  inherits: false;  initial-value: 0;}@property --wf-team-vs {  syntax: '<integer>';  inherits: false;  initial-value: 0;}@property --wf-team-dh {  syntax: '<integer>';  inherits: false;  initial-value: 0;}@property --wf-team-dm {
hours-cards.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useRef,  useState,  type CSSProperties,  type FocusEvent,  type KeyboardEvent,} from 'react';import { CornerArrow, CornerGrip, DotsIcon, Glyph, StarBadge, StopwatchIcon } from './icons';import styles from './hours-cards.module.css';const SHOW_EVENTS = [  'pointerover',  'pointerenter',  'pointermove',  'mouseover',  'mouseenter',  'mousemove',] as const;
hours-cards.module.css
/* Time bank and Monthly employee, positioned in the 1526×881 dashboard frame. */.banco,.monthly {  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);  --ink-soft: var(--wf-ink-3);  --ink-muted: var(--wf-muted-3);  --accent: var(--wf-sky);  position: absolute;}/* ---------------------------------------------------------------- *//* Time bank                     *//* ---------------------------------------------------------------- */.banco {  left: 89.017px;  top: 359.246px;  width: 729.619px;  height: 209.825px;}.bancoBox,.monthlyBox {  position: absolute;  left: -0.332px;  top: -0.332px;
trend-cards.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type ReactNode,} from 'react';import { useHydratedReducedMotion } from './use-reduced-motion';import type { Person, PersonProps } from './types';import { CornerGrip, DotsIcon, LegendDot, TooltipTail } from './icons';import styles from './trend-cards.module.css';/** Hours with a thousands separator: 5,000. */const HOURS = new Intl.NumberFormat('en-US');/* ------------------------------------------------------------------ data */type DepartmentId = 'comercial' | 'tecnologia' | 'financeiro';
trend-cards.module.css
/* Baselines: Chrome rounds font ascent/descent to whole px and floors the half-leading, so a   fixed line-height box puts small type up to ~1px above the design's baseline. The small labels   below use line-height: normal inside a box of the design's line height, with the exact   half-leading as padding (Inter: ascent 0.96875em, descent 0.2421875em).   the design nodes (Time bank by department), (Monthly time bank) and (Positive time bank). Every length is in the dashboard's 1526×881 design space. */.card {  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);  --focus: var(--wf-accent);  position: absolute;  border-radius: 2.122px;  background: var(--wf-surface);  /* The Box vector's 0.663px var(--wf-line) stroke is centred on the edge (half outside, half inside). */  box-shadow:    0 0 0 0.3315px var(--wf-line),    inset 0 0 0 0.3315px var(--wf-line);  font-feature-settings:    'cv09' 1,    'ss11' 1,    'calt' 0,    'liga' 0;}.departments {  left: 828.968px;
showcase.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useLayoutEffect, useRef } from 'react';import { useHydratedReducedMotion } from './use-reduced-motion';import styles from './showcase.module.css';/* * Autoplay showcase. A virtual cursor drives the real cards the way a reader would (pointer and * mouse events at client coordinates, element.click() on buttons) and then personalises the * layout by dragging cards into each other's slots. It plays only while the dashboard is on * screen, the tab is visible and nobody is using the dashboard (no pointer over it, no focus in * it); reduced motion turns it off. It never moves focus itself, and when the dashboard scrolls * sideways (phones) it scrolls that box, never the page, to keep each scene in view. */type Point = { x: number; y: number };type Slot = { left: number; top: number };type SwapCard = 'active' | 'groups' | 'health' | 'employee' | 'monthly-bank' | 'people';/** Home slots of the cards that move, in frame px (from the region files). Sizes never change. */const HOME: Record<SwapCard, Slot & { width: number; height: number }> = {  active: { left: 89.017, top: 136.704, width: 360.714, height: 209.533 },
showcase.module.css
/* * Autoplay showcase layers, drawn in the dashboard's 1526×881 design space so they scale with it. * The stage (virtual cursor, click ripples) sits above every layer, the drop zone sits between * the resting cards and a card that is being dragged (z-index 25, set by the director). */.note {  position: absolute;  width: 1px;  height: 1px;  margin: -1px;  padding: 0;  overflow: hidden;  clip-path: inset(50%);  white-space: nowrap;}.stage {  position: absolute;  inset: 0;  z-index: 60;  overflow: hidden;  pointer-events: none;}
heart.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useId } from 'react';/* * The health score's heart, drawn inline: a grid fading out from its middle, the raised heart * shape with its soft shadow, a blush surface, and three red waves that fill it. Each drawing is * sized by its class in the CSS (team-cards.module.css). Gradients and filters take ids from * useId, so two dashboards on a page never share them. */const HEART =  'M89.1474 0C107.825 0 122.962 14.5394 122.962 34.8946C122.962 75.605 76.8512 98.8681 61.481 107.592C46.1107 98.8681 0 75.605 0 34.8946C0 14.5394 15.3702 0 33.8145 0C45.25 0 55.3329 5.81577 61.481 11.6315C67.6291 5.81577 77.712 0 89.1474 0Z';const WAVE =  'M62.2134 20.59C26.9109 8.85154 6.02843 43.5632 0 62.3863L103.809 211.341C144.923 126.266 217.747 -33.834 180.129 6.36151C133.108 56.6059 106.341 35.2632 62.2134 20.59Z';/** The grid behind the heart: 12 × 6 squares of 20.49 units, faded by a radial gradient. */export function HeartPattern({ className }: { className: string }) {  const id = useId();  const step = 20.4937;  const lines: string[] = [];
icons.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The dashboard's icons, drawn inline so they take the colour tokens (currentColor or a CSS * variable) in light and dark. Every one is decorative (aria-hidden) and sized by its class in * the CSS, never by width/height attributes. */import type { CSSProperties, ReactNode } from 'react';type IconProps = { className?: string };const glyph = (className: string | undefined, viewBox: string, children: ReactNode) => (  <svg className={className} viewBox={viewBox} fill="none" aria-hidden="true" focusable="false">    {children}  </svg>);/** The invented product's mark: a clock face cut into a rounded tile, white on the top bar. */export function LogoMark({ className }: IconProps) {  return glyph(    className,    '0 0 23.27 31.027',    <>
types.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./** Shared state of the workforce dashboard. Every name in it is invented. *//** The invented product the dashboard belongs to (the logo's accessible name). */export const PRODUCT = 'Hourline';export type Person = 'all' | 'mateo' | 'ava' | 'rosana';export type Filter = { id: string; label: string };export const initialFilters: Filter[] = [  { id: 'country', label: 'Brazil' },  { id: 'subsidiary', label: 'All subsidiary' },  { id: 'period', label: 'Dec 01 - Dec 31 - 2022' },];/** Regions work uncontrolled on their own and controlled inside the full dashboard. */export type PersonProps = {  person?: Person;  onPersonChange?: (person: Person) => void;};export type FilterProps = {
use-reduced-motion.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useSyncExternalStore } from 'react';const query = '(prefers-reduced-motion: reduce)';const subscribe = (onStoreChange: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', onStoreChange);  return () => media.removeEventListener('change', onStoreChange);};const getClientSnapshot = () => window.matchMedia(query).matches;const getServerSnapshot = () => false;export function useHydratedReducedMotion() {  return useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);}
  • The sidebar is a labelled navigation whose icon buttons carry their names (shown as tooltips on hover and focus); on the narrow layout the menu button reports aria-expanded and Escape closes the drawer and returns focus.
  • Each card is a group named by its title. Charts are focusable with a spoken description of the point in view; arrow keys, Home and End move through the points and Escape leaves.
  • Card menus, the grouping pickers and the metric picker follow the menu button pattern: arrow keys open and move, Escape closes and returns focus. Legend toggles and colleague chips are toggle buttons with aria-pressed.
  • Removing a filter chip moves focus to the next chip, or to Reset once none is left. The notifications popover is a labelled dialog that Escape closes.
  • Changes the reader makes are announced through polite live regions; the demo’s own changes are not. A visually hidden note tells screen reader users about the demo, and the virtual cursor is hidden from assistive technology.
  • Reduced motion stops the demo, the heartbeat, the count-ups and every entrance; the dashboard then shows its final state.