Skip to content
Star on GitHub
CollaborationReactions
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Reactions

Quick reactions on an agent update, with counts that pop. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add reactions
Design agent10:42

Shipped the pricing update to staging. Trial to paid preview is up 8.1 points against last month.

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add reactions

React

$npx designme add reactions --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Reactions puts a row of emoji reactions under a message, set up for an update the agent posted. People click a reaction to add or remove their own, or pick a new one from a small picker, and the counts roll up or down as they change. It gives the team a lightweight way to acknowledge or approve what the agent reported.

  • When an agent posts a status update in a chat thread and people want to acknowledge it
  • When a quick signal, such as a check or eyes, is enough and a reply would be noise
  • When the team should see at a glance who has reacted to an update
  • When you want an agent update to sit flat in a thread or stand alone as a card
03

Usage

Import it and drive it from your own state.

Import

import { Reactions } from '@/components/designme/reactions/Reactions';

Example

The demo above as a React component, 29 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { Reactions, type ReactionsItem } from './Reactions';
import { useStory } from '../lib/useScript';

/* ---------- Reactions: three reactions on an agent update, and you add a rocket after a moment ---------- */
const START: ReactionsItem[] = [
  { emoji: '๐Ÿš€', count: 4, people: ['Maya', 'Jon', 'Ari', 'Sam'] },
  { emoji: '๐Ÿ‘€', count: 2, people: ['Jon', 'Dana'] },
  { emoji: 'โœ…', count: 1, people: ['Maya'] }
];

export default function ReactionsDemo() {
  const [reactions, setReactions] = useState(START);
  // the preview adds one reaction on its own, unless you take over first
  const ref = useRef<HTMLDivElement>(null);
  useStory(ref, async ({ wait }) => {
    await wait(1100);
    setReactions(list => list.map((r, i) => i === 0 ? { ...r, mine: !r.mine, count: r.count + (r.mine ? -1 : 1) } : r).filter(r => r.count > 0));
  });
  return (
    <div ref={ref} style={{ display: 'contents' }}>
      <Reactions author="Design agent" time="10:42" reactions={reactions} onReactionsChange={setReactions}
        message="Shipped the pricing update to staging. Trial to paid preview is up 8.1 points against last month." />
    </div>
  );
}
04

Props

Everything Reactions takes.

PropTypeWhat it does
authorrequiredReactNodeWho wrote the message, like "Design agent"
timeReactNodeWhen, like "10:42"
messagerequiredReactNodeThe message being reacted to
iconIconNameThe icon on the author's square
reactionsReactionsItem[]The reactions under the message. Leave it out to let clicks and the picker change them
defaultReactionsReactionsItem[]
onReactionsChange(reactions: ReactionsItem[]) => void
emojisstring[]The emoji in the picker
meLabelstringHow you are named in the tooltips
addLabelstringThe add button's tooltip
barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

Code

The full source, as the CLI installs it.

