Roadmap Votes
A public roadmap: ideas under review, planned, in progress and shipped, with upvotes, sorting, and a suggestion box.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/roadmap-votes.json'use client'
import { useState } from 'react'
import './roadmap-votes.css'
export interface RoadmapItem { id: string; title: string; stage: string; votes: number; age: number }
export interface RoadmapVotesProps {
items: RoadmapItem[]
sort?: 'top' | 'new'
fill?: 'dark' | 'light'
className?: string
}
const STAGES = [['review', 'Under review'], ['planned', 'Planned'], ['progress', 'In progress'], ['shipped', 'Shipped']] as const
const ago = (d: number) => (d === 0 ? 'today' : d === 1 ? 'yesterday' : d < 30 ? `${d} days ago` : `${Math.round(d / 30)} mo ago`)
/** Public roadmap with voting. */
export function RoadmapVotes({ items, sort: s0 = 'top', fill = 'dark', className }: RoadmapVotesProps) {
const [list, setList] = useState(items)
const [tab, setTab] = useState<string>('planned')
const [mine, setMine] = useState<Set<string>>(new Set())
const [sort, setSort] = useState(s0)
const [draft, setDraft] = useState('')
const votes = (i: RoadmapItem) => i.votes + (mine.has(i.id) ? 1 : 0)
const shown = list.filter((i) => i.stage === tab).sort((a, b) => (sort === 'top' ? votes(b) - votes(a) : a.age - b.age))
return (
<div className={['sd-rdm', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-rdm__tabs" role="tablist">{STAGES.map(([id, l]) => <button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}>{l}<small>{list.filter((i) => i.stage === id).length}</small></button>)}</div>
{tab !== 'shipped' && <div className="sd-rdm__srt">{(['top', 'new'] as const).map((s) => <button key={s} type="button" aria-pressed={sort === s} onClick={() => setSort(s)}>{s === 'top' ? 'Top' : 'Newest'}</button>)}</div>}
<ul role="tabpanel" aria-live="polite">{shown.map((i) => (
<li key={i.id}>{tab === 'shipped' ? <span className="sd-rdm__ship" aria-hidden><svg viewBox="0 0 16 16"><path d="M3 8.5l3 3 7-7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
: <button type="button" className="sd-rdm__v" aria-pressed={mine.has(i.id)} aria-label={`Upvote ${i.title}, ${votes(i)} votes`} onClick={() => setMine((m) => { const n = new Set(m); n.has(i.id) ? n.delete(i.id) : n.add(i.id); return n })}><svg viewBox="0 0 12 8" aria-hidden><path d="M1 7l5-5.5L11 7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>{votes(i)}</button>}
<div><b>{i.title}</b><small>{tab === 'shipped' ? `Shipped ${ago(i.age)} · ${i.votes} votes` : `Added ${ago(i.age)}`}</small></div></li>
))}{!shown.length && <li><span /><small>Nothing here yet.</small></li>}</ul>
<form onSubmit={(e) => { e.preventDefault(); const t = draft.trim(); if (!t) return; const id = `s${list.length}`; setList([...list, { id, title: t, stage: 'review', votes: 0, age: 0 }]); setMine(new Set(mine).add(id)); setDraft(''); setTab('review') }}><input aria-label="Suggest a feature" placeholder="Suggest a feature" value={draft} onChange={(e) => setDraft(e.target.value)} /><button type="submit" disabled={!draft.trim()}>Suggest</button></form>
</div>
)
}.sd-rdm { --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-rdm[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-rdm :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-rdm :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rdm .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-rdm { 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-rdm__tabs { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; border-bottom: 1px solid var(--line); }
.sd-rdm__tabs button { flex: none; height: 32px; padding: 0 9px; font-size: 12px; color: var(--mute) !important; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.sd-rdm__tabs button[aria-selected='true'] { color: var(--fg) !important; border-color: var(--fg); font-weight: 600; }
.sd-rdm__tabs small { margin-left: 4px; font-variant-numeric: tabular-nums; }
.sd-rdm__srt { display: flex; justify-content: flex-end; gap: 10px; font-size: 12px; }
.sd-rdm__srt button { color: var(--mute) !important; }
.sd-rdm__srt button[aria-pressed='true'] { color: var(--fg) !important; text-decoration: underline; text-underline-offset: 3px; }
.sd-rdm ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; min-height: 180px; align-content: start; }
.sd-rdm li { display: grid; grid-template-columns: 46px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px; border-radius: 11px; transition: background-color 140ms; animation: sd-rdm-in 220ms ease-out; }
@keyframes sd-rdm-in { from { opacity: 0; transform: translateY(4px); } }
.sd-rdm li:hover { background: var(--soft); }
.sd-rdm__v { height: 46px; border-radius: 10px; display: grid; place-content: center; justify-items: center; gap: 1px; box-shadow: inset 0 0 0 1px var(--line); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
.sd-rdm__v svg { width: 12px; height: 8px; }
.sd-rdm__v[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-rdm__ship { height: 46px; border-radius: 10px; display: grid; place-items: center; background: var(--soft); }
.sd-rdm__ship svg { width: 16px; height: 16px; }
.sd-rdm li b { display: block; font-size: 13.5px; font-weight: 600; }
.sd-rdm li small { font-size: 11.5px; color: var(--mute); }
.sd-rdm form { display: flex; gap: 6px; }
.sd-rdm 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-rdm form input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-rdm form button { padding: 0 12px; border-radius: 10px; font-size: 12.5px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-rdm form button:disabled { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) { .sd-rdm *, .sd-rdm *::before, .sd-rdm *::after { animation: none !important; transition: none !important; } }Overview
Each tab lists items with a vote button and count. Sort by top or newest. Shipped items show when they went out instead of a vote.
Suggestions land in Under review with your vote already counted, and the tab count updates.
When to use
- Product sites, community portals, in-app feedback.
When not to use
- Private internal planning.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/roadmap-votes.json$
npx shadcn@latest add https://sundaeui.dev/r/roadmap-votes.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/roadmap-votes.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/roadmap-votes.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 roadmap-votes.tsx and roadmap-votes.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 { RoadmapVotes } from '@/components/sundae/roadmap-votes'
<RoadmapVotes />Props
| Prop | Type | Default |
|---|---|---|
items*Roadmap items. | { id: string; title: string; stage: string; votes: number; age: number }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Stages are tabs with counts; votes are toggle buttons with the count in the label.
- New suggestions are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-rdm; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Roadmap Votes free?
- Yes. Roadmap Votes is free for personal and commercial projects, with full source on this page.
- How do I install Roadmap Votes?
- Run npx shadcn@latest add https://sundaeui.dev/r/roadmap-votes.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Roadmap Votes support keyboard and screen readers?
- Stages are tabs with counts; votes are toggle buttons with the count in the label. New suggestions are announced.
- Does Roadmap Votes respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Toast Stack
Components · RefinedHold to Confirm
Components · RefinedInline Alert
Components · RefinedHotspot Image
Components · RefinedProduct Q&A
Components · RefinedRanked-Choice Vote
Components · Refined