Time Tracker
Track time per project: one running timer at a time, switch projects in a tap, today’s entries and a daily total.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/time-tracker.json'use client'
import { useEffect, useRef, useState } from 'react'
import './time-tracker.css'
export interface TimeEntry { id: string; project: string; secs: number; at: string }
export interface TimeTrackerProps {
projects: { id: string; name: string }[]
entries?: TimeEntry[]
goal?: number
speed?: number
fill?: 'dark' | 'light'
className?: string
}
const clk = (s: number) => `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`
const hm = (s: number) => `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
/** Per-project time tracker with one running timer. */
export function TimeTracker({ projects, entries = [], goal = 6, speed = 1, fill = 'dark', className }: TimeTrackerProps) {
const [log, setLog] = useState(entries)
const [run, setRun] = useState<{ p: string; secs: number } | null>(null)
const n = useRef(0)
useEffect(() => {
if (!run) return
const id = setInterval(() => setRun((r) => (r ? { ...r, secs: r.secs + speed / 4 } : r)), 250)
return () => clearInterval(id)
}, [run?.p, speed])
const stop = () => { if (run && run.secs >= 1) setLog((l) => [{ id: `e${n.current++}`, project: run.p, secs: Math.round(run.secs), at: 'now' }, ...l]); setRun(null) }
const start = (p: string) => { if (run?.p === p) return stop(); stop(); setRun({ p, secs: 0 }) }
const per = (p: string) => log.filter((e) => e.project === p).reduce((a, e) => a + e.secs, 0) + (run?.p === p ? run.secs : 0)
const total = projects.reduce((a, p) => a + per(p.id), 0)
const left = goal * 3600 - total
return (
<div className={['sd-tmt', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-tmt__top"><div><small>Today</small><b role="timer">{clk(total)}</b></div><span>{left > 0 ? `${hm(left)} to your ${goal}h goal` : 'Goal reached'}</span></div>
<div className="sd-tmt__split" aria-hidden>{projects.map((p) => <i key={p.id} style={{ width: `${(per(p.id) / Math.max(total, goal * 3600)) * 100}%` }} />)}</div>
<ul className="sd-tmt__p">
{projects.map((p) => {
const on = run?.p === p.id
return (
<li key={p.id} data-run={on || undefined}>
<span>{p.name}<small>{on ? `Running ${clk(run!.secs)}` : `${log.filter((e) => e.project === p.id).length} entries`}</small></span>
<output>{hm(per(p.id))}</output>
<button type="button" className="sd-tmt__b" aria-pressed={on} aria-label={`${on ? 'Stop' : 'Start'} ${p.name}`} onClick={() => start(p.id)}>{on ? <svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" rx="1" /></svg> : <svg viewBox="0 0 12 12"><path d="M3 1.5v9l7.5-4.5z" /></svg>}</button>
</li>
)
})}
</ul>
<h5>Entries</h5>
<ul className="sd-tmt__log" aria-live="polite">
{log.map((e) => <li key={e.id}><span>{projects.find((p) => p.id === e.project)?.name} · {e.at}</span><b>{hm(e.secs)}</b><button type="button" aria-label="Delete entry" onClick={() => setLog((l) => l.filter((x) => x.id !== e.id))}>×</button></li>)}
</ul>
</div>
)
}.sd-tmt { --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-tmt[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-tmt :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-tmt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-tmt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-tmt { width: min(100%, 330px); 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: 12px; }
.sd-tmt__top { display: flex; justify-content: space-between; align-items: flex-end; }
.sd-tmt__top small { display: block; color: var(--mute); font-size: 12px; }
.sd-tmt__top b { font-size: 30px; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.sd-tmt__top span { font-size: 12px; color: var(--mute); text-align: right; }
.sd-tmt__split { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--line); gap: 2px; }
.sd-tmt__split i { height: 100%; transition: width 400ms; }
.sd-tmt__split i:nth-child(1) { background: var(--fg); }
.sd-tmt__split i:nth-child(2) { background: color-mix(in srgb, var(--fg) 62%, var(--bg)); }
.sd-tmt__split i:nth-child(3) { background: color-mix(in srgb, var(--fg) 38%, var(--bg)); }
.sd-tmt__split i:nth-child(4) { background: color-mix(in srgb, var(--fg) 22%, var(--bg)); }
.sd-tmt__p { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.sd-tmt__p li { display: flex; align-items: center; gap: 10px; padding: 8px 8px 8px 12px; border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-tmt__p li[data-run] { box-shadow: inset 0 0 0 2px var(--fg); }
.sd-tmt__p li > span { flex: 1; min-width: 0; font-size: 13.5px; font-weight: 600; }
.sd-tmt__p li small { display: block; font-weight: 400; color: var(--mute); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.sd-tmt__p li output { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; }
.sd-tmt__b { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; box-shadow: inset 0 0 0 1px var(--line); flex: none; }
.sd-tmt__b[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-tmt__b svg { width: 12px; height: 12px; fill: currentColor; }
.sd-tmt__log { list-style: none; margin: 0; padding: 0; max-height: 120px; overflow-y: auto; font-size: 12px; }
.sd-tmt__log li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); color: var(--mute); }
.sd-tmt__log li b { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
.sd-tmt__log li button { width: 20px; height: 20px; border-radius: 5px; color: var(--mute) !important; }
.sd-tmt__log li span { flex: 1; }
.sd-tmt h5 { margin: 0; font-size: 11px; font-weight: 600; color: var(--mute); text-transform: uppercase; letter-spacing: 0.06em; }
@media (prefers-reduced-motion: reduce) { .sd-tmt *, .sd-tmt *::before, .sd-tmt *::after { animation: none !important; transition: none !important; } }Overview
Starting a project stops whatever was running and logs it as an entry. The running row shows a live clock; the total bar splits the day by project.
Entries can be deleted; the daily goal line says how far you are from it.
When to use
- Freelance and agency tools, personal productivity.
When not to use
- Payroll time clocks with approvals.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/time-tracker.json$
npx shadcn@latest add https://sundaeui.dev/r/time-tracker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/time-tracker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/time-tracker.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 time-tracker.tsx and time-tracker.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 { TimeTracker } from '@/components/sundae/time-tracker'
<TimeTracker />Props
| Prop | Type | Default |
|---|---|---|
projects*Projects. | { id: string; name: string }[] | — |
entriesLogged today. | { project: string; secs: number }[] | — |
goalDaily goal in hours. | number | 6 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each project row has a start/stop button labelled with the project.
- The running time is a timer; totals are text.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-tmt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Time Tracker free?
- Yes. Time Tracker is free for personal and commercial projects, with full source on this page.
- How do I install Time Tracker?
- Run npx shadcn@latest add https://sundaeui.dev/r/time-tracker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Time Tracker support keyboard and screen readers?
- Each project row has a start/stop button labelled with the project. The running time is a timer; totals are text.
- Does Time Tracker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Focus Timer
Components · Refined Task List
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined