Reaction Burst
Emoji reaction chips with a picker; reacting bursts a few copies of the emoji and rolls the count.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/reaction-burst.json'use client'
import { useEffect, useRef, useState } from 'react'
import './reaction-burst.css'
export interface Reaction {
emoji: string
label: string
count: number
}
export interface ReactionBurstProps {
/** Emoji offered in the picker, with names for screen readers. */
options: { emoji: string; label: string }[]
initial?: Reaction[]
/** Emoji size in px. */
size?: number
fill?: 'dark' | 'light'
/** Demo hook: emoji to react with next (changes trigger a reaction). */
autoReact?: { emoji: string; n: number }
className?: string
}
function Digits({ n }: { n: number }) {
const s = String(n)
return (
<span className="sd-roll" aria-hidden>
{s.split('').map((ch, i) => (
<span key={s.length - i} className="sd-roll__col">
<span className="sd-roll__strip" style={{ transform: `translateY(${-Number(ch) * 10}%)` }}>
{'0123456789'.split('').map((d) => (
<span key={d}>{d}</span>
))}
</span>
</span>
))}
</span>
)
}
/** Reaction chips with a picker. Reacting bursts a few copies of the emoji and rolls the count. */
export function ReactionBurst({ options, initial = [], size = 22, fill = 'dark', autoReact, className }: ReactionBurstProps) {
const [list, setList] = useState<(Reaction & { mine?: boolean })[]>(initial)
const [open, setOpen] = useState(false)
const [burst, setBurst] = useState<{ emoji: string; key: number } | null>(null)
const react = (emoji: string) => {
const label = options.find((o) => o.emoji === emoji)?.label ?? emoji
setList((l) => {
const hit = l.find((r) => r.emoji === emoji)
if (!hit) return [...l, { emoji, label, count: 1, mine: true }]
return l.map((r) => (r.emoji === emoji ? { ...r, count: r.count + (r.mine ? -1 : 1), mine: !r.mine } : r)).filter((r) => r.count > 0)
})
setBurst({ emoji, key: Date.now() })
setOpen(false)
}
const reactRef = useRef(react)
reactRef.current = react
const autoN = autoReact?.n
useEffect(() => {
if (autoN && autoReact) reactRef.current(autoReact.emoji)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoN])
return (
<div className={['sd-rb', className].filter(Boolean).join(' ')} data-fill={fill} style={{ '--rb-size': `${size}px` } as React.CSSProperties}>
<div className="sd-rb__picker" data-open={open || undefined} role="menu" aria-label="Pick a reaction" aria-hidden={!open}>
{options.map((o, i) => (
<button key={o.emoji} type="button" role="menuitem" tabIndex={open ? 0 : -1} aria-label={o.label} onClick={() => react(o.emoji)} style={{ '--i': i } as React.CSSProperties}>
{o.emoji}
</button>
))}
</div>
<ul className="sd-rb__row" aria-label="Reactions">
{list.map((r) => (
<li key={r.emoji}>
<button type="button" className="sd-rb__chip" aria-pressed={Boolean(r.mine)} aria-label={`${r.label}, ${r.count}`} onClick={() => react(r.emoji)}>
<span className="sd-rb__emo">{r.emoji}</span>
<Digits n={r.count} />
{burst?.emoji === r.emoji && (
<span className="sd-rb__burst" key={burst.key} aria-hidden>
{[0, 1, 2, 3, 4, 5].map((k) => (
<i key={k} style={{ '--a': `${k * 60 - 90}deg` } as React.CSSProperties}>{r.emoji}</i>
))}
</span>
)}
</button>
</li>
))}
<li>
<button type="button" className="sd-rb__add" aria-label="Add reaction" aria-expanded={open} aria-haspopup="menu" onClick={() => setOpen((o) => !o)} onKeyDown={(e) => e.key === 'Escape' && setOpen(false)}>
<svg viewBox="0 0 20 20" aria-hidden><circle cx="10" cy="10" r="7" /><path d="M7 11.5c.8 1 1.8 1.5 3 1.5s2.2-.5 3-1.5M7.5 8h.01M12.5 8h.01" /></svg>
</button>
</li>
</ul>
</div>
)
}.sd-rb { --bg: #232323; --chip: #2a2a2a; --fg: #fafafa; --mute: #9a9a9a; --on: rgb(255 255 255 / 0.12); --on-line: #fafafa;
position: relative; display: grid; gap: 12px; justify-items: start; padding-top: 58px; color: var(--fg); font-feature-settings: 'tnum'; }
.sd-rb[data-fill='light'] { --bg: #fff; --chip: #f1f1f1; --fg: #111; --mute: #666; --on: rgb(0 0 0 / 0.08); --on-line: #111; }
.sd-rb__picker { position: absolute; top: 0; left: 0; display: flex; gap: 2px; padding: 6px; border-radius: 999px; background: var(--bg); box-shadow: 0 16px 40px -14px rgb(0 0 0 / 0.6), inset 0 0 0 1px rgb(127 127 127 / 0.2);
opacity: 0; transform: translateY(8px) scale(0.9); transform-origin: bottom left; pointer-events: none; transition: opacity 200ms, transform 350ms cubic-bezier(0.3, 1.5, 0.4, 1); }
.sd-rb__picker[data-open] { opacity: 1; transform: none; pointer-events: auto; }
.sd-rb__picker button { width: calc(var(--rb-size) + 14px); height: calc(var(--rb-size) + 14px); border-radius: 999px; font-size: var(--rb-size); transition: transform 250ms cubic-bezier(0.3, 1.8, 0.4, 1); }
.sd-rb__picker[data-open] button { animation: sd-rb-in 420ms cubic-bezier(0.3, 1.6, 0.4, 1) both; animation-delay: calc(var(--i) * 30ms); }
.sd-rb__picker button:hover, .sd-rb__picker button:focus-visible { transform: scale(1.3) translateY(-3px); outline: none; }
@keyframes sd-rb-in { from { transform: translateY(10px) scale(0.4); opacity: 0; } }
.sd-rb__row { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; list-style: none; }
.sd-rb__chip, .sd-rb__add { position: relative; display: inline-flex; align-items: center; gap: 7px; height: calc(var(--rb-size) + 18px); padding: 0 13px 0 10px; border-radius: 999px; background: var(--chip); color: var(--fg); font-size: 15px; font-weight: 500; transition: background-color 200ms, box-shadow 200ms, transform 300ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-rb__chip:active, .sd-rb__add:active { transform: scale(0.92); }
.sd-rb__chip[aria-pressed='true'] { background: var(--on); box-shadow: inset 0 0 0 1.5px var(--on-line); }
.sd-rb__emo { font-size: var(--rb-size); line-height: 1; }
.sd-rb__add { padding: 0; width: calc(var(--rb-size) + 18px); justify-content: center; color: var(--mute); }
.sd-rb__add svg { width: 20px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; }
.sd-rb button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rb__burst { position: absolute; left: 22px; top: 50%; pointer-events: none; }
.sd-rb__burst i { position: absolute; font-style: normal; font-size: 15px; translate: -50% -50%; animation: sd-rb-fly 700ms cubic-bezier(0.2, 0.8, 0.3, 1) forwards; }
@keyframes sd-rb-fly { to { transform: rotate(var(--a)) translateX(34px) rotate(calc(var(--a) * -1)) scale(0.6); opacity: 0; } }
.sd-roll { display: inline-flex; line-height: 1.1; }
.sd-roll__col { display: inline-block; height: 1.1em; overflow: hidden; }
.sd-roll__strip { display: flex; flex-direction: column; transition: transform 650ms cubic-bezier(0.2, 1.35, 0.35, 1); }
.sd-roll__strip > span { height: 1.1em; }
@media (prefers-reduced-motion: reduce) { .sd-rb *, .sd-roll__strip { transition: none !important; animation: none !important; } .sd-rb__burst { display: none; } }Overview
Existing reactions sit as chips with rolling counts. The smiley button opens a picker that springs up one emoji at a time.
Your own reactions are highlighted and toggle off when pressed again.
When to use
- Comments, messages, changelogs and posts.
When not to use
- Formal feedback where a rating or text answer is needed.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/reaction-burst.json$
npx shadcn@latest add https://sundaeui.dev/r/reaction-burst.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/reaction-burst.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/reaction-burst.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 reaction-burst.tsx and reaction-burst.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 { ReactionBurst } from '@/components/sundae/reaction-burst'
<ReactionBurst
options={[{ emoji: '🎉', label: 'Party' }, { emoji: '🔥', label: 'Fire' }]}
initial={[{ emoji: '🎉', label: 'Party', count: 12 }]}
/>Props
| Prop | Type | Default |
|---|---|---|
options*Picker emoji with screen-reader names. | { emoji; label }[] | — |
initialStarting reactions and counts. | Reaction[] | [] |
sizeEmoji size, px. | number | 22 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Chips are toggle buttons named by emoji label and count ("Party, 12").
- The picker is a menu opened by a button with aria-expanded; Escape closes it.
- Reduced motion removes the burst and the roll.
Customization
- Every colour is a CSS custom property on the root class; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Enter / Space
- React or remove your reaction
- Escape
- Close the picker
FAQ
- Is Reaction Burst free?
- Yes. Reaction Burst is free for personal and commercial projects, with full source on this page.
- How do I install Reaction Burst?
- Run npx shadcn@latest add https://sundaeui.dev/r/reaction-burst.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Reaction Burst support keyboard and screen readers?
- Chips are toggle buttons named by emoji label and count ("Party, 12"). The picker is a menu opened by a button with aria-expanded; Escape closes it.
- Does Reaction Burst respect reduced motion?
- Reduced motion removes the burst and the roll.
More like this
All components →Emoji Picker
Components · RefinedToast Stack
Components · RefinedMessage Thread
Components · RefinedFold Accordion
Components · Understated- Pro
Sheet Drawer
Components · Refined - Pro
AI Composer
Components · Understated