A public roadmap list where visitors vote for the features they want. Each row has an upvote button with a count, a title, a status tag and one line of detail. Pressing the button fills it in and rolls the number up; pressing again takes the vote back. The list is always sorted by votes, and rows slide past each other when the order changes. A few votes arrive on their own after load so the re-sorting can be seen. On phones the rank number is hidden and the rows keep their layout.
// Adapted from Cult UI (https://cult-ui.com/docs/components/feature-voting)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ChevronUp } from "lucide-react";
type Status = "In progress" | "Planned" | "Under review";
const requests: { id: string; title: string; detail: string; status: Status; votes: number }[] = [
{ id: "recurring", title: "Recurring invoices", detail: "Bill the same client on the first of every month without rebuilding the invoice.", status: "In progress", votes: 214 },
{ id: "reminders", title: "Late payment reminders", detail: "A polite nudge at 7, 14 and 30 days overdue, sent from my own address.", status: "Planned", votes: 209 },
{ id: "currency", title: "Invoice in any currency", detail: "Quote in euros, get paid in pounds, and show the rate that was used.", status: "Planned", votes: 187 },
{ id: "receipts", title: "Photograph a receipt", detail: "Snap it on a phone and have the amount and supplier filled in.", status: "Under review", votes: 181 },
{ id: "mileage", title: "Mileage log", detail: "Track business trips and add them to the tax summary at the approved rate.", status: "Under review", votes: 96 },
];
const statusStyle: Record<Status, string> = {
"In progress": "bg-emerald-100 text-emerald-800",
Planned: "bg-indigo-100 text-indigo-800",
"Under review": "bg-slate-100 text-slate-700",
};
/** Votes from "other people" that arrive while the board is on screen: [request id, delay in ms]. */
const INCOMING: [string, number][] = [
["reminders", 1300],
["reminders", 700],
["receipts", 1500],
["receipts", 500],
["receipts", 900],
["currency", 1600],
];
export default function FeatureRequestVotingBoardWithLiveReordering() {
const reduce = useReducedMotion();
const [votes, setVotes] = useState<Record<string, number>>(() => Object.fromEntries(requests.map((r) => [r.id, r.votes])));
const [mine, setMine] = useState<string[]>(["recurring"]);
const [step, setStep] = useState(0);
// Play the incoming votes one at a time so the list can be seen re-sorting.
useEffect(() => {
if (reduce || step >= INCOMING.length) return;
const [id, delay] = INCOMING[step];
const t = window.setTimeout(() => {
setVotes((v) => ({ ...v, [id]: v[id] + 3 }));
setStep((s) => s + 1);
}, delay);
return () => window.clearTimeout(t);
}, [step, reduce]);
const toggle = (id: string) => {
const voted = mine.includes(id);
setMine((m) => (voted ? m.filter((x) => x !== id) : [...m, id]));
setVotes((v) => ({ ...v, [id]: Math.max(0, v[id] + (voted ? -1 : 1)) }));
};
const sorted = [...requests].sort((a, b) => votes[b.id] - votes[a.id]);
const total = Object.values(votes).reduce((n, v) => n + v, 0);
return (
<section className="bg-white px-4 py-16 sm:px-6 sm:py-20">
<div className="mx-auto max-w-2xl">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-sm font-semibold text-indigo-700">Roadmap</p>
<h2 className="mt-1 text-3xl font-semibold tracking-tight text-slate-900">What should we build next?</h2>
<p className="mt-2 max-w-md text-base text-slate-600">Vote for what would save you the most time. The list sorts itself as votes come in.</p>
</div>
<p className="rounded-full bg-slate-100 px-3 py-1.5 text-sm font-medium text-slate-700" aria-live="polite">
<span className="tabular-nums">{total}</span> votes
</p>
</div>
<ul aria-label="Feature requests, most voted first" className="mt-8 space-y-3">
{sorted.map((r, rank) => {
const voted = mine.includes(r.id);
return (
<motion.li
key={r.id}
layout={!reduce}
transition={{ type: "spring", stiffness: 420, damping: 34 }}
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
>
<button
type="button"
aria-pressed={voted}
aria-label={`${voted ? "Remove your vote for" : "Vote for"} ${r.title}, ${votes[r.id]} votes`}
onClick={() => toggle(r.id)}
className={`flex h-16 w-14 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 ${
voted ? "border-indigo-600 bg-indigo-600 text-white" : "border-slate-300 bg-white text-slate-800 hover:border-indigo-400 hover:bg-indigo-50"
}`}
>
<motion.span animate={voted && !reduce ? { y: [0, -5, 0] } : { y: 0 }} transition={{ duration: 0.35 }}>
<ChevronUp aria-hidden="true" className="h-4 w-4" strokeWidth={2.5} />
</motion.span>
<span className="relative block h-5 w-full">
<AnimatePresence initial={false}>
<motion.span
key={votes[r.id]}
initial={reduce ? false : { y: 12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduce ? { opacity: 0 } : { y: -12, opacity: 0 }}
transition={{ duration: 0.22 }}
className="absolute inset-0 text-center tabular-nums"
>
{votes[r.id]}
</motion.span>
</AnimatePresence>
</span>
</button>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h3 className="text-base font-semibold text-slate-900">{r.title}</h3>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusStyle[r.status]}`}>{r.status}</span>
</div>
<p className="mt-1 text-sm leading-6 text-slate-600">{r.detail}</p>
</div>
<span aria-hidden="true" className="hidden text-sm font-medium tabular-nums text-slate-400 sm:block">#{rank + 1}</span>
</motion.li>
);
})}
</ul>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/feature-request-voting-board-with-live-reordering.json
Every row is a `motion.li` with the `layout` prop and a stable `key`. When the sorted array changes, Framer Motion measures where each row was and where it is now, and animates the difference with a spring.
In the demo the ids you voted for are kept in the `mine` state, and pressing a voted row removes the vote. On a real site store that on your server against the signed in user, and treat this state as a fast local copy.
The count is rendered with its value as the `key` inside `AnimatePresence`. When the value changes the old number slides up and out while the new one slides in from below, clipped by the button.
Yes. Sort once into state when the page loads, and re-sort only on a button press or after a pause, instead of on every render.
Replace the starting `votes` object with counts from your API, and call your endpoint inside `toggle` next to the two state updates.
Adapted from Feature Voting in Cult UI, used under its open-source licence. Licences and credits
Related: Sortable Data Table with Status Badges and Row Actions, Animated Notification Feed with Looping Arrivals, Self-Drawing Vertical Timeline with Pop-In Items.
More tables and lists or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.