# 반응 토글

숫자가 그 자리에서 바뀌는 좋아요·싫어요 알약이에요.

- Type: component
- Theme: base
- Tags: arc, component, reaction, like, toggle, vote, community
- English title: Reaction toggle
- URL: https://dsui.vercel.app/items/reaction-toggle

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| disabled | boolean | 비활성 | false |
| error | text | 오류 문구 |  |
| label | text | 이름 | 이 항목에 대한 반응 |

## Source

### reaction-toggle.module.css

```css
.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.tsx

```tsx
"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;

```
