{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"reactions","type":"registry:ui","title":"Reactions","description":"Acknowledge a post in one tap, with counts that roll.","categories":["actions"],"dependencies":["@radix-ui/react-popover","lucide-react","motion"],"registryDependencies":["https://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/reactions/reactions.tsx","type":"registry:component","target":"@components/arc/reactions/reactions.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, RefObject } from \"react\";\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\";\nimport { AnimatePresence, LayoutGroup, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { AnimationPlaybackControls, TargetAndTransition, Transition, Variants } from \"motion/react\";\nimport { Check, SmilePlus, X } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./reactions.module.css\";\n\n/**\n * A reaction bar for comments, posts, and release notes. Each pill shows one reaction and how many people chose it; the viewer's own reactions carry the accent.\n * Tapping a pill adds or removes the viewer's reaction, and the add button opens a compact picker that grows from it. Use it for quick acknowledgement;\n * when people need to say something specific, give them a reply field instead.\n */\nexport interface ReactionOption { id: string; label: string; icon: LucideIcon }\nexport interface Reaction { id: string; count: number; reacted: boolean }\nexport interface ReactionsProps {\n  /** Every reaction the picker offers, in picker order. */\n  options: ReactionOption[];\n  /** Controlled reactions. Pills keep this order, and a reaction whose count reaches zero leaves the bar. */\n  value?: Reaction[];\n  defaultValue?: Reaction[];\n  /** Called with the next reactions and the change that produced them. */\n  onValueChange?: (next: Reaction[], change: { id: string; reacted: boolean }) => void;\n  /** Accessible name for the bar. */\n  label?: string;\n  className?: string;\n}\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst blur = (px: number) => `blur(${px}px)`;\nconst noop = () => () => {};\n\n/** Reduced motion only counts after hydration, so the server and the first client render always agree. */\nfunction useReduced() {\n  const hydrated = useSyncExternalStore(noop, () => true, () => false);\n  const reduced = useReducedMotion() ?? false;\n  return hydrated && reduced;\n}\n\n/** Follows the natural size of `content`: after `key` changes the size springs to it; any other resize (a late font, a reflow) jumps, so nothing wobbles on load. */\nfunction useFollowSize(content: RefObject<HTMLElement | null>, axis: \"width\" | \"height\", key: string, reduced: boolean, transition: Transition) {\n  const size = useMotionValue<number | \"auto\">(\"auto\");\n  const lastKey = useRef(key), armedUntil = useRef(0);\n  useLayoutEffect(() => {\n    if (lastKey.current === key) return;\n    lastKey.current = key;\n    armedUntil.current = performance.now() + 700;\n  }, [key]);\n  useEffect(() => {\n    const node = content.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    let sizing: AnimationPlaybackControls | undefined;\n    // Border-box sizes ignore transforms, so a pill that is still scaling in reports its real width.\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const box = entry.borderBoxSize?.[0];\n      const next = box ? (axis === \"width\" ? box.inlineSize : box.blockSize) : axis === \"width\" ? node.offsetWidth : node.offsetHeight;\n      sizing?.stop();\n      if (!measured || reduced || performance.now() > armedUntil.current) { measured = true; size.jump(next); return; }\n      sizing = animate(size, next, transition);\n    });\n    observer.observe(node, { box: \"border-box\" });\n    return () => { observer.disconnect(); sizing?.stop(); };\n  }, [axis, content, reduced, size, transition]);\n  return size;\n}\n\n/** Digits roll in the direction the count moved: up and out when it grows, down when it shrinks. Only the digits that change move. */\nconst digitMotion: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${direction * .55}em`, filter: blur(motionTokens.blur.subtle) }),\n  center: { opacity: 1, y: \"0em\", filter: blur(0) },\n  exit: (direction: number) => ({ opacity: 0, y: `${direction * -.55}em`, filter: blur(motionTokens.blur.subtle), transition: { duration: motionTokens.duration.fast, ease: standard } }),\n};\nconst digitStill: Variants = { enter: { opacity: 1 }, center: { opacity: 1 }, exit: { opacity: 0, transition: { duration: 0 } } };\nconst digitTransition = { y: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };\n\nfunction RollingCount({ value, reduced }: { value: number; reduced: boolean }) {\n  const [roll, setRoll] = useState({ value, direction: 1 });\n  if (roll.value !== value) setRoll({ value, direction: value > roll.value ? 1 : -1 });\n  const row = useRef<HTMLSpanElement>(null);\n  const text = String(value);\n  const width = useFollowSize(row, \"width\", text, reduced, motionTokens.spring.morph);\n  return <motion.span className={styles.count} style={{ width }} aria-hidden=\"true\">\n    <span ref={row} className={styles.digits}>\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={roll.direction}>\n        {[...text].map((digit, index) => <motion.span key={`${text.length - index}:${digit}`} className={styles.digit} custom={roll.direction} variants={reduced ? digitStill : digitMotion}\n          initial=\"enter\" animate=\"center\" exit=\"exit\" transition={reduced ? { duration: 0 } : digitTransition}>{digit}</motion.span>)}\n      </AnimatePresence>\n    </span>\n  </motion.span>;\n}\n\n/** An impulse rather than a keyframe: the icon starts at rest with velocity, overshoots, and settles. A quick second tap adds to the motion instead of restarting it. */\nconst kick = { type: \"spring\", stiffness: 420, damping: 39 } as const;\n\nfunction KickIcon({ icon: Icon, pop, reduced }: { icon: LucideIcon; pop: number; reduced: boolean }) {\n  const scale = useMotionValue(1), rotate = useMotionValue(0);\n  const seen = useRef(0);\n  useEffect(() => {\n    if (pop <= seen.current) return;\n    seen.current = pop;\n    if (reduced) return;\n    animate(scale, 1, { ...kick, velocity: scale.getVelocity() + 11 });\n    animate(rotate, 0, { ...kick, velocity: rotate.getVelocity() - 420 });\n  }, [pop, reduced, rotate, scale]);\n  useEffect(() => () => { scale.stop(); rotate.stop(); }, [rotate, scale]);\n  return <motion.span className={styles.icon} style={{ scale, rotate }} aria-hidden=\"true\"><Icon size={16} strokeWidth={1.75} /></motion.span>;\n}\n\nconst rest: TargetAndTransition = { opacity: 1, scale: 1, y: 0, filter: blur(0) };\nconst fadeIn: TargetAndTransition = { opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\nconst pillIn: TargetAndTransition = { opacity: 0, scale: .5, filter: blur(motionTokens.blur.soft) };\nconst pillOut: TargetAndTransition = { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle), transition: { duration: motionTokens.duration.fast, ease: standard } };\nconst pillTransition = { layout: motionTokens.spring.morph, scale: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle) };\nconst iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: standard } };\nconst iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: blur(motionTokens.blur.soft) };\nconst textOut: TargetAndTransition = { opacity: 0, y: \"-0.3em\", filter: blur(motionTokens.blur.subtle), transition: { duration: motionTokens.duration.fast, ease: standard } };\n\n/** The tray of every option. A shared highlight glides to the hovered or focused option, and a caption names it, so an icon never has to explain itself. */\nfunction Picker({ options, reacted, reduced, onPick }: { options: ReactionOption[]; reacted: Set<string>; reduced: boolean; onPick: (id: string) => void }) {\n  const [focusIndex, setFocusIndex] = useState(0);\n  const [hovered, setHovered] = useState<number | null>(null);\n  const active = Math.min(hovered ?? focusIndex, options.length - 1);\n  const buttons = useRef<(HTMLButtonElement | null)[]>([]);\n  const x = useMotionValue(0);\n  const placed = useRef(false);\n  useLayoutEffect(() => {\n    const target = buttons.current[active]?.offsetLeft ?? 0;\n    if (!placed.current || reduced) { placed.current = true; x.jump(target); return; }\n    // A new target retargets the running spring and keeps its velocity, so a fast sweep glides instead of stepping.\n    animate(x, target, motionTokens.spring.morph);\n  }, [active, reduced, x]);\n  useEffect(() => () => x.stop(), [x]);\n\n  function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {\n    const steps: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };\n    const count = options.length;\n    const next = event.key in steps ? (focusIndex + steps[event.key]! + count) % count : event.key === \"Home\" ? 0 : event.key === \"End\" ? count - 1 : -1;\n    if (next < 0) return;\n    event.preventDefault();\n    setHovered(null);\n    setFocusIndex(next);\n    buttons.current[next]?.focus();\n  }\n\n  const option = options[active];\n  const chosen = option ? reacted.has(option.id) : false;\n  return <>\n    <div className={styles.options} role=\"toolbar\" aria-label=\"Reactions\" aria-orientation=\"horizontal\" onKeyDown={onKeyDown} onPointerLeave={() => setHovered(null)}>\n      <motion.span className={styles.highlight} style={{ x }} aria-hidden=\"true\" />\n      {options.map((item, index) => {\n        const Icon = item.icon;\n        const lit = index === active;\n        return <motion.button key={item.id} ref={node => { buttons.current[index] = node; }} type=\"button\" className={styles.option} aria-label={item.label} aria-pressed={reacted.has(item.id)} tabIndex={index === focusIndex ? 0 : -1}\n          onFocus={() => setFocusIndex(index)} onPointerMove={event => { if (event.pointerType === \"mouse\" && hovered !== index) setHovered(index); }} onClick={() => onPick(item.id)}\n          initial={reduced ? fadeIn : iconIn} animate={rest} transition={reduced ? { duration: motionTokens.duration.fast } : { ...iconEnter, delay: .04 + index * motionTokens.stagger.item, opacity: { ...iconEnter.opacity, delay: .04 + index * motionTokens.stagger.item }, filter: { ...iconEnter.filter, delay: .04 + index * motionTokens.stagger.item } }}>\n          <motion.span className={styles.optionIcon} animate={{ scale: lit && !reduced ? 1.14 : 1, y: lit && !reduced ? -1 : 0 }} transition={motionTokens.spring.snappy}><Icon size={20} strokeWidth={1.75} aria-hidden=\"true\" /></motion.span>\n        </motion.button>;\n      })}\n    </div>\n    <div className={styles.caption} aria-hidden=\"true\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {option && <motion.span key={`${option.id}:${chosen}`} className={styles.captionText} initial={reduced ? fadeIn : textIn} animate={rest} exit={reduced ? fadeOut : textOut} transition={{ duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }}>\n          {chosen && <Check size={12} strokeWidth={2} />}{option.label}\n        </motion.span>}\n      </AnimatePresence>\n    </div>\n  </>;\n}\n\nexport function Reactions({ options, value, defaultValue = [], onValueChange, label = \"Reactions\", className }: ReactionsProps) {\n  const reduced = useReduced();\n  const groupId = useId();\n  const [own, setOwn] = useState(defaultValue);\n  const list = value ?? own;\n  const [pops, setPops] = useState<Record<string, number>>({});\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [open, setOpen] = useState(false);\n  const bar = useRef<HTMLDivElement>(null);\n  const trigger = useRef<HTMLButtonElement>(null);\n  const refocus = useRef<string | null>(null);\n  const byId = new Map(options.map(option => [option.id, option]));\n  const shown = list.filter(reaction => reaction.count > 0 && byId.has(reaction.id));\n  const reacted = new Set(shown.filter(reaction => reaction.reacted).map(reaction => reaction.id));\n  // Wrapping onto a new row changes the bar's height; the height follows on a spring instead of shoving the content below.\n  const height = useFollowSize(bar, \"height\", shown.map(reaction => `${reaction.id}${reaction.count}`).join(), reduced, motionTokens.spring.smooth);\n\n  // A pill that leaves while focused hands focus to its neighbour, or to the add button, so keyboard users never fall back to the page.\n  useLayoutEffect(() => {\n    const target = refocus.current;\n    if (!target) return;\n    refocus.current = null;\n    const next = target === \"add\" ? trigger.current : bar.current?.querySelector<HTMLElement>(`[data-reaction=\"${CSS.escape(target)}\"]`);\n    next?.focus();\n  });\n\n  function toggle(id: string) {\n    const option = byId.get(id);\n    if (!option) return;\n    const current = shown.find(reaction => reaction.id === id);\n    const add = !current?.reacted;\n    const count = Math.max(0, (current?.count ?? 0) + (add ? 1 : -1));\n    const next = current ? list.map(reaction => reaction.id === id ? { ...reaction, count, reacted: add } : reaction).filter(reaction => reaction.count > 0) : [...list.filter(reaction => reaction.id !== id), { id, count: 1, reacted: true }];\n    if (count === 0) {\n      const pill = bar.current?.querySelector(`[data-reaction=\"${CSS.escape(id)}\"]`);\n      if (pill && pill === document.activeElement) {\n        const index = shown.findIndex(reaction => reaction.id === id);\n        refocus.current = shown[index + 1]?.id ?? shown[index - 1]?.id ?? \"add\";\n      }\n    }\n    if (add) setPops(previous => ({ ...previous, [id]: (previous[id] ?? 0) + 1 }));\n    if (value === undefined) setOwn(next);\n    setAnnouncement(add ? `Added ${option.label}. ${count} ${count === 1 ? \"reaction\" : \"reactions\"}.` : count ? `Removed ${option.label}. ${count} ${count === 1 ? \"reaction\" : \"reactions\"}.` : `Removed ${option.label}.`);\n    onValueChange?.(next, { id, reacted: add });\n  }\n\n  function pick(id: string) {\n    setOpen(false);\n    trigger.current?.focus();\n    toggle(id);\n  }\n\n  return <motion.div className={[styles.root, className].filter(Boolean).join(\" \")} style={{ height }}>\n    <div ref={bar} className={styles.bar} role=\"group\" aria-label={label}>\n      <LayoutGroup id={groupId}>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {shown.map(reaction => {\n            const option = byId.get(reaction.id)!;\n            const noun = reaction.count === 1 ? \"reaction\" : \"reactions\";\n            return <motion.button key={reaction.id} layout=\"position\" type=\"button\" className={styles.pill} data-reaction={reaction.id} aria-pressed={reaction.reacted}\n              aria-label={`${option.label}, ${reaction.count} ${noun}${reaction.reacted ? \", including yours\" : \"\"}`} onClick={() => toggle(reaction.id)}\n              initial={reduced ? fadeIn : pillIn} animate={rest} exit={reduced ? fadeOut : pillOut} whileTap={{ scale: reduced ? 1 : .97 }}\n              transition={reduced ? { duration: motionTokens.duration.fast, layout: { duration: 0 } } : pillTransition}>\n              <KickIcon icon={option.icon} pop={pops[reaction.id] ?? 0} reduced={reduced} />\n              <RollingCount value={reaction.count} reduced={reduced} />\n              <span className={styles.tip} aria-hidden=\"true\">{option.label}</span>\n            </motion.button>;\n          })}\n        </AnimatePresence>\n        <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>\n          {/* The add button anchors the picker, so it answers a press with colour and never scales. */}\n          <PopoverPrimitive.Trigger asChild>\n            <motion.button ref={trigger} layout=\"position\" transition={reduced ? { layout: { duration: 0 } } : { layout: motionTokens.spring.morph }} type=\"button\" className={styles.add} aria-label=\"Add reaction\">\n              <span className={styles.addIcon} aria-hidden=\"true\">\n                <AnimatePresence initial={false}>\n                  <motion.span key={open ? \"close\" : \"add\"} className={styles.addGlyph} initial={reduced ? fadeIn : iconIn} animate={rest} exit={reduced ? fadeOut : iconOut} transition={reduced ? { duration: motionTokens.duration.fast } : iconEnter}>\n                    {open ? <X size={16} strokeWidth={1.75} /> : <SmilePlus size={16} strokeWidth={1.75} />}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n              {!open && <span className={styles.tip} aria-hidden=\"true\">Add reaction</span>}\n            </motion.button>\n          </PopoverPrimitive.Trigger>\n          <AnimatePresence>\n            {open && <PopoverPrimitive.Portal forceMount>\n              {/* Focus returns by hand: at once after a pick or Escape, and not at all when people click away. */}\n              <PopoverPrimitive.Content forceMount asChild side=\"top\" align=\"center\" sideOffset={8} collisionPadding={12} aria-label=\"Add a reaction\"\n                onEscapeKeyDown={() => trigger.current?.focus()} onCloseAutoFocus={event => event.preventDefault()}>\n                <motion.div className={styles.picker} initial={reduced ? fadeIn : { opacity: 0, scale: .5, filter: blur(motionTokens.blur.soft) }} animate={rest}\n                  exit={reduced ? fadeOut : { opacity: 0, scale: .9, filter: blur(motionTokens.blur.subtle), transition: { duration: motionTokens.duration.fast, ease: standard } }}\n                  transition={reduced ? { duration: motionTokens.duration.fast } : { scale: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: standard }, filter: { duration: motionTokens.duration.fast, ease: enter } }}>\n                  {/* An invisible arrow lets Radix place the transform origin on the trigger's centre, so the tray grows out of the button even when it shifts to stay on screen. */}\n                  <PopoverPrimitive.Arrow width={0} height={0} className={styles.arrow} />\n                  <Picker options={options} reacted={reacted} reduced={reduced} onPick={pick} />\n                </motion.div>\n              </PopoverPrimitive.Content>\n            </PopoverPrimitive.Portal>}\n          </AnimatePresence>\n        </PopoverPrimitive.Root>\n      </LayoutGroup>\n    </div>\n    <span className={styles.srOnly} role=\"status\">{announcement}</span>\n  </motion.div>;\n}\n\nexport default Reactions;\n"},{"path":"registry/components/reactions/reactions.module.css","type":"registry:component","target":"@components/arc/reactions/reactions.module.css","content":"/* The root follows the bar's wrapped height on a spring, so a new row never shoves the content below in one frame. */\n.root { position: relative; min-width: 0; }\n.bar { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) 6px; }\n\n/* Compact pills: an icon and a tabular count. The viewer's own reactions take the accent and a filled glyph, so the state never rests on colour alone. */\n.pill, .add { position: relative; display: inline-flex; height: 32px; flex: none; align-items: center; margin: 0; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface); color: var(--text-secondary); font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }\n.pill { gap: 6px; padding: 0 11px 0 9px; font-size: var(--text-sm); font-weight: 500; line-height: 1; letter-spacing: var(--tracking-body); font-variant-numeric: tabular-nums; }\n.pill[aria-pressed=\"true\"] { border-color: color-mix(in oklab, var(--accent) 45%, var(--border)); background: var(--accent-subtle); color: var(--accent-strong); }\n.pill[aria-pressed=\"true\"] .icon svg { fill: color-mix(in oklab, currentColor 22%, transparent); }\n.add { justify-content: center; width: 38px; padding: 0; color: var(--text-muted); }\n.add[data-state=\"open\"] { border-color: var(--border-strong); background: var(--surface-muted); color: var(--foreground); }\n.pill:active { background: var(--surface-muted); }\n.pill[aria-pressed=\"true\"]:active { background: color-mix(in oklab, var(--accent) 18%, var(--surface)); }\n.add:active { background: var(--surface-muted); color: var(--foreground); }\n@media (hover: hover) and (pointer: fine) {\n  .pill:hover, .add:hover { border-color: var(--border-strong); background: var(--surface-muted); color: var(--foreground); }\n  .pill[aria-pressed=\"true\"]:hover { border-color: color-mix(in oklab, var(--accent) 65%, var(--border)); background: color-mix(in oklab, var(--accent) 17%, var(--surface)); color: var(--accent-strong); }\n  .pill:hover .tip, .add:hover .tip { opacity: 1; transform: translate(-50%, 0); transition-delay: .45s; }\n  .pill:hover, .add:hover { z-index: 2; }\n}\n.pill:focus-visible, .add:focus-visible { z-index: 2; }\n.pill:focus-visible .tip { opacity: 1; transform: translate(-50%, 0); transition-delay: .2s; }\n\n.icon, .addIcon { display: grid; width: 16px; height: 16px; flex: none; place-items: center; }\n.addGlyph { display: grid; grid-area: 1 / 1; place-items: center; }\n/* The count window hugs its digits and springs when a digit is added; digits leaving pop out of flow so the width follows at once. */\n.count { display: inline-flex; min-width: 0; }\n.digits { position: relative; display: inline-flex; width: max-content; }\n.digit { display: inline-block; }\n\n/* The label appears after a short hover or on keyboard focus. It floats, so it earns a quiet shadow. */\n.tip { position: absolute; bottom: calc(100% + 8px); left: 50%; z-index: 1; padding: 5px 9px; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); box-shadow: var(--shadow-raised); color: var(--foreground); font-size: var(--text-xs); font-weight: 400; line-height: 1.2; letter-spacing: var(--tracking-body); white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 3px); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }\n\n/* The picker grows out of the add button: Radix places the transform origin on the button's centre. */\n.picker { z-index: 60; display: grid; gap: 2px; padding: 5px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); transform-origin: var(--radix-popover-content-transform-origin); outline: none; }\n.picker[data-state=\"closed\"] { pointer-events: none; }\n.arrow { visibility: hidden; }\n.options { position: relative; isolation: isolate; display: flex; gap: 2px; }\n.highlight { position: absolute; top: 0; left: 0; z-index: -1; width: 40px; height: 40px; border-radius: var(--radius-pill); background: var(--surface-muted); }\n.option { position: relative; display: grid; width: 40px; height: 40px; flex: none; place-items: center; padding: 0; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n/* Chosen options carry a ring as well as the tint, so they never read as the hover highlight. */\n.option[aria-pressed=\"true\"] { background: var(--accent-subtle); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent); color: var(--accent-strong); }\n.option[aria-pressed=\"true\"] svg { fill: color-mix(in oklab, currentColor 22%, transparent); }\n@media (hover: hover) and (pointer: fine) { .option:hover { color: var(--foreground); } .option[aria-pressed=\"true\"]:hover { color: var(--accent-strong); } }\n.optionIcon { display: grid; place-items: center; }\n.caption { position: relative; display: flex; height: 22px; align-items: center; justify-content: center; color: var(--text-secondary); font-size: var(--text-xs); line-height: 1; }\n.captionText { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .pill, .add, .option, .tip { transition: none; } }\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/reactions","meta":{"tier":"free","kind":"component","tags":["reactions","social","comments"],"docs":"https://uiarc.dev/components/reactions","markdown":"https://uiarc.dev/components/reactions/markdown"}}