Skip to content
goodtabs UI
BrowseBrowse components

Vote button

An upvote toggle with its count in one outline pill that turns solid when pressed.

npx shadcn@latest add https://ui.goodtabs.co/r/vote-button.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/vote-button

Controlled from the parent. Press it to add your vote.

Vertical, in a feed

  • Show: a Cmd+K menu that reads your sitemap

    by mira, 3 hours ago

  • Why we moved our docs back to plain markdown

    by tomasz, 6 hours ago

  • Ask: what do you use for favicons in 2026?

    by lena, yesterday

The chevron sits above the count beside each post.

Small, in a list

  • Export saved tabs as markdown
  • Keyboard shortcut to save the current tab
  • Share a collection with a link

The sm size at the end of each feature request.

Usage

import { VoteButton } from "@/components/ui/vote-button"

<VoteButton count={128} onPressedChange={(pressed) => save(post.id, pressed)} />
<VoteButton count={412} orientation="vertical" defaultPressed />

Props

PropTypeDefaultDescription
countnumberVotes before the viewer's own. One is added while pressed.
pressedbooleanControlled pressed state. Leave out to let the button manage it.
defaultPressedbooleanfalsePressed state on first render when uncontrolled.
onPressedChange(pressed: boolean) => voidCalled with the next pressed state on every toggle.
orientation"horizontal" | "vertical""horizontal"Chevron beside the count, or above it.
size"sm" | "default""default"28px or 32px tall. Vertical grows to fit.
labelstring"Upvote"Accessible name read before the count.

Source

vote-button/vote-button.tsx
"use client";

import * as React from "react";
import { ChevronUpIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

const numberFormat = new Intl.NumberFormat("en-US");

type VoteButtonProps = Omit<
  React.ComponentProps<typeof Button>,
  "onClick" | "children" | "variant" | "size" | "asChild"
> & {
  /** Votes before the viewer's own. One is added while the button is pressed. */
  count: number;
  /** Controlled pressed state. */
  pressed?: boolean;
  /** Pressed state on first render when uncontrolled. */
  defaultPressed?: boolean;
  /** Called with the next pressed state on every toggle. */
  onPressedChange?: (pressed: boolean) => void;
  /** Chevron beside the count, or above it for a feed. */
  orientation?: "horizontal" | "vertical";
  /** Control height: 28px or 32px. Vertical grows to fit. */
  size?: "sm" | "default";
  /** Accessible name. Defaults to "Upvote". */
  label?: string;
};

/**
 * An upvote toggle with its count in one outline pill.
 * Pressed turns solid and adds one to the count locally; wire onPressedChange to persist it.
 */
export function VoteButton({
  count,
  pressed,
  defaultPressed = false,
  onPressedChange,
  orientation = "horizontal",
  size = "default",
  label = "Upvote",
  className,
  ...props
}: VoteButtonProps) {
  const [internal, setInternal] = React.useState(defaultPressed);
  const isPressed = pressed ?? internal;
  const shown = count + (isPressed ? 1 : 0);

  const toggle = () => {
    const next = !isPressed;
    if (pressed === undefined) setInternal(next);
    onPressedChange?.(next);
  };

  return (
    <Button
      type="button"
      variant={isPressed ? "default" : "outline"}
      size={size}
      aria-pressed={isPressed}
      onClick={toggle}
      className={cn(
        "rounded-full transition-colors duration-100 ease-out",
        orientation === "vertical" && "h-auto min-w-9 flex-col gap-0 px-2 py-1",
        orientation === "vertical" && size === "sm" && "min-w-8 px-1.5 py-0.5",
        className,
      )}
      {...props}
    >
      <ChevronUpIcon aria-hidden="true" className={size === "sm" ? "size-3.5" : "size-4"} />
      <span className="sr-only">{label}</span>
      <span className="font-mono text-xs tabular-nums" aria-live="polite">
        {numberFormat.format(shown)}
      </span>
    </Button>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/vote-button/prompt.md, or the Copy AI prompt button above.

When to use

  • Next to posts, comments or feature requests where one vote per person is the model
  • In a feed with the vertical orientation so the count lines up down the left edge
  • When the pressed state must read at a glance, in both themes

When not to use

  • For likes with no count, use a plain toggle
  • For up and down voting, which needs two controls and a net score
  • As a counter that people can press more than once

Agent notes

  • count is the total without the viewer's vote; the button adds one while pressed, so pass the stored count as is
  • Controlled with pressed and onPressedChange, or uncontrolled with defaultPressed; the demo that passes a handler is a client component
  • Pressed renders shadcn's default button variant, unpressed the outline one, so both follow the consumer's theme
  • The count is formatted with Intl.NumberFormat in en-US; wrap it if you need another locale