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
| Prop | Type | Default | Description |
|---|---|---|---|
| count | number | Votes before the viewer's own. One is added while pressed. | |
| pressed | boolean | Controlled pressed state. Leave out to let the button manage it. | |
| defaultPressed | boolean | false | Pressed state on first render when uncontrolled. |
| onPressedChange | (pressed: boolean) => void | Called 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. |
| label | string | "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