Skip to content
txbrendan/ui

Feature Voting

beta

A list of ideas, each with an upvote button and its count, or an up and down vote stacked around the count. It shows how this person voted.

Preview

voted, with statuses and a shipped idea

  • Dark mode for the dashboard
    Planned
    Easier on the eyes late at night.
  • Export reports to a spreadsheet
    Download a year of numbers as a CSV file.
  • Meeting reminders
    In progress
    A text the night before each meeting.
  • Calendar sync
    Shipped

no votes yet

  • Profile photos
  • Group chat

disabled

  • Profile photos
  • Group chat

stack, voted up and down, with a count below zero

  • Dark mode for the dashboard
    Planned
    Easier on the eyes late at night.
  • Export reports to a spreadsheet
    Download a year of numbers as a CSV file.
  • A sound for every new message
  • Calendar sync
    Shipped

stack, no votes yet

  • Profile photos
  • Group chat

stack, disabled

  • Profile photos
  • Group chat

neutral

  • Profile photos
  • Group chat

brand

  • Profile photos
  • Group chat

danger

  • Profile photos
  • Group chat

success

  • Profile photos
  • Group chat

warning

  • Profile photos
  • Group chat

stack, neutral

  • Profile photos
  • Group chat

stack, brand

  • Profile photos
  • Group chat

stack, danger

  • Profile photos
  • Group chat

stack, success

  • Profile photos
  • Group chat

stack, warning

  • Profile photos
  • Group chat

de-DE

  • Dunkler Modus
    Geplant
  • Export als Tabelle

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/feature-voting

Use it for

  • Letting people vote on a list of ideas, like feature requests or topics for a meeting.
  • People can vote for as many ideas as they like. Pressing a button again takes the vote back.
  • Pass layout="stack" to let people vote up or down, like a forum. An up caret, the count, and a down caret stack in a column at the start of each idea, and votes is then ups minus downs, so it can go below zero.
  • In the stack layout, pressing the other caret moves the vote in one press. Each caret has a tooltip that says what it does, and the count rolls to its new value.
  • Pass each idea with its votes so far. A status like Planned shows as a badge beside the title.
  • The list keeps the order you pass. Sort it yourself, like by votes, when you load the data.
  • Return a promise from onVote to save each vote. The button and its count change at once, with savingText after the title, until the promise settles. If it rejects, the vote goes back and errorText shows under the idea.
  • Leave voted out to let the list keep its own state. It then counts the votes it takes, and defaultVoted marks earlier votes, with defaultDownvoted for down votes. People can press again while a vote saves, and the newest press on each idea wins, even when an older save finishes later.
  • Pass voted, and downvoted in the stack layout, to keep the votes in your own state, and include this person's votes in the counts you pass. Once a save succeeds, and before your onVote promise resolves, update them and the counts. Your state then decides, so if saves can finish out of order, skip one that a newer press replaced.
  • While a vote saves, its item has data-pending, for your own styles. A vote up sets data-voted, and a vote down data-downvoted.
  • For another language, pass upvoteText, downvoteText, savingText, errorText, votesText, and locale.

Avoid it for

  • One question with a few fixed answers, where people pick one. Use Choice Poll instead.
  • Rating one thing on a scale, like 1 to 5 stars.
  • A single like button on a post. Use a Button with aria-pressed.
  • Sorting the list again while someone votes. The idea would move away from their focus.

Props

  • items

    The ideas, in the order to show them. value must be unique. votes is the count so far, this person's included. In the stack layout it is ups minus downs, and it can be below zero. disabled blocks voting on one idea, like one that has shipped.

    { value: string; title: ReactNode; description?: ReactNode; votes: number; status?: { label: string; tone?: "neutral" | "brand" | "danger" | "success" | "warning" }; disabled?: boolean }[]

  • layout

    inline puts one upvote button with its count at the end of each idea. stack puts an up caret, the count, and a down caret in a column at the start, so people can vote up or down.

    "inline" | "stack" · default "inline"

  • voted

    The values of the ideas this person voted up. Controlled, together with downvoted. The list shows the counts you pass as they are, plus a vote that is still saving.

    string[]

  • downvoted

    The values of the ideas this person voted down, in the stack layout. Controlled, together with voted.

    string[]

  • defaultVoted

    Earlier votes up by this person when you do not control the votes. They are already in the counts, so the list does not add them again.

    string[]

  • defaultDownvoted

    Earlier votes down by this person when you do not control the votes, in the stack layout. They are already in the counts.

    string[]

  • onVote

    Called when someone votes on an idea (true) or takes the vote back (false), with the direction pressed. Moving a vote from up to down is one call, with true and "down". Return a promise, and the list shows the change at once with savingText until it settles. A rejection puts the earlier vote back and shows errorText under the idea.

    (value: string, voted: boolean, direction: "up" | "down") => void | Promise<unknown>

  • disabled

    Blocks every vote button. The counts still show.

    boolean · default false

  • tone

    Color of the vote buttons. A vote fills its button with it. The rest are outlined, or plain carets in the stack layout.

    "neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"

  • upvoteText

    The word that starts each upvote button's name for screen readers. In the stack layout, the up caret's tooltip shows it too.

    string · default "Upvote"

  • downvoteText

    The word that starts each down caret's name for screen readers, and its tooltip, in the stack layout.

    string · default "Downvote"

  • savingText

    Shown after an idea's title while its vote saves. The vote button's aria-describedby points to it.

    string · default "Saving…"

  • errorText

    Shown under an idea when its vote does not save. Screen readers announce it.

    string · default "Could not save your vote. Try again."

  • votesText

    How a count of votes reads in each plural form, with {count} for the number. Screen readers hear it in each button's name, and the button shows only the number. The list picks the form with Intl.PluralRules for locale.

    { zero?: string; one?: string; two?: string; few?: string; many?: string; other: string } · default { one: "{count} vote", other: "{count} votes" }

  • locale

    A language tag for the counts and the plural forms, like 'de-DE'.

    string · default "en-US"

  • className

    Extra classes for the list. Later classes win, so className='divide-y-0' drops the lines between ideas.

    string