Reactions.tsxReact ยท 167 lines
'use client';
import { useEffect, useRef, useState, type AnimationEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './reactions.css';

export interface ReactionsItem {
  emoji: string;
  /** How many people reacted with it, you included */
  count: number;
  /** Who else reacted, for the tooltip, like ['Maya', 'Jon'] */
  people?: string[];
  /** Whether you reacted with it. Your reactions are tinted and their count is accent coloured */
  mine?: boolean;
}

export interface ReactionsProps {
  /** Who wrote the message, like "Design agent" */
  author: ReactNode;
  /** When, like "10:42" */
  time?: ReactNode;
  /** The message being reacted to */
  message: ReactNode;
  /** The icon on the author's square */
  icon?: IconName;
  /** The reactions under the message. Leave it out to let clicks and the picker change them */
  reactions?: ReactionsItem[];
  defaultReactions?: ReactionsItem[];
  onReactionsChange?: (reactions: ReactionsItem[]) => void;
  /** The emoji in the picker */
  emojis?: string[];
  /** How you are named in the tooltips */
  meLabel?: string;
  /** The add button's tooltip */
  addLabel?: string;
  /** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
  bare?: boolean;
  className?: string;
}

const EMOJIS = ['๐Ÿ‘', 'โค๏ธ', '๐ŸŽ‰', '๐Ÿš€', '๐Ÿ‘€', 'โœ…', '๐Ÿ”ฅ', '๐Ÿ’ฏ', '๐Ÿ™Œ', '๐Ÿค”', 'โœจ', '๐Ÿ‘'];

/** "You, Maya, Jon and 2 more" */
const whoOf = (r: ReactionsItem, me: string) => {
  const who = r.mine ? [me, ...(r.people ?? [])] : r.people ?? [];
  return who.slice(0, 3).join(', ') + (who.length > 3 ? ` and ${who.length - 3} more` : '');
};

/** A reaction on screen: the latest data, or the last data of one that is fading out */
interface Shown { item: ReactionsItem; leaving: boolean }

/** Keeps a removed reaction in its place while it fades and shrinks, then lets it go */
function useLeaving(items: ReactionsItem[], ms: number) {
  const [shown, setShown] = useState<Shown[]>(() => items.map(item => ({ item, leaving: false })));
  const [prev, setPrev] = useState(items);
  if (prev !== items) {
    setPrev(items);
    const now = new Set(items.map(r => r.emoji));
    const next: Shown[] = items.map(item => ({ item, leaving: false }));
    shown.forEach((s, i) => { if (!now.has(s.item.emoji)) next.splice(Math.min(i, next.length), 0, { item: s.item, leaving: true }); });
    setShown(next);
  }
  const leaving = shown.filter(s => s.leaving).map(s => s.item.emoji).join('\u0000');
  useEffect(() => {
    if (!leaving) return;
    const t = setTimeout(() => setShown(list => list.filter(s => !s.leaving)), ms);
    return () => clearTimeout(t);
  }, [leaving, ms]);
  return shown;
}

function Reaction({ item, leaving, fresh, pulse, me, onToggle }: { item: ReactionsItem; leaving: boolean; fresh: boolean; pulse: number; me: string; onToggle: () => void }) {
  const ref = useRef<HTMLButtonElement>(null);
  const [isNew, setNew] = useState(fresh);
  // the count rolls up or down instead of the whole row re-rendering under the pointer
  const [nums, setNums] = useState([{ k: 0, v: item.count, c: '' }]);
  const last = useRef(item.count);
  useEffect(() => {
    if (item.count === last.current || leaving) return;
    const up = item.count > last.current; last.current = item.count;
    setNums(ns => { const top = ns[ns.length - 1]; return [{ ...top, c: up ? 'dm-ou' : 'dm-od' }, { k: top.k + 1, v: item.count, c: up ? 'dm-iu' : 'dm-id' }]; });
    const t = setTimeout(() => setNums(ns => ns.slice(-1)), 380);
    return () => clearTimeout(t);
  }, [item.count, leaving]);

  // a ring and a little jump when you react, or pick one you already have
  const [bumped, setBumped] = useState(false);
  const bump = () => {
    const el = ref.current;
    if (!el || !el.classList.contains('dm-bump')) { setBumped(true); return; }
    el.classList.remove('dm-bump'); void el.offsetWidth; el.classList.add('dm-bump');
  };
  const wasMine = useRef(!!item.mine);
  useEffect(() => {
    const mine = !!item.mine;
    if (mine && !wasMine.current && !leaving) bump();
    wasMine.current = mine;
  }, [item.mine, leaving]);
  useEffect(() => { if (pulse) bump(); }, [pulse]);
  useEffect(() => { if (fresh && item.mine) bump(); }, []); // eslint-disable-line react-hooks/exhaustive-deps

  return (
    <Tip label={whoOf(item, me)} off={leaving}>
      <button type="button" ref={ref} className={cx('dm-rxx-r', item.mine && 'dm-me', isNew && 'dm-new', bumped && 'dm-bump', leaving && 'dm-gone')}
        aria-pressed={!!item.mine} onClick={leaving ? undefined : onToggle}
        onAnimationEnd={(e: AnimationEvent) => { if (e.target === e.currentTarget && e.animationName.endsWith('popin')) setNew(false); }}>
        <span className="dm-e">{item.emoji}</span>
        <span className="dm-n dm-num">{nums.map(n => <i key={n.k} className={n.c || undefined}>{n.v}</i>)}</span>
      </button>
    </Tip>
  );
}

/** Quick reactions on an agent update, with counts that pop. */
export function Reactions({ author, time, message, icon = 'spark', reactions, defaultReactions = [], onReactionsChange, emojis = EMOJIS, meLabel = 'You', addLabel = 'Add reaction', bare = true, className }: ReactionsProps) {
  const [list, setList] = useControllable(reactions, defaultReactions, onReactionsChange);
  const shown = useLeaving(list, 320);
  const menu = usePopover();
  // reactions that arrive after the first render pop in
  const [initial] = useState(() => new Set(list.map(r => r.emoji)));
  const [pulses, setPulses] = useState<Record<string, number>>({});

  const toggle = (emoji: string) => setList(list.flatMap(r => {
    if (r.emoji !== emoji) return [r];
    const mine = !r.mine, count = r.count + (mine ? 1 : -1);
    return count > 0 ? [{ ...r, mine, count }] : [];
  }));
  const pick = (emoji: string) => {
    const had = list.find(r => r.emoji === emoji);
    if (!had) setList([...list, { emoji, count: 1, people: [], mine: true }]);
    else if (!had.mine) toggle(emoji);
    else setPulses(p => ({ ...p, [emoji]: (p[emoji] ?? 0) + 1 }));
    menu.closeSoon();
  };

  return (
    <div data-dm="" className={cx('dm-rxx', !bare && 'dm-panel', className)}>
      <div className="dm-rxx-m">
        <span className="dm-rxx-ic"><Icon name={icon} size="xs" /></span>
        <div className="dm-rxx-b">
          <span className="dm-rxx-n"><b>{author}</b>{time && <span className="dm-muted">{time}</span>}</span>
          <p>{message}</p>
        </div>
      </div>
      <div className="dm-rxx-row">
        <div className="dm-rxx-list">
          {shown.map(({ item, leaving }) => (
            <Reaction key={item.emoji} item={item} leaving={leaving} fresh={!initial.has(item.emoji)} pulse={pulses[item.emoji] ?? 0} me={meLabel} onToggle={() => toggle(item.emoji)} />
          ))}
        </div>
        <Tip label={addLabel} off={menu.open}>
          <button type="button" className="dm-rxx-add" aria-label={addLabel} {...menu.trigger}><Icon name="smile" size="xs" /></button>
        </Tip>
      </div>
      <div {...menu.pop} className={cx(menu.pop.className, 'dm-rxx-pk')} role="menu" aria-label={addLabel}>
        <div className="dm-rxx-grid">
          {emojis.map(e => <button type="button" key={e} role="menuitem" onClick={() => pick(e)}>{e}</button>)}
        </div>
      </div>
    </div>
  );
}
reactions.cssReact ยท 42 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Reactions ---------- */
/* an agent message in the feed: no container; with .panel it becomes a card */
.dm-rxx{padding:14px 0;display:flex;flex-direction:column;gap:10px}
.dm-rxx.dm-panel{padding:14px}
.dm-rxx-m{display:flex;gap:10px}
.dm-rxx-ic{width:30px;height:30px;border-radius:10px;background:var(--dm-accent);color:var(--dm-on-accent);display:grid;place-items:center;flex:none}
.dm-rxx-b{display:flex;flex-direction:column;font-size:13.5px;line-height:1.5;min-width:0}
.dm-rxx-n{display:flex;gap:6px;align-items:baseline;font-size:13px}
.dm-rxx-n b{font-weight:500}
.dm-rxx-n .dm-muted{font-size:11.5px}
.dm-rxx-b p{margin:2px 0 0;color:var(--dm-fg2)}
.dm-rxx-row{display:flex;flex-wrap:wrap;gap:5px;padding-left:40px;align-items:center}
.dm-rxx-list{display:contents}
.dm-rxx-r{position:relative;display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 9px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13px;transition:background .2s,box-shadow .2s,transform .15s,opacity .3s}
/* white pills with a hairline, so they stand out on a grey page; yours has a stronger grey edge and its count in the accent */
@media (hover:hover) and (pointer:fine){.dm-rxx-r:hover{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}}
.dm-rxx-r:active{transform:scale(.94)}
.dm-rxx-r.dm-me{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-pick-on) 50%,transparent)}
.dm-rxx-r .dm-n{display:inline-grid;overflow:hidden;height:16px;font-size:12px;line-height:16px;color:var(--dm-fg2);min-width:8px}
.dm-rxx-r .dm-n i{grid-area:1/1;font-style:normal;text-align:center}
.dm-rxx-r.dm-me .dm-n{color:var(--dm-accent-fg,var(--dm-accent));font-weight:500}
.dm-rxx-r .dm-n .dm-iu{animation:dm-rxiu .38s var(--dm-ease) both}.dm-rxx-r .dm-n .dm-ou{animation:dm-rxou .38s var(--dm-ease) both}
.dm-rxx-r .dm-n .dm-id{animation:dm-rxid .38s var(--dm-ease) both}.dm-rxx-r .dm-n .dm-od{animation:dm-rxod .38s var(--dm-ease) both}
@keyframes dm-rxiu{from{transform:translateY(100%);opacity:0}}@keyframes dm-rxou{to{transform:translateY(-100%);opacity:0}}
@keyframes dm-rxid{from{transform:translateY(-100%);opacity:0}}@keyframes dm-rxod{to{transform:translateY(100%);opacity:0}}
.dm-rxx-r::after{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0}
.dm-rxx-r.dm-bump::after{animation:dm-rxring .6s var(--dm-ease)}
@keyframes dm-rxring{0%{opacity:.5;box-shadow:0 0 0 0 var(--dm-accent-mark,var(--dm-accent))}100%{opacity:0;box-shadow:0 0 0 7px var(--dm-accent-mark,var(--dm-accent))}}
.dm-rxx-r.dm-new{animation:dm-popin .45s var(--dm-ease) backwards}
.dm-rxx-r.dm-gone{opacity:0;transform:scale(.8)}
.dm-rxx-r .dm-e{display:inline-block}
.dm-rxx-r.dm-bump .dm-e{animation:dm-rxp .5s var(--dm-ease)}
@keyframes dm-rxp{0%{transform:scale(1)}35%{transform:scale(1.5) rotate(-12deg)}60%{transform:scale(.92) rotate(4deg)}100%{transform:scale(1)}}
.dm-rxx-add{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;color:var(--dm-muted);transition:background .2s,color .2s}
.dm-rxx-add[aria-expanded="true"]{background:var(--dm-row);color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.dm-rxx-add:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-rxx-pk{min-width:0;padding:6px}
.dm-rxx-grid{display:grid;grid-template-columns:repeat(6,32px);gap:2px}
.dm-rxx-grid button{width:32px;height:32px;border-radius:var(--dm-r-sm);font-size:17px;transition:background .15s,transform .2s var(--dm-ease)}
@media (hover:hover) and (pointer:fine){.dm-rxx-grid button:hover{background:var(--dm-row);transform:scale(1.15)}}
reactions.htmlHTML ยท 10 lines
<div class="card bare" data-c="reactions">
  <div class="stage-in">
    <div class="rxx">
      <div class="rxx-m"><span class="rxx-ic"><svg class="i xs"><use href="#i-spark"/></svg></span><div class="rxx-b"><span class="rxx-n"><b>Design agent</b><span class="muted">10:42</span></span><p>Shipped the pricing update to staging. Trial to paid preview is up 8.1 points against last month.</p></div></div>
      <div class="rxx-row"><div class="rxx-list"></div><button class="rxx-add" data-pop=".rxx-pk" aria-label="Add reaction" data-tip="Add reaction"><svg class="i xs"><use href="#i-smile"/></svg></button></div>
      <div class="pop rxx-pk" role="menu"><div class="rxx-grid"></div></div>
    </div>
  </div>
</div>
reactions.cssHTML ยท 41 lines
/* ---------- Reactions ---------- */
/* an agent message in the feed: no container; with .panel it becomes a card */
.rxx{padding:14px 0;display:flex;flex-direction:column;gap:10px}
.rxx.panel{padding:14px}
.rxx-m{display:flex;gap:10px}
.rxx-ic{width:30px;height:30px;border-radius:10px;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;flex:none}
.rxx-b{display:flex;flex-direction:column;font-size:13.5px;line-height:1.5;min-width:0}
.rxx-n{display:flex;gap:6px;align-items:baseline;font-size:13px}
.rxx-n b{font-weight:500}
.rxx-n .muted{font-size:11.5px}
.rxx-b p{margin:2px 0 0;color:var(--fg2)}
.rxx-row{display:flex;flex-wrap:wrap;gap:5px;padding-left:40px;align-items:center}
.rxx-list{display:contents}
.rxx-r{position:relative;display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 9px;border-radius:var(--r-pill);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);font-size:13px;transition:background .2s,box-shadow .2s,transform .15s,opacity .3s}
/* white pills with a hairline, so they stand out on a grey page; yours has a stronger grey edge and its count in the accent */
@media (hover:hover) and (pointer:fine){.rxx-r:hover{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}}
.rxx-r:active{transform:scale(.94)}
.rxx-r.me{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pick-on) 50%,transparent)}
.rxx-r .n{display:inline-grid;overflow:hidden;height:16px;font-size:12px;line-height:16px;color:var(--fg2);min-width:8px}
.rxx-r .n i{grid-area:1/1;font-style:normal;text-align:center}
.rxx-r.me .n{color:var(--accent-fg,var(--accent));font-weight:500}
.rxx-r .n .iu{animation:rxiu .38s var(--ease) both}.rxx-r .n .ou{animation:rxou .38s var(--ease) both}
.rxx-r .n .id{animation:rxid .38s var(--ease) both}.rxx-r .n .od{animation:rxod .38s var(--ease) both}
@keyframes rxiu{from{transform:translateY(100%);opacity:0}}@keyframes rxou{to{transform:translateY(-100%);opacity:0}}
@keyframes rxid{from{transform:translateY(-100%);opacity:0}}@keyframes rxod{to{transform:translateY(100%);opacity:0}}
.rxx-r::after{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0}
.rxx-r.bump::after{animation:rxring .6s var(--ease)}
@keyframes rxring{0%{opacity:.5;box-shadow:0 0 0 0 var(--accent-mark,var(--accent))}100%{opacity:0;box-shadow:0 0 0 7px var(--accent-mark,var(--accent))}}
.rxx-r.new{animation:popin .45s var(--ease) backwards}
.rxx-r.gone{opacity:0;transform:scale(.8)}
.rxx-r .e{display:inline-block}
.rxx-r.bump .e{animation:rxp .5s var(--ease)}
@keyframes rxp{0%{transform:scale(1)}35%{transform:scale(1.5) rotate(-12deg)}60%{transform:scale(.92) rotate(4deg)}100%{transform:scale(1)}}
.rxx-add{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;color:var(--muted);transition:background .2s,color .2s}
.rxx-add[aria-expanded="true"]{background:var(--row);color:var(--fg)}
@media (hover:hover) and (pointer:fine){.rxx-add:hover{background:var(--row);color:var(--fg)}}
.rxx-pk{min-width:0;padding:6px}
.rxx-grid{display:grid;grid-template-columns:repeat(6,32px);gap:2px}
.rxx-grid button{width:32px;height:32px;border-radius:var(--r-sm);font-size:17px;transition:background .15s,transform .2s var(--ease)}
@media (hover:hover) and (pointer:fine){.rxx-grid button:hover{background:var(--row);transform:scale(1.15)}}
reactions.jsHTML ยท 21 lines
DM.def('reactions', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{spark}', I('spark','xs')).replace('{smile}', I('smile','xs'));
  const R = [['๐Ÿš€',4,['Maya','Jon','Ari','Sam'],0],['๐Ÿ‘€',2,['Jon','Dana'],0],['โœ…',1,['Maya'],0]];
  const list = $('.rxx-list', r);
  const tipOf = b => { const x = b._x, who = x[3] ? ['You', ...x[2]] : x[2]; b.dataset.tip = who.slice(0, 3).join(', ') + (who.length > 3 ? ' and ' + (who.length - 3) + ' more' : ''); if (tipFor === b) showTip(b); };
  function make(x){ const b = html(`<button class="rxx-r${x[3] ? ' me' : ''}" aria-pressed="${!!x[3]}"><span class="e">${x[0]}</span><span class="n num"><i>${x[1]}</i></span></button>`); b._x = x; tipOf(b); b.onclick = () => toggle(b); return b; }
  /* the count rolls up or down instead of the whole row re-rendering under the pointer */
  function roll(b, up){ const n = $('.n', b), old = $('i:last-child', n), nu = html(`<i class="${up ? 'iu' : 'id'}">${b._x[1]}</i>`); old.className = up ? 'ou' : 'od'; n.appendChild(nu); setTimeout(() => old.remove(), 380); }
  function bump(b){ b.classList.remove('bump'); void b.offsetWidth; b.classList.add('bump'); }
  function toggle(b){ const x = b._x; x[3] = x[3] ? 0 : 1; x[1] += x[3] ? 1 : -1; b.classList.toggle('me', !!x[3]); b.setAttribute('aria-pressed', !!x[3]);
    if (x[1] <= 0){ R.splice(R.indexOf(x), 1); b.classList.add('gone'); hideTip(); setTimeout(() => b.remove(), 320); return; }
    roll(b, !!x[3]); tipOf(b); if (x[3]) bump(b); }
  R.forEach(x => list.appendChild(make(x)));
  $('.rxx-grid', r).innerHTML = ['๐Ÿ‘','โค๏ธ','๐ŸŽ‰','๐Ÿš€','๐Ÿ‘€','โœ…','๐Ÿ”ฅ','๐Ÿ’ฏ','๐Ÿ™Œ','๐Ÿค”','โœจ','๐Ÿ‘'].map(e => `<button data-close>${e}</button>`).join('');
  $$('.rxx-grid button', r).forEach(b => b.addEventListener('click', () => { const e = b.textContent, x = R.find(y => y[0] === e);
    if (x){ const el = $$('.rxx-r', list).find(y => y._x === x); if (!x[3]) toggle(el); else bump(el); }
    else { const nx = [e, 1, [], 1]; R.push(nx); const el = make(nx); el.classList.add('new'); list.appendChild(el); bump(el); } }));
  // the preview adds one reaction on its own, unless you take over first
  c.auto(1100, () => toggle($('.rxx-r', list)));
});
06

Questions

What people ask about it.

How do I keep reactions in sync with my backend?
Control it with reactions and onReactionsChange. You get the full updated list after every toggle or pick, and can save it and pass back what the server returns.
What counts as my reaction?
A reaction with mine: true. Its count includes you, and the tooltip puts meLabel, "You" by default, before the names in people.
Can I change the emoji in the picker?
Yes. Pass emojis as an array of strings. Twelve common ones are the default.
07

More in Collaboration

Threads, inboxes and boards for teams working alongside an agent.

CommentsA design thread where you can @mention the agent and it replies.
NotificationsAn inbox where agent updates and mentions arrive live.
BannerAn announcement on a tint of its tone, with its own line drawing.
Invite to collaborateAdd teammates by email as chips, set their role, send.
Profile menuThe account menu behind an avatar: switch workspace, theme, shortcuts and sign out.
Context switcherThe organisation, project and environment you are in, each part a searchable switcher.
Filter menuAdd filters from a menu, see them as chips, and watch the count change.
Ticket listSupport tickets the agent has triaged, with suggested replies.
All 10 in Collaboration โ†’