Vote Arrows
Up / down vote control where the score rolls in the vote’s direction and the chosen arrow fills.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/vote-arrows.json'use client'
import { useState } from 'react'
import './vote-arrows.css'
type Vote = -1 | 0 | 1
export interface VoteArrowsProps {
score: number
vote?: Vote
defaultVote?: Vote
onVote?: (v: Vote) => void
orientation?: 'vertical' | 'horizontal'
fill?: 'dark' | 'light'
className?: string
}
const compact = (n: number) => (Math.abs(n) >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(n))
/** Up / down votes with a rolling score. */
export function VoteArrows({ score, vote, defaultVote = 0, onVote, orientation = 'vertical', fill = 'dark', className }: VoteArrowsProps) {
const [inner, setInner] = useState<Vote>(defaultVote)
const v = vote ?? inner
const [d, setD] = useState<'up' | 'down'>('up')
const total = score + v
const cast = (n: 1 | -1) => {
const next: Vote = v === n ? 0 : n
setD(next > v ? 'up' : 'down'); setInner(next); onVote?.(next)
}
const arrow = <svg viewBox="0 0 18 18" aria-hidden><path d="M9 3 3.5 9.5H7v5h4v-5h3.5z" /></svg>
return (
<div className={['sd-vt', className].filter(Boolean).join(' ')} data-fill={fill} data-dir={orientation}>
<button type="button" className="sd-vt__b" data-up="" aria-label="Upvote" aria-pressed={v === 1} onClick={() => cast(1)}>{arrow}</button>
<div className="sd-vt__n" data-d={d} aria-live="polite" aria-label={`Score ${total}`}><span key={total}>{compact(total)}</span></div>
<button type="button" className="sd-vt__b" aria-label="Downvote" aria-pressed={v === -1} onClick={() => cast(-1)}>{arrow}</button>
</div>
)
}.sd-vt { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-vt[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-vt button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-vt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-vt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-vt { display: inline-flex; flex-direction: column; align-items: center; gap: 2px; padding: 4px; border-radius: 14px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-vt[data-dir='horizontal'] { flex-direction: row; border-radius: 999px; }
.sd-vt__b { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; color: var(--mute); transition: color 160ms, background-color 160ms, transform 160ms; }
.sd-vt[data-dir='horizontal'] .sd-vt__b { border-radius: 999px; }
.sd-vt__b:hover { color: var(--fg); background: var(--soft); }
.sd-vt__b:active { transform: scale(0.88); }
.sd-vt__b svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linejoin: round; transition: transform 300ms cubic-bezier(0.3, 1.7, 0.4, 1); }
.sd-vt__b[aria-pressed='true'] { color: var(--fg); }
.sd-vt__b[aria-pressed='true'] svg { fill: currentColor; }
.sd-vt__b[data-up][aria-pressed='true'] svg { transform: translateY(-2px); }
.sd-vt__b:not([data-up])[aria-pressed='true'] svg { transform: translateY(2px) rotate(180deg); }
.sd-vt__b:not([data-up]) svg { transform: rotate(180deg); }
.sd-vt__n { position: relative; height: 22px; min-width: 40px; overflow: hidden; text-align: center; font: 650 14px/22px ui-sans-serif, system-ui; font-variant-numeric: tabular-nums; }
.sd-vt__n span { display: block; animation: sd-vt-up 280ms cubic-bezier(0.2, 1.2, 0.3, 1); }
.sd-vt__n[data-d='down'] span { animation-name: sd-vt-down; }
@keyframes sd-vt-up { from { transform: translateY(100%); opacity: 0; } }
@keyframes sd-vt-down { from { transform: translateY(-100%); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .sd-vt *, .sd-vt *::before, .sd-vt *::after { animation: none !important; transition: none !important; } }Overview
Upvote and the arrow fills and nudges upward while the score rolls up; downvote mirrors it. Clicking your current vote again removes it.
Works vertically for forums and horizontally for compact rows.
When to use
- Forums, Q&A, feature-request boards, comment threads.
When not to use
- Simple likes: use a single toggle.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/vote-arrows.json$
npx shadcn@latest add https://sundaeui.dev/r/vote-arrows.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/vote-arrows.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/vote-arrows.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy vote-arrows.tsx and vote-arrows.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { VoteArrows } from '@/components/sundae/vote-arrows'
<VoteArrows />Props
| Prop | Type | Default |
|---|---|---|
score*Score before the user’s vote. | number | — |
vote / defaultVoteThe user’s vote. | -1 | 0 | 1 | 0 |
onVoteCalled on change. | (v: -1 | 0 | 1) => void | — |
orientationLayout. | 'vertical' | 'horizontal' | 'vertical' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Two toggle buttons with aria-pressed; the score is announced politely.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-vt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Vote Arrows free?
- Yes. Vote Arrows is free for personal and commercial projects, with full source on this page.
- How do I install Vote Arrows?
- Run npx shadcn@latest add https://sundaeui.dev/r/vote-arrows.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Vote Arrows support keyboard and screen readers?
- Two toggle buttons with aria-pressed; the score is announced politely. Reduced motion removes the movement and keeps every state change.
- Does Vote Arrows respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Poll Card
Components · RefinedFollow Button
Components · RefinedMessage Thread
Components · RefinedShare Sheet
Components · Refined- Pro
Comment Thread
Components · Refined Toast Stack
Components · Refined