emoteer

Post reactions

A Discord-style message with a reaction row and an add-reaction picker

A chat-style message with reactions: the avatar, author and body sit above the reaction row. Existing reactions show their counts and active state, an "add" button opens a picker, and clicking a chip toggles your reaction.

vyersToday at 12:42

Shipping the Svelte bindings today šŸš€ — react with whatever fits.

The pattern

components/message.tsx
import {
  EmoteList,
  ReactionButton,
  ReactionCounter,
  type Reaction,
} from '@emoteer/react';
import { useState } from 'react';

const INITIAL: Reaction[] = [
  { emoji: 'šŸ‘', count: 8, active: false },
  { emoji: 'ā¤ļø', count: 3, active: true },
  { emoji: 'šŸŽ‰', count: 2, active: false },
];

export function Message() {
  const [reactions, setReactions] = useState(INITIAL);
  const [pickerOpen, setPickerOpen] = useState(false);

  const handleToggle = (emoji: string, active: boolean) => {
    setReactions((prev) =>
      prev
        .map((r) =>
          r.emoji === emoji
            ? { ...r, active, count: r.count + (active ? 1 : -1) }
            : r,
        )
        .filter((r) => r.count > 0),
    );
  };

  const handleAdd = (emoji: string) => {
    setReactions((prev) =>
      prev.some((r) => r.emoji === emoji)
        ? prev
        : [...prev, { emoji, count: 1, active: true }],
    );
    setPickerOpen(false);
  };

  return (
    <div className="flex gap-3">
      <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-lg">
        🦊
      </div>
      <div className="flex flex-col gap-1.5">
        <p className="text-sm">
          <span className="font-semibold">vyers</span>{' '}
          <span className="text-xs text-muted-foreground">Today at 12:42</span>
        </p>
        <p className="text-sm">
          Shipping the Svelte bindings today šŸš€ — react with whatever fits.
        </p>

        <div className="mt-0.5 flex flex-wrap items-center gap-2">
          <ReactionCounter reactions={reactions} onToggle={handleToggle} />

          <ReactionButton.Popover
            open={pickerOpen}
            onOpenChange={(d) => setPickerOpen(d.open)}
          >
            <ReactionButton.Trigger>
              <button className="h-7 rounded-full border px-2.5 text-sm text-muted-foreground">
                +
              </button>
            </ReactionButton.Trigger>
            <ReactionButton.Content>
              <EmoteList.Root onSelect={(e) => handleAdd(e.unicode)}>
                <EmoteList.Search />
                <EmoteList.Tabs />
                <EmoteList.Grid />
              </EmoteList.Root>
            </ReactionButton.Content>
          </ReactionButton.Popover>
        </div>
      </div>
    </div>
  );
}
components/Message.svelte
<script lang="ts">
  import {
    EmoteList,
    ReactionButton,
    ReactionCounter,
    type Reaction,
  } from '@emoteer/svelte';

  let reactions = $state<Reaction[]>([
    { emoji: 'šŸ‘', count: 8, active: false },
    { emoji: 'ā¤ļø', count: 3, active: true },
    { emoji: 'šŸŽ‰', count: 2, active: false },
  ]);
  let pickerOpen = $state(false);

  function handleToggle(emoji: string, active: boolean) {
    reactions = reactions
      .map((r) =>
        r.emoji === emoji
          ? { ...r, active, count: r.count + (active ? 1 : -1) }
          : r,
      )
      .filter((r) => r.count > 0);
  }

  function handleAdd(emoji: string) {
    if (!reactions.some((r) => r.emoji === emoji)) {
      reactions = [...reactions, { emoji, count: 1, active: true }];
    }
    pickerOpen = false;
  }
</script>

<div class="flex gap-3">
  <div class="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-lg">
    🦊
  </div>
  <div class="flex flex-col gap-1.5">
    <p class="text-sm">
      <span class="font-semibold">vyers</span>
      <span class="text-xs text-muted-foreground">Today at 12:42</span>
    </p>
    <p class="text-sm">
      Shipping the Svelte bindings today šŸš€ — react with whatever fits.
    </p>

    <div class="mt-0.5 flex flex-wrap items-center gap-2">
      <ReactionCounter {reactions} onToggle={handleToggle} />

      <ReactionButton.Popover open={pickerOpen} onOpenChange={(d) => (pickerOpen = d.open)}>
        <ReactionButton.Trigger>+</ReactionButton.Trigger>
        <ReactionButton.Content>
          <EmoteList.Root onSelect={(e) => handleAdd(e.unicode)}>
            <EmoteList.Search />
            <EmoteList.Tabs />
            <EmoteList.Grid />
          </EmoteList.Root>
        </ReactionButton.Content>
      </ReactionButton.Popover>
    </div>
  </div>
</div>

What it does

  • The message chrome (avatar, author, timestamp, body) is plain markup — the only stateful part is the reaction row.
  • Toggling a chip bumps or decrements its count; reactions that hit 0 drop off the list.
  • The add button opens a full picker; choosing an emoji pushes a new active reaction (or no-ops if it already exists).

Backend integration

The demo hardcodes the message and INITIAL reactions. In a real app, accept them as props and wrap handleToggle / handleAdd in mutations for optimistic updates — roll back in onError, invalidate the message on success. The component stays the same; all async concerns live outside it.

On this page