emoteer

Chat composer

A message input with shortcode autocomplete and an emoji picker button

A chat composer combines an autocomplete-driven input with a picker button. Typing : surfaces inline :shortcode suggestions via EmoteAutocomplete, the ReactionButton.Popover opens a full EmoteList, and Enter sends — pushing each message onto a list rendered as bubbles above the input.

The pattern

components/chat-composer.tsx
import { EmoteAutocomplete, EmoteList, ReactionButton } from '@emoteer/react';
import { useState } from 'react';

export function ChatComposer() {
  const [value, setValue] = useState('');
  const [pickerOpen, setPickerOpen] = useState(false);
  const [messages, setMessages] = useState<string[]>([]);

  const send = () => {
    const text = value.trim();
    if (!text) return;
    setMessages((prev) => [...prev, text]);
    setValue('');
  };

  return (
    <div className="flex flex-col gap-3">
      {messages.length > 0 && (
        <div className="flex flex-col items-end gap-1.5">
          {messages.map((message, i) => (
            <div
              key={i}
              className="max-w-[80%] rounded-2xl rounded-br-sm bg-foreground px-3 py-1.5 text-sm text-background"
            >
              {message}
            </div>
          ))}
        </div>
      )}

      <div className="flex items-center gap-1 rounded-2xl border p-1 focus-within:border-primary">
        <EmoteAutocomplete.Root onSelect={(_, next) => setValue(next)}>
          <EmoteAutocomplete.Input
            value={value}
            onChange={(e) => setValue(e.target.value)}
            onKeyDown={(e) => {
              // The autocomplete consumes Enter to pick a highlighted
              // suggestion; only send when it didn't.
              if (e.key === 'Enter' && !e.defaultPrevented) {
                e.preventDefault();
                send();
              }
            }}
            placeholder="Message… type : for emoji"
            className="min-w-0 flex-1 border-0 bg-transparent px-3 py-1.5 outline-none"
          />
          <EmoteAutocomplete.Content>
            <EmoteAutocomplete.List />
          </EmoteAutocomplete.Content>
        </EmoteAutocomplete.Root>

        <ReactionButton.Popover
          open={pickerOpen}
          onOpenChange={(d) => setPickerOpen(d.open)}
        >
          <ReactionButton.Trigger>
            <ReactionButton.Plus />
          </ReactionButton.Trigger>
          <ReactionButton.Content>
            <EmoteList.Root
              onSelect={(e) => {
                setValue((prev) => `${prev}${e.unicode}`);
                setPickerOpen(false);
              }}
            >
              <EmoteList.Search />
              <EmoteList.Tabs />
              <EmoteList.Grid />
            </EmoteList.Root>
          </ReactionButton.Content>
        </ReactionButton.Popover>

        <button
          type="button"
          onClick={send}
          className="rounded-full bg-foreground px-4 py-1.5 text-sm font-semibold text-background"
        >
          Send
        </button>
      </div>
    </div>
  );
}
components/ChatComposer.svelte
<script lang="ts">
  import { EmoteAutocomplete, EmoteList, ReactionButton } from '@emoteer/svelte';

  let value = $state('');
  let pickerOpen = $state(false);
  let messages = $state<string[]>([]);

  function send() {
    const text = value.trim();
    if (!text) return;
    messages = [...messages, text];
    value = '';
  }
</script>

<div class="flex flex-col gap-3">
  {#if messages.length > 0}
    <div class="flex flex-col items-end gap-1.5">
      {#each messages as message}
        <div
          class="max-w-[80%] rounded-2xl rounded-br-sm bg-foreground px-3 py-1.5 text-sm text-background"
        >
          {message}
        </div>
      {/each}
    </div>
  {/if}

  <div class="flex items-center gap-1 rounded-2xl border p-1 focus-within:border-primary">
    <EmoteAutocomplete.Root onSelect={(_e, next) => (value = next)}>
      <EmoteAutocomplete.Input
        {value}
        oninput={(e) => (value = e.currentTarget.value)}
        onkeydown={(e) => {
          // The autocomplete consumes Enter to pick a highlighted
          // suggestion; only send when it didn't.
          if (e.key === 'Enter' && !e.defaultPrevented) {
            e.preventDefault();
            send();
          }
        }}
        placeholder="Message… type : for emoji"
        class="min-w-0 flex-1 border-0 bg-transparent px-3 py-1.5 outline-none"
      />
      <EmoteAutocomplete.Content>
        <EmoteAutocomplete.List />
      </EmoteAutocomplete.Content>
    </EmoteAutocomplete.Root>

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

    <button
      type="button"
      onclick={send}
      class="rounded-full bg-foreground px-4 py-1.5 text-sm font-semibold text-background"
    >
      Send
    </button>
  </div>
</div>

What it does

  • Typing : followed by a shortcode (e.g. :grin) surfaces inline suggestions. Arrow keys move through them; Enter or click picks one.
  • The picker button opens a full EmoteList; selecting an emoji appends it to the message.
  • Enter sends — except while a suggestion is highlighted, where the autocomplete claims the key (so it never sends mid-completion). Each sent message is pushed onto the local messages list and rendered as a bubble.

Notes

  • The input is controlled here so a single value drives the autocomplete, the picker and the send button. EmoteAutocomplete.Input is uncontrolled by design but accepts value / onChange for this.
  • messages is local component state for the demo — swap it for your own send handler or store to persist and stream real messages.
  • Want a growing multi-line box? Swap the single input for EmoteTextArea to also auto-expand :shortcode: to unicode as the user types.

On this page