Feedback Pins
Design review on a screen: tap anywhere to drop a numbered pin, write the note, then resolve pins as they are fixed.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/feedback-pins.json'use client'
import { useState } from 'react'
import './feedback-pins.css'
export interface FeedbackPin { id: number; x: number; y: number; text: string; done?: boolean }
export interface FeedbackPinsProps {
pins?: FeedbackPin[]
filter?: 'open' | 'all'
fill?: 'dark' | 'light'
className?: string
}
/** Pin-based design feedback on a mock screen. */
export function FeedbackPins({ pins: p0 = [], filter: f0 = 'all', fill = 'dark', className }: FeedbackPinsProps) {
const [pins, setPins] = useState(p0)
const [sel, setSel] = useState<number | null>(null)
const [draft, setDraft] = useState<{ x: number; y: number } | null>(null)
const [text, setText] = useState('')
const [filter, setFilter] = useState(f0)
const next = pins.reduce((a, p) => Math.max(a, p.id), 0) + 1
const drop = (x: number, y: number) => { setDraft({ x, y }); setText(''); setSel(null) }
const save = () => { if (!draft || !text.trim()) { setDraft(null); return } setPins([...pins, { id: next, ...draft, text: text.trim() }]); setSel(next); setDraft(null) }
const list = pins.filter((p) => filter === 'all' || !p.done).sort((a, b) => Number(!!a.done) - Number(!!b.done) || a.id - b.id)
return (
<div className={['sd-fbp', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-fbp__cv" tabIndex={0} aria-label="Screen. Click to add a pin." onKeyDown={(e) => { if (e.key === 'Enter' && e.target === e.currentTarget) drop(50, 50) }}
onClick={(e) => { if (e.target !== e.currentTarget && !(e.target as Element).closest('svg')) return; const r = e.currentTarget.getBoundingClientRect(); drop(Math.round(((e.clientX - r.left) / r.width) * 100), Math.round(((e.clientY - r.top) / r.height) * 100)) }}>
<svg viewBox="0 0 400 300" aria-hidden fill="none" stroke="currentColor" strokeWidth="2">
<rect x="20" y="18" width="80" height="12" rx="6" fill="currentColor" stroke="none" opacity="0.5" /><rect x="300" y="14" width="80" height="22" rx="11" />
<rect x="20" y="58" width="220" height="20" rx="4" fill="currentColor" stroke="none" opacity="0.7" /><rect x="20" y="88" width="170" height="10" rx="5" fill="currentColor" stroke="none" opacity="0.35" />
<rect x="20" y="124" width="112" height="150" rx="10" /><rect x="144" y="124" width="112" height="150" rx="10" strokeWidth="3" /><rect x="268" y="124" width="112" height="150" rx="10" />
{[32, 156, 280].map((x) => <g key={x}><rect x={x} y="140" width="50" height="8" rx="4" fill="currentColor" stroke="none" opacity="0.5" /><rect x={x} y="158" width="70" height="16" rx="4" fill="currentColor" stroke="none" opacity="0.8" /><rect x={x} y="240" width="88" height="22" rx="8" /></g>)}
</svg>
{pins.filter((p) => filter === 'all' || !p.done).map((p) => <button key={p.id} type="button" className="sd-fbp__pin" data-done={p.done || undefined} aria-pressed={sel === p.id} aria-label={`Pin ${p.id}: ${p.text}`} style={{ left: `${p.x}%`, top: `${p.y}%` }} onClick={(e) => { e.stopPropagation(); setSel(sel === p.id ? null : p.id) }}>{p.id}</button>)}
{draft && <>
<span className="sd-fbp__pin" style={{ left: `${draft.x}%`, top: `${draft.y}%` }} aria-hidden>{next}</span>
<form className="sd-fbp__pop" style={{ left: `clamp(4px, calc(${draft.x}% - 90px), calc(100% - 184px))`, top: draft.y > 60 ? `calc(${draft.y}% - 74px)` : `calc(${draft.y}% + 8px)` }} onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); save() }}>
<input autoFocus aria-label={`Note for pin ${next}`} placeholder="Add a note" value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === 'Escape' && setDraft(null)} /><button type="submit">Pin</button>
</form></>}
</div>
<div className="sd-fbp__bar"><span aria-live="polite">{pins.filter((p) => !p.done).length} open · {pins.filter((p) => p.done).length} resolved</span><div className="sd-fbp__seg" role="radiogroup" aria-label="Filter">{(['open', 'all'] as const).map((f) => <button key={f} type="button" role="radio" aria-checked={filter === f} onClick={() => setFilter(f)}>{f === 'open' ? 'Open' : 'All'}</button>)}</div></div>
<ul>{list.map((p) => <li key={p.id} data-sel={sel === p.id || undefined} data-done={p.done || undefined}><b>{p.id}</b><span onClick={() => setSel(p.id)}>{p.text}</span><button type="button" onClick={() => setPins(pins.map((x) => (x.id === p.id ? { ...x, done: !x.done } : x)))}>{p.done ? 'Reopen' : 'Resolve'}</button></li>)}</ul>
</div>
)
}.sd-fbp { --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-fbp[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-fbp :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-fbp :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-fbp .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-fbp { width: min(100%, 340px); padding: 12px; 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-fbp__cv { position: relative; aspect-ratio: 4 / 3; border-radius: 12px; background: var(--soft); overflow: hidden; cursor: crosshair; outline: 0; }
.sd-fbp__cv:focus-visible { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-fbp__cv svg { position: absolute; inset: 0; width: 100%; height: 100%; color: var(--mute); }
.sd-fbp__pin { position: absolute; width: 26px; height: 26px; margin: -26px 0 0 -2px; border-radius: 13px 13px 13px 2px; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; background: var(--strong) !important; color: var(--on) !important; animation: sd-fbp-drop 260ms cubic-bezier(0.3, 1.4, 0.5, 1); transition: transform 140ms; }
@keyframes sd-fbp-drop { from { transform: translateY(-10px) scale(0.6); opacity: 0; } }
.sd-fbp__pin[data-done] { background: var(--bg) !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-fbp__pin[aria-pressed='true'] { transform: scale(1.18); }
.sd-fbp__pop { position: absolute; z-index: 2; width: 180px; display: flex; gap: 4px; padding: 4px; border-radius: 10px; background: var(--bg); box-shadow: 0 0 0 1px var(--line), 0 8px 24px rgb(0 0 0 / 0.35); }
.sd-fbp__pop input { flex: 1; min-width: 0; height: 30px; padding: 0 8px; border-radius: 7px; border: 0; background: var(--soft); color: var(--fg); font: 15px ui-sans-serif, system-ui; outline: 0; }
.sd-fbp__pop button { padding: 0 8px; border-radius: 7px; font-size: 12px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-fbp__bar { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--mute); }
.sd-fbp__seg { display: flex; padding: 2px; border-radius: 8px; background: var(--soft); }
.sd-fbp__seg button { height: 24px; padding: 0 10px; border-radius: 6px; font-size: 11.5px; color: var(--mute) !important; }
.sd-fbp__seg button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1px var(--line); }
.sd-fbp ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; max-height: 150px; overflow-y: auto; }
.sd-fbp li { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 6px 6px; border-radius: 9px; font-size: 12.5px; }
.sd-fbp li[data-sel] { background: var(--soft); }
.sd-fbp li > b { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-fbp li[data-done] span { color: var(--mute); text-decoration: line-through; }
.sd-fbp li button { height: 26px; padding: 0 8px; border-radius: 7px; font-size: 11.5px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-fbp li > span { cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .sd-fbp *, .sd-fbp *::before, .sd-fbp *::after { animation: none !important; transition: none !important; } }Overview
Tapping the canvas drops a pin and opens a small note field anchored to it. Notes are listed under the canvas with their number; selecting either highlights both.
Resolved pins turn hollow and move to the end of the list. Filter between open and all.
When to use
- Design tools, QA, client approvals.
When not to use
- Threaded discussions: use a comment thread.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/feedback-pins.json$
npx shadcn@latest add https://sundaeui.dev/r/feedback-pins.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/feedback-pins.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/feedback-pins.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 feedback-pins.tsx and feedback-pins.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 { FeedbackPins } from '@/components/sundae/feedback-pins'
<FeedbackPins />Props
| Prop | Type | Default |
|---|---|---|
pinsStarting pins, x/y in percent. | { id: number; x: number; y: number; text: string; done?: boolean }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Pins are buttons named with their number and note.
- The canvas also accepts Enter to add a pin at its centre.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-fbp; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Feedback Pins free?
- Yes. Feedback Pins is free for personal and commercial projects, with full source on this page.
- How do I install Feedback Pins?
- Run npx shadcn@latest add https://sundaeui.dev/r/feedback-pins.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Feedback Pins support keyboard and screen readers?
- Pins are buttons named with their number and note. The canvas also accepts Enter to add a pin at its centre.
- Does Feedback Pins respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Rating Scale
Forms · RefinedStar Rating
Forms · RefinedWas This Helpful
Forms · RefinedBug Report
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined