OKR Tracker
Objectives with key results: progress rolls up from each KR, confidence per KR, and quick check-ins that nudge values.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/okr-tracker.json'use client'
import { useState } from 'react'
import './okr-tracker.css'
export interface KeyResult { id: string; title: string; value: number; target: number; unit?: string; conf: 1 | 2 | 3 }
export interface Objective { id: string; title: string; krs: KeyResult[] }
export interface OkrTrackerProps {
objectives: Objective[]
quarter?: string
openAll?: boolean
onChange?: (o: Objective[]) => void
fill?: 'dark' | 'light'
className?: string
}
const prog = (k: KeyResult) => Math.max(0, Math.min(1, k.value / k.target))
const avg = (o: Objective) => o.krs.reduce((a, k) => a + prog(k), 0) / (o.krs.length || 1)
const CONF = ['', 'Low', 'Medium', 'High']
/** Objectives and key results with check-ins. */
export function OkrTracker({ objectives, quarter = 'Q4 2026', openAll = false, onChange, fill = 'dark', className }: OkrTrackerProps) {
const [os, setOs] = useState(objectives)
const [open, setOpen] = useState<string[]>(openAll ? objectives.map((o) => o.id) : [objectives[0]?.id])
const [ci, setCi] = useState<{ kr: string; v: number } | null>(null)
const total = os.reduce((a, o) => a + avg(o), 0) / (os.length || 1)
const step = (k: KeyResult) => Math.max(1, Math.round(k.target / 20))
const save = (oid: string, kid: string, v: number) => { const n = os.map((o) => (o.id === oid ? { ...o, krs: o.krs.map((k) => (k.id === kid ? { ...k, value: v } : k)) } : o)); setOs(n); onChange?.(n); setCi(null) }
return (
<div className={['sd-okr', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-okr__top"><div><b>Team OKRs</b><small>{quarter} · week 5 of 13</small></div><strong aria-label={`Overall ${Math.round(total * 100)} percent`}>{(total * 1).toFixed(2)}</strong></div>
{os.map((o) => {
const p = avg(o), C = 2 * Math.PI * 14, isOpen = open.includes(o.id)
return (
<div className="sd-okr__o" key={o.id}>
<button type="button" className="sd-okr__ob" aria-expanded={isOpen} aria-label={`${o.title}, ${Math.round(p * 100)}%`} onClick={() => setOpen(isOpen ? open.filter((x) => x !== o.id) : [...open, o.id])}>
<span className="sd-okr__ring" aria-hidden><svg viewBox="0 0 34 34"><circle className="bg" cx="17" cy="17" r="14" /><circle className="fg" cx="17" cy="17" r="14" strokeDasharray={C} strokeDashoffset={C * (1 - p)} /></svg><b>{Math.round(p * 100)}</b></span>
<span>{o.title}</span><i aria-hidden>›</i>
</button>
{isOpen && <ul>
{o.krs.map((k) => (
<li key={k.id}>
<div className="sd-okr__kt"><span>{k.title}</span><b>{k.value}{k.unit} / {k.target}{k.unit}</b></div>
<div className="sd-okr__bar" role="meter" aria-label={k.title} aria-valuemin={0} aria-valuemax={k.target} aria-valuenow={k.value}><i style={{ width: `${prog(k) * 100}%` }} /></div>
{ci?.kr === k.id ? (
<div className="sd-okr__ci">
<button type="button" aria-label="Decrease" onClick={() => setCi({ kr: k.id, v: Math.max(0, ci.v - step(k)) })}>−</button>
<output aria-live="polite">{ci.v}{k.unit}</output>
<button type="button" aria-label="Increase" onClick={() => setCi({ kr: k.id, v: ci.v + step(k) })}>+</button>
<button type="button" onClick={() => setCi(null)}>Cancel</button>
<button type="button" data-pri onClick={() => save(o.id, k.id, ci.v)}>Save</button>
</div>
) : (
<div className="sd-okr__km"><span className="sd-okr__cf" aria-hidden>{[1, 2, 3].map((n) => <u key={n} data-on={n <= k.conf || undefined} />)}</span>{CONF[k.conf]} confidence<button type="button" onClick={() => setCi({ kr: k.id, v: k.value })}>Check in</button></div>
)}
</li>
))}
</ul>}
</div>
)
})}
</div>
)
}.sd-okr { --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-okr[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-okr :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-okr :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-okr .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-okr { 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: 8px; }
.sd-okr__top { display: flex; justify-content: space-between; align-items: flex-end; padding-bottom: 4px; }
.sd-okr__top b { font-size: 15px; display: block; }
.sd-okr__top small { color: var(--mute); font-size: 12px; }
.sd-okr__top strong { font-size: 26px; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.sd-okr__o { border-radius: 14px; box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-okr__ob { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 12px; text-align: left; }
.sd-okr__ob span { flex: 1; min-width: 0; font-size: 13.5px; font-weight: 600; }
.sd-okr__ring { width: 34px; height: 34px; flex: none; position: relative; }
.sd-okr__ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.sd-okr__ring circle { fill: none; stroke-width: 3.5; }
.sd-okr__ring .bg { stroke: var(--line); }
.sd-okr__ring .fg { stroke: var(--fg); stroke-linecap: round; transition: stroke-dashoffset 400ms; }
.sd-okr__ring b { position: absolute; inset: 0; display: grid; place-items: center; font-size: 10.5px; font-variant-numeric: tabular-nums; }
.sd-okr__ob i { font-style: normal; color: var(--mute); transition: transform 200ms; }
.sd-okr__ob[aria-expanded='true'] i { transform: rotate(90deg); }
.sd-okr ul { list-style: none; margin: 0; padding: 0 12px 10px; display: grid; gap: 10px; }
.sd-okr li { display: grid; gap: 5px; }
.sd-okr__kt { display: flex; gap: 8px; font-size: 12.5px; }
.sd-okr__kt span { flex: 1; min-width: 0; }
.sd-okr__kt b { font-variant-numeric: tabular-nums; white-space: nowrap; }
.sd-okr__bar { height: 5px; border-radius: 3px; background: var(--line); overflow: hidden; }
.sd-okr__bar i { display: block; height: 100%; background: var(--fg); transition: width 400ms; }
.sd-okr__km { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--mute); }
.sd-okr__cf { display: flex; gap: 2px; }
.sd-okr__cf u { width: 8px; height: 4px; border-radius: 1px; background: var(--line); text-decoration: none; }
.sd-okr__cf u[data-on] { background: var(--fg); }
.sd-okr__km button { margin-left: auto; font-size: 11.5px; font-weight: 600; color: var(--fg) !important; text-decoration: underline; text-underline-offset: 2px; }
.sd-okr__ci { display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 10px; background: var(--soft); font-size: 12px; }
.sd-okr__ci button { height: 28px; min-width: 28px; padding: 0 8px; border-radius: 7px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); background: var(--bg) !important; }
.sd-okr__ci output { flex: 1; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }
.sd-okr__ci button[data-pri] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
@media (prefers-reduced-motion: reduce) { .sd-okr *, .sd-okr *::before, .sd-okr *::after { animation: none !important; transition: none !important; } }Overview
Each objective is a disclosure whose ring is the average of its key results. Key results show current versus target and a confidence level of low, medium or high.
Check-in opens a small stepper on a key result; saving updates the ring and the overall score.
When to use
- Quarterly planning, team pages, 1:1 notes.
When not to use
- Task tracking: use a task list.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/okr-tracker.json$
npx shadcn@latest add https://sundaeui.dev/r/okr-tracker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/okr-tracker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/okr-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 okr-tracker.tsx and okr-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 { OkrTracker } from '@/components/sundae/okr-tracker'
<OkrTracker />Props
| Prop | Type | Default |
|---|---|---|
objectives*Objectives. | { id: string; title: string; krs: { id: string; title: string; value: number; target: number; unit?: string; conf: 1 | 2 | 3 }[] }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Objectives are disclosure buttons with their progress in the label.
- Each KR has a meter; confidence is text plus a three-step marker.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-okr; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is OKR Tracker free?
- Yes. OKR Tracker is free for personal and commercial projects, with full source on this page.
- How do I install OKR Tracker?
- Run npx shadcn@latest add https://sundaeui.dev/r/okr-tracker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does OKR Tracker support keyboard and screen readers?
- Objectives are disclosure buttons with their progress in the label. Each KR has a meter; confidence is text plus a three-step marker.
- Does OKR Tracker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →- Pro
Activity Rings
Data · Expressive Savings Goal
Data · Refined- Pro
Task Timeline
Data · Refined Org Chart
Data · Refined- Pro
Gantt Timeline
Data · Refined - Pro
Bullet KPIs
Data · Refined