Timesheet
A weekly timesheet grid: hours per project per day, row and day totals, billable split and a target bar.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/timesheet.json'use client'
import { useState } from 'react'
import './timesheet.css'
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
const parse = (s: string): number | null => { const t = s.trim(); if (!t) return 0; const m = t.match(/^(\d{1,2}):([0-5]\d)$/); if (m) return Number(m[1]) + Number(m[2]) / 60; const n = Number(t.replace(',', '.')); return isFinite(n) && n >= 0 && n <= 24 ? n : null }
const fmt = (n: number) => (Math.round(n * 100) / 100).toString()
export interface TimesheetProps {
target?: number
projects?: string[]
fill?: 'dark' | 'light'
className?: string
}
/** Weekly hours grid with totals. */
export function Timesheet({ target = 40, projects = ['Client portal', 'Mobile app', 'Internal'], fill = 'dark', className }: TimesheetProps) {
const [cells, setCells] = useState<string[][]>(() => [['6', '7', '5', '', ''], ['2', '1', '3', '', ''], ['', '0:30', '', '', '']].slice(0, projects.length))
const [bill, setBill] = useState<boolean[]>(projects.map((_, i) => i < 2))
const num = cells.map((r) => r.map((c) => parse(c) ?? 0))
const rowT = num.map((r) => r.reduce((a, b) => a + b, 0))
const colT = DAYS.map((_, d) => num.reduce((a, r) => a + r[d], 0))
const tot = rowT.reduce((a, b) => a + b, 0)
const billable = rowT.reduce((a, t, i) => a + (bill[i] ? t : 0), 0)
const set = (r: number, d: number, v: string) => setCells(cells.map((row, i) => (i === r ? row.map((c, j) => (j === d ? v : c)) : row)))
const move = (e: React.KeyboardEvent<HTMLInputElement>, r: number, d: number) => {
const k = e.key; let nr = r, nd = d
if (k === 'ArrowRight' && e.currentTarget.selectionStart === e.currentTarget.value.length) nd++
else if (k === 'ArrowLeft' && e.currentTarget.selectionStart === 0) nd--
else if (k === 'ArrowDown' || k === 'Enter') nr++
else if (k === 'ArrowUp') nr--
else return
if (nr < 0 || nd < 0 || nr >= cells.length || nd >= DAYS.length) return
e.preventDefault()
const el = e.currentTarget.closest('table')?.querySelector<HTMLInputElement>(`input[data-c="${nr}-${nd}"]`)
el?.focus(); el?.select()
}
return (
<div className={['sd-tms', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-tms__wk">Week 45<span>Tap a dot to mark billable</span></div>
<table>
<thead><tr><th>Project</th>{DAYS.map((d) => <th key={d}>{d}</th>)}<th>Total</th></tr></thead>
<tbody>{projects.map((p, r) => <tr key={p}>
<th><button type="button" aria-pressed={bill[r]} aria-label={`${p}, billable`} onClick={() => setBill(bill.map((b, i) => (i === r ? !b : b)))}><i>●</i><span>{p}</span></button></th>
{DAYS.map((d, j) => <td key={d}><input inputMode="decimal" data-c={`${r}-${j}`} aria-label={`${p} ${d} hours`} value={cells[r][j]} data-bad={parse(cells[r][j]) === null || undefined} onChange={(e) => set(r, j, e.target.value)} onKeyDown={(e) => move(e, r, j)} onFocus={(e) => e.target.select()} /></td>)}
<td className="t">{fmt(rowT[r])}</td>
</tr>)}</tbody>
<tfoot><tr><td>Day</td>{colT.map((t, i) => <td key={i}>{fmt(t)}</td>)}<td>{fmt(tot)}</td></tr></tfoot>
</table>
<div className="sd-tms__bar" aria-hidden><i style={{ width: `${Math.min(100, (billable / Math.max(target, tot)) * 100)}%` }} /><i style={{ width: `${Math.min(100, ((tot - billable) / Math.max(target, tot)) * 100)}%` }} /></div>
<div className="sd-tms__f" aria-live="polite"><span><b>{fmt(tot)}</b> / {target} h{tot > target ? ` · ${fmt(tot - target)} overtime` : ` · ${fmt(target - tot)} to go`}</span><span>Billable <b>{tot ? Math.round((billable / tot) * 100) : 0}%</b></span></div>
</div>
)
}.sd-tms { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-tms[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-tms :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-tms :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-tms .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-tms { width: min(100%, 350px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-tms__wk { display: flex; justify-content: space-between; align-items: center; font-size: 13px; font-weight: 600; }
.sd-tms__wk span { font-size: 11px; color: var(--mute); font-weight: 400; }
.sd-tms table { width: 100%; border-collapse: separate; border-spacing: 3px; table-layout: fixed; margin: -3px; width: calc(100% + 6px); }
.sd-tms th { font-size: 10px; font-weight: 500; color: var(--mute); text-align: center; padding: 0 0 2px; }
.sd-tms th:first-child { width: 30%; text-align: left; }
.sd-tms th:last-child, .sd-tms td:last-child { width: 14%; }
.sd-tms tbody th { text-align: left; font-size: 11.5px; color: var(--fg); font-weight: 500; }
.sd-tms tbody th button { display: flex; align-items: center; gap: 5px; text-align: left; max-width: 100%; }
.sd-tms tbody th button i { flex: none; width: 9px; height: 9px; border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--mute); font-size: 0; }
.sd-tms tbody th button[aria-pressed='true'] i { box-shadow: inset 0 0 0 5px var(--fg); }
.sd-tms tbody th button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-tms td input { width: 100%; height: 32px; border: 0; border-radius: 7px; background: var(--soft); color: var(--fg); font: inherit; font-size: 12px; text-align: center; padding: 0; outline: 0; font-variant-numeric: tabular-nums; }
.sd-tms td input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-tms td input[data-bad] { box-shadow: inset 0 0 0 1.5px var(--mute); text-decoration: underline wavy; }
.sd-tms td.t, .sd-tms tfoot td { text-align: center; font-size: 11.5px; font-weight: 600; font-variant-numeric: tabular-nums; }
.sd-tms tfoot td { color: var(--mute); font-weight: 500; padding-top: 2px; }
.sd-tms tfoot td:first-child { text-align: left; }
.sd-tms__bar { height: 8px; border-radius: 4px; background: var(--soft); display: flex; overflow: hidden; }
.sd-tms__bar i { height: 100%; background: var(--fg); transition: width 200ms; }
.sd-tms__bar i + i { background: var(--mute); }
.sd-tms__f { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--mute); }
.sd-tms__f b { color: var(--fg); }
@media (prefers-reduced-motion: reduce) { .sd-tms *, .sd-tms *::before, .sd-tms *::after { animation: none !important; transition: none !important; } }Overview
Each cell takes hours or h:mm; arrow keys move around the grid like a spreadsheet. Totals recalculate as you type.
Projects can be marked billable, and the footer shows billable hours, the weekly total against target and overtime.
When to use
- Agency and freelancer tools, payroll prep, HR portals.
When not to use
- Live timers: use a time tracker.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/timesheet.json$
npx shadcn@latest add https://sundaeui.dev/r/timesheet.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/timesheet.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/timesheet.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy timesheet.tsx and timesheet.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { Timesheet } from '@/components/sundae/timesheet'
<Timesheet />Props
| Prop | Type | Default |
|---|---|---|
targetWeekly hours target. | number | 40 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each cell input is named by project and day.
- Arrow keys move between cells; totals sit in a live region.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-tms; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Arrow keys
- Move between cells
- Enter
- Move down a row
FAQ
- Is Timesheet free?
- Yes. Timesheet is free for personal and commercial projects, with full source on this page.
- How do I install Timesheet?
- Run npx shadcn@latest add https://sundaeui.dev/r/timesheet.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Timesheet support keyboard and screen readers?
- Each cell input is named by project and day. Arrow keys move between cells; totals sit in a live region.
- Does Timesheet respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Week Strip
Forms · RefinedAvailability Grid
Forms · RefinedShift Roster
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined