Feature Request Voting Board with Live Reordering

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.

Code

// 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

When to use it

How to customize it

Accessibility

Questions

How do the rows slide into their new order?

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.

How is one vote per person enforced?

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.

How does the number roll?

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.

Can I stop the list from re-sorting while someone is reading?

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.

Where do I connect my own data?

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.

More in Tables

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.

Product

Who It's For

Features

Resources

Company

View Sitemap