Standup Notes
Async standup on one card: yesterday, today and blockers per teammate, flip through people, and post your own update.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/standup-notes.json'use client'
import { useState } from 'react'
import './standup-notes.css'
export interface StandupUpdate { id: string; name: string; y: string; t: string; b?: string }
export interface StandupNotesProps {
updates: StandupUpdate[]
showMine?: boolean
onPost?: (u: StandupUpdate) => void
fill?: 'dark' | 'light'
className?: string
}
/** Async standup reader and composer. */
export function StandupNotes({ updates, showMine = false, onPost, fill = 'dark', className }: StandupNotesProps) {
const [list, setList] = useState(updates)
const [sel, setSel] = useState(showMine ? 'me' : updates[0]?.id)
const [f, setF] = useState({ y: '', t: '', b: '' })
const [seen, setSeen] = useState<string[]>([updates[0]?.id])
const mine = list.find((u) => u.id === 'me')
const cur = list.find((u) => u.id === sel)
const blockers = list.filter((u) => u.b).length
return (
<div className={['sd-std', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-std__h"><b>Standup · Wed</b><small>{list.length} posted · {blockers} blocker{blockers === 1 ? '' : 's'}</small></div>
<div className="sd-std__ppl" role="tablist" aria-label="Teammates">
{!mine && <button type="button" role="tab" aria-selected={sel === 'me'} data-new onClick={() => setSel('me')}><i>+</i>You</button>}
{list.map((u) => <button key={u.id} type="button" role="tab" aria-selected={sel === u.id} onClick={() => { setSel(u.id); setSeen((s) => [...new Set([...s, u.id])]) }}><i>{u.name[0]}{!seen.includes(u.id) && u.id !== 'me' && <u aria-label="unread" />}</i>{u.id === 'me' ? 'You' : u.name.split(' ')[0]}</button>)}
</div>
{sel === 'me' && !mine ? (
<div className="sd-std__f" role="tabpanel">
{([['y', 'Yesterday'], ['t', 'Today'], ['b', 'Blockers (optional)']] as const).map(([k, l]) => <label key={k}>{l}<textarea value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })} /></label>)}
<button type="button" className="sd-std__go" disabled={!f.y.trim() || !f.t.trim()} onClick={() => { const u = { id: 'me', name: 'You', y: f.y.trim(), t: f.t.trim(), b: f.b.trim() || undefined }; setList([u, ...list]); setSel('me'); onPost?.(u) }}>Post update</button>
</div>
) : cur && (
<div className="sd-std__sec" key={cur.id} role="tabpanel" aria-label={cur.name}>
<section><h5>Yesterday</h5><p>{cur.y}</p></section>
<section><h5>Today</h5><p>{cur.t}</p></section>
{cur.b ? <section data-block><h5>Blocker</h5><p>{cur.b}</p></section> : <section><h5>Blockers</h5><p style={{ color: 'var(--mute)' }}>None</p></section>}
</div>
)}
</div>
)
}.sd-std { --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-std[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-std :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-std :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-std .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-std { 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: 12px; }
.sd-std__h { display: flex; justify-content: space-between; align-items: baseline; }
.sd-std__h b { font-size: 15px; }
.sd-std__h small { color: var(--mute); font-size: 12px; }
.sd-std__ppl { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; padding: 2px; }
.sd-std__ppl button { flex: none; display: grid; justify-items: center; gap: 3px; font-size: 11px; color: var(--mute) !important; width: 50px; }
.sd-std__ppl button i { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; font-style: normal; font-weight: 700; font-size: 13px; background: var(--soft); color: var(--fg); position: relative; }
.sd-std__ppl button[aria-selected='true'] { color: var(--fg) !important; font-weight: 600; }
.sd-std__ppl button[aria-selected='true'] i { background: var(--strong) !important; color: var(--on) !important; }
.sd-std__ppl button i u { position: absolute; right: -1px; top: -1px; width: 10px; height: 10px; border-radius: 50%; background: var(--bg); box-shadow: inset 0 0 0 2.5px var(--fg); text-decoration: none; }
.sd-std__ppl button[data-new] i { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-std__sec { display: grid; gap: 8px; animation: sd-std-in 200ms ease-out; }
@keyframes sd-std-in { from { opacity: 0; transform: translateX(6px); } }
.sd-std__sec section { padding: 10px 12px; border-radius: 12px; background: var(--soft); }
.sd-std__sec section[data-block] { background: var(--bg); box-shadow: inset 0 0 0 2px var(--fg); }
.sd-std__sec h5 { margin: 0 0 3px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--mute); }
.sd-std__sec section[data-block] h5 { color: var(--fg); }
.sd-std__sec p { margin: 0; font-size: 13px; }
.sd-std__f { display: grid; gap: 6px; }
.sd-std__f label { display: grid; gap: 3px; font-size: 11.5px; color: var(--mute); }
.sd-std__f textarea { height: 46px; padding: 7px 10px; border: 0; border-radius: 9px; background: var(--soft); color: var(--fg); font: inherit; font-size: 12.5px; resize: none; outline: 0; }
.sd-std__f textarea:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-std__go { height: 40px; border-radius: 11px; font-size: 13px; font-weight: 600;background: var(--strong) !important; color: var(--on) !important; }
.sd-std__go:disabled { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) { .sd-std *, .sd-std *::before, .sd-std *::after { animation: none !important; transition: none !important; } }Overview
Teammates are avatars along the top; the selected person’s update shows in three short sections. Blockers get a heavy outline so they stand out in a skim.
Your own tab is a small form with the same three fields and a Post button that adds you to the row.
When to use
- Team hubs, Slack-style apps, project dashboards.
When not to use
- Long written reports: use a doc.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/standup-notes.json$
npx shadcn@latest add https://sundaeui.dev/r/standup-notes.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/standup-notes.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/standup-notes.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 standup-notes.tsx and standup-notes.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 { StandupNotes } from '@/components/sundae/standup-notes'
<StandupNotes />Props
| Prop | Type | Default |
|---|---|---|
updates*Team updates. | { id: string; name: string; y: string; t: string; b?: string }[] | — |
onPostCalled on post. | (u) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- People are tabs; sections use headings.
- Blockers are labelled as blockers, not only outlined.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-std; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Standup Notes free?
- Yes. Standup Notes is free for personal and commercial projects, with full source on this page.
- How do I install Standup Notes?
- Run npx shadcn@latest add https://sundaeui.dev/r/standup-notes.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Standup Notes support keyboard and screen readers?
- People are tabs; sections use headings. Blockers are labelled as blockers, not only outlined.
- Does Standup Notes respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Status Picker
Forms · RefinedInvite Members
Forms · RefinedAsync Switch
Forms · RefinedAssignee Picker
Forms · RefinedStatus Pill
Forms · RefinedRetro Board
Forms · Refined