Accessibility

  • The ideas are a list. Each item holds the title, the details, the status badge, and the vote button.
  • Each vote button is a toggle button. Its name reads upvoteText, the title, and the count, like 'Upvote Dark mode 48 votes'. aria-pressed says whether this person voted.
  • The name comes from aria-labelledby on ids in the same item, so it is in the server HTML too.
  • Space and Enter vote and take the vote back, like any button.
  • A vote shows at once, so there is no spinner and no aria-busy. savingText shows after the title until the vote saves, and the button's aria-describedby points to it. Every button stays ready, so people can press again.
  • A failed save shows errorText under the idea in a role="alert" message. The button's aria-describedby points to it, and focus stays on the button.
  • disabled, on the list or on one idea, takes those buttons out of the tab order.
  • A voted button is filled and the rest are outlined, so the vote does not rest on color alone.
  • A changed count fades in over --motion-duration-base. data-motion="off" makes it instant. Reduced motion drops the slide but keeps the fade.
  • In the stack layout, each caret is a toggle button with its own aria-pressed. Their names read the direction, the title, and the count, like 'Upvote Dark mode 48 votes' and 'Downvote Dark mode 48 votes'. The number between them is hidden from screen readers, since both names hold it.
  • Each caret's tooltip says what it does. It opens after the pointer rests 150 ms, at once on keyboard focus, and Escape closes it. Touch shows none.
  • The stack layout's count rolls to each new value. Reduced motion and data-motion="off" jump straight to it.
  • Every word has a prop with an English default: upvoteText, downvoteText, savingText, errorText, and votesText. locale formats the counts and picks each plural form.

Examples

A list that keeps its own state

<FeatureVoting
  items={[
    { value: "dark-mode", title: "Dark mode", description: "Easier on the eyes at night.", votes: 48 },
    { value: "export", title: "Export to a spreadsheet", votes: 31 },
    { value: "reminders", title: "Meeting reminders", votes: 17 },
  ]}
  defaultVoted={["dark-mode"]}
/>

Save votes yourself

"use client";

const items = [
  { value: "dark-mode", title: "Dark mode", votes: 48 },
  { value: "export", title: "Export to a spreadsheet", votes: 31 },
];

// The list shows each vote at once and counts it. If this throws, it puts the vote back and says so.
// It keeps its own state, so the newest press on an idea wins even when an older save finishes later.
async function save(value: string, voted: boolean) {
  // Replace this with your own request. fetch does not throw on a server error, so check ok.
  const response = await fetch("/api/votes", { method: "POST", body: JSON.stringify({ idea: value, voted }) });
  if (!response.ok) throw new Error("The vote did not save.");
}

function IdeaBoard() {
  return <FeatureVoting items={items} defaultVoted={["export"]} onVote={save} tone="brand" />;
}

Statuses and a shipped idea

<FeatureVoting
  items={[
    { value: "reminders", title: "Meeting reminders", votes: 17, status: { label: "In progress", tone: "warning" } },
    { value: "dark-mode", title: "Dark mode", votes: 48, status: { label: "Planned", tone: "brand" } },
    { value: "calendar", title: "Calendar sync", votes: 52, status: { label: "Shipped", tone: "success" }, disabled: true },
  ]}
/>

Vote up or down in a stacked column

"use client";

const items = [
  { value: "dark-mode", title: "Dark mode", description: "Easier on the eyes at night.", votes: 48 },
  { value: "sounds", title: "A sound for every message", votes: -4 },
];

// voted is false when the vote is taken back, so the idea then has no vote from this person.
async function save(value: string, voted: boolean, direction: "up" | "down") {
  const response = await fetch("/api/votes", { method: "POST", body: JSON.stringify({ idea: value, vote: voted ? direction : null }) });
  if (!response.ok) throw new Error("The vote did not save.");
}

function IdeaBoard() {
  return <FeatureVoting layout="stack" items={items} defaultVoted={["dark-mode"]} defaultDownvoted={["sounds"]} onVote={save} />;
}

In another language

<FeatureVoting
  items={[
    { value: "dark-mode", title: "Dunkler Modus", votes: 1204 },
    { value: "export", title: "Export als Tabelle", votes: 1 },
  ]}
  upvoteText="Dafür stimmen"
  votesText={{ one: "{count} Stimme", other: "{count} Stimmen" }}
  locale="de-DE"
/>