Retro Board
A sprint retro in a card: went well, to improve and actions as tabs, sticky notes with dot votes, sorted by votes.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/retro-board.json'use client'
import { useState } from 'react'
import './retro-board.css'
export interface RetroNote { id: string; col: string; text: string; votes: number }
export interface RetroBoardProps {
notes: RetroNote[]
votes?: number
fill?: 'dark' | 'light'
className?: string
}
const COLS = [['well', 'Went well'], ['improve', 'To improve'], ['action', 'Actions']] as const
/** Tabbed retro with dot voting. */
export function RetroBoard({ notes: n0, votes = 5, fill = 'dark', className }: RetroBoardProps) {
const [notes, setNotes] = useState(n0)
const [tab, setTab] = useState<string>('well')
const [mine, setMine] = useState<Set<string>>(new Set())
const [draft, setDraft] = useState('')
const left = votes - mine.size
const list = notes.filter((n) => n.col === tab).sort((a, b) => b.votes + (mine.has(b.id) ? 1 : 0) - (a.votes + (mine.has(a.id) ? 1 : 0)))
const vote = (id: string) => setMine((s) => { const x = new Set(s); x.has(id) ? x.delete(id) : x.size < votes && x.add(id); return x })
return (
<div className={['sd-rtr', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-rtr__tabs" role="tablist">{COLS.map(([id, l]) => <button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}>{l}<small>{notes.filter((n) => n.col === id).length}</small></button>)}</div>
<div className="sd-rtr__v" aria-live="polite">{Array.from({ length: votes }, (_, i) => <i key={i} data-used={i >= left || undefined} />)}<span>{left} {left === 1 ? 'vote' : 'votes'} left</span></div>
<ul role="tabpanel">{list.map((n) => { const v = n.votes + (mine.has(n.id) ? 1 : 0); return (
<li key={n.id}><div><p>{n.text}</p>{tab === 'improve' && <small><button type="button" onClick={() => { setNotes(notes.concat({ id: `${n.id}-a`, col: 'action', text: `Fix: ${n.text}`, votes: 0 })); setTab('action') }} disabled={notes.some((x) => x.id === `${n.id}-a`)}>{notes.some((x) => x.id === `${n.id}-a`) ? 'Action added' : 'Make an action'}</button></small>}</div>
<button type="button" className="sd-rtr__dot" aria-pressed={mine.has(n.id)} aria-label={`Vote for ${n.text}, ${v} votes`} disabled={!mine.has(n.id) && left === 0} onClick={() => vote(n.id)}><i />{v}</button></li>
) })}</ul>
<form onSubmit={(e) => { e.preventDefault(); const t = draft.trim(); if (!t) return; setNotes([...notes, { id: `n${notes.length}-${Date.now()}`, col: tab, text: t, votes: 0 }]); setDraft('') }}><input aria-label="New note" placeholder={`Add to ${COLS.find((c) => c[0] === tab)![1].toLowerCase()}`} value={draft} onChange={(e) => setDraft(e.target.value)} /><button type="submit" disabled={!draft.trim()}>Add</button></form>
</div>
)
}.sd-rtr { --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-rtr[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-rtr :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-rtr :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rtr .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-rtr { width: min(100%, 340px); 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-rtr__tabs { display: flex; gap: 2px; padding: 2px; border-radius: 10px; background: var(--soft); }
.sd-rtr__tabs button { flex: 1; height: 32px; border-radius: 8px; font-size: 12px; color: var(--mute) !important; }
.sd-rtr__tabs button[aria-selected='true'] { background: var(--bg) !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1px var(--line); }
.sd-rtr__tabs small { margin-left: 4px; font-variant-numeric: tabular-nums; }
.sd-rtr__v { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--mute); }
.sd-rtr__v i { width: 9px; height: 9px; border-radius: 50%; background: var(--fg); }
.sd-rtr__v i[data-used] { background: none; box-shadow: inset 0 0 0 1.5px var(--line); }
.sd-rtr__v span { margin-left: auto; }
.sd-rtr ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; min-height: 150px; align-content: start; }
.sd-rtr li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: start; padding: 10px; border-radius: 12px; background: var(--soft); font-size: 13px; animation: sd-rtr-in 220ms ease-out; }
@keyframes sd-rtr-in { from { transform: translateY(6px); opacity: 0; } }
.sd-rtr li p { margin: 0; }
.sd-rtr li small { display: block; margin-top: 4px; }
.sd-rtr li small button { font-size: 11.5px; color: var(--mute) !important; text-decoration: underline; text-underline-offset: 2px; }
.sd-rtr__dot { display: flex; align-items: center; gap: 5px; height: 28px; padding: 0 9px; border-radius: 99px; background: var(--bg) !important; box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.sd-rtr__dot i { width: 7px; height: 7px; border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-rtr__dot[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-rtr__dot[aria-pressed='true'] i { background: var(--on); box-shadow: none; }
.sd-rtr__dot:disabled { opacity: 0.4; }
.sd-rtr form { display: flex; gap: 6px; }
.sd-rtr form input { flex: 1; min-width: 0; height: 38px; padding: 0 10px; border-radius: 10px; border: 0; background: var(--soft); color: var(--fg); font: 15px ui-sans-serif, system-ui; outline: 0; }
.sd-rtr form input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-rtr form button { padding: 0 14px; border-radius: 10px; font-size: 13px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-rtr form button:disabled { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) { .sd-rtr *, .sd-rtr *::before, .sd-rtr *::after { animation: none !important; transition: none !important; } }Overview
Each tab holds short notes. Everyone gets a fixed number of dot votes to spread across notes; the counter shows what is left.
Notes sort by votes so the discussion order is obvious, and any note can be turned into an action in one tap.
When to use
- Team rituals, workshop tools, async retros.
When not to use
- Large boards with grouping: use a full canvas.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/retro-board.json$
npx shadcn@latest add https://sundaeui.dev/r/retro-board.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/retro-board.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/retro-board.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 retro-board.tsx and retro-board.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 { RetroBoard } from '@/components/sundae/retro-board'
<RetroBoard />Props
| Prop | Type | Default |
|---|---|---|
notes*Starting notes. | { id: string; col: string; text: string; votes: number }[] | — |
votesDot votes per person. | number | 5 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Columns are tabs; votes are toggle buttons with their count in the label.
- Votes remaining are announced politely.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-rtr; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Retro Board free?
- Yes. Retro Board is free for personal and commercial projects, with full source on this page.
- How do I install Retro Board?
- Run npx shadcn@latest add https://sundaeui.dev/r/retro-board.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Retro Board support keyboard and screen readers?
- Columns are tabs; votes are toggle buttons with their count in the label. Votes remaining are announced politely.
- Does Retro Board respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Invite Members
Forms · RefinedAssignee Picker
Forms · RefinedStandup Notes
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined