Component
Reaction toggle
Like and dislike pills whose count changes in place.
- #arc
- #component
- #reaction
- #like
- #toggle
- #vote
- #community
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/reaction-toggle.jsonFiles
components/arc/reaction-toggle/reaction-toggle.module.csscomponents/arc/reaction-toggle/reaction-toggle.tsx
Registry items it pulls in
Source
View as Markdownreaction-toggle.module.cssreaction-toggle.module.cssreaction-toggle.module.csscss
.wrap { display: flex; flex-direction: column; gap: var(--space-2); }
.row { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.pill { display: inline-flex; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 0 var(--space-4); background: var(--surface); color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: var(--label-weight); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
.icon { display: inline-flex; flex: none; }
/* Tabular figures: the count changes under the reader's finger, and the pill must not resize as it does. */
.count { font-variant-numeric: tabular-nums; }
.pill[aria-pressed="true"] { border-color: transparent; background: var(--accent-subtle); color: var(--accent); }
.pill:disabled { background: var(--surface-muted); border-color: transparent; color: color-mix(in oklab, var(--foreground) 55%, transparent); cursor: not-allowed; }
@media (hover: hover) and (pointer: fine) {
.pill:hover:not(:disabled) { border-color: var(--border-strong); color: var(--foreground); }
.pill[aria-pressed="true"]:hover:not(:disabled) { border-color: transparent; color: var(--accent); }
}
.pill:active:not(:disabled) { transform: scale(.97); }
/* The box keeps its place whether or not there is a message, so nothing jumps when one appears. */
.error { min-height: var(--space-4); margin: 0; color: var(--danger); font-size: var(--text-xs); line-height: var(--space-4); }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .pill { transition: none; } .pill:active { transform: none; } }
reaction-toggle.tsxreaction-toggle.tsxreaction-toggle.tsxtsx
"use client";
import type { ReactNode } from "react";
import { playUiSound } from "../sound/play";
import styles from "./reaction-toggle.module.css";
export interface ReactionOption {
/** What this option means, e.g. 1 for a like and -1 for a dislike. */
value: number;
/** Read out instead of the number, since the number alone says nothing. */
label: string;
icon: ReactNode;
count: number;
}
export interface ReactionToggleProps {
options: ReactionOption[];
/** The option the reader has chosen, or 0 for none. */
value?: number;
onSelect?: (value: number) => void;
disabled?: boolean;
/** Shown under the row when something went wrong, and announced. */
error?: string;
/** The accessible name of the group. */
label?: string;
className?: string;
}
/**
* Pills whose number changes in place. The label is spoken and the number is seen: a button called "12" tells a
* screen reader nothing, and a button called "좋아요" leaves the count out, so both are there and only one shows.
*
* Pressing the option already chosen takes it back, which is why these are `aria-pressed` toggles rather than a
* radio group — "none" is a real answer here.
*/
export function ReactionToggle({ options, value = 0, onSelect, disabled = false, error, label, className }: ReactionToggleProps) {
return (
<div className={[styles.wrap, className].filter(Boolean).join(" ")}>
<div role="group" aria-label={label} className={styles.row}>
{options.map(option => {
const pressed = value === option.value;
return (
<button
key={option.value}
type="button"
className={styles.pill}
aria-pressed={pressed}
disabled={disabled}
onClick={() => { playUiSound("click"); onSelect?.(pressed ? 0 : option.value); }}
>
<span className={styles.icon} aria-hidden="true">{option.icon}</span>
<span className={styles.srOnly}>{option.label}</span>
<span className={styles.count}>{option.count}</span>
</button>
);
})}
</div>
{/* Always in the tree, so a message that appears later is announced rather than silently added. */}
<p role="status" className={styles.error}>{error ?? ""}</p>
</div>
);
}
ReactionToggle.displayName = "ReactionToggle";
export default ReactionToggle;