Component

Composer

A chat message composer with auto-grow, inline triggers (@mention, /command, :emoji:) committed as atomic tokens, attachments, drafts, busy/stop, and edit-in-place — driven by a headless, framework-agnostic core.

Basic

Enter sends (Shift+Enter for a newline); whitespace-only messages are never sendable. The field clears optimistically on submit.

Mentions

An @ trigger with a local candidate list. Committed mentions are atomic tokens: backspace removes the whole token and the caret never rests inside one.

Slash commands and emoji

A / trigger scoped to the start of the message, plus a : emoji trigger from createEmojiTrigger(), which resolves against the full shared emoji set (~1,900 emoji) — the same source the picker uses. Directly typing a known :shortcode: commits the emoji without opening the menu. The menu now eases in (fade + scale) and honours reduced motion.

Inline expression panel & filled surface

accessoryPanel docks host content (here the full emoji picker) below the field, inside the composer's own stack — never a floating overlay that covers the message you are typing, the web analogue of WhatsApp's panel-in-the-keyboard. A toggle button appears in the action row; open/close is animated and reduced-motion aware. This example also uses surface="filled": a calm muted fill distinct from the page with a tasteful focus ring.

The panel docks below the field, inside the composer — it never floats over the message you are typing. Uses the filled surface.

Attachments

Files arrive via paste, drag-and-drop, or imperatively through apiRef.addAttachment. Chips render in a tray above the field and now animate in (fade + scale) and out (a faster exit) before unmounting; attachments-only messages are sendable. Under reduced motion the removal is instant.

Busy / stop

While busy, the send button becomes a stop button and Enter is swallowed — the ChatGPT/Claude-style send ⇄ stop swap is one prop.

Edit in place

beginEdit seeds the field and submissions carry editingMessageId; Escape cancels and restores the pre-edit draft. ArrowUp on an empty field fires onEditLastRequested.

We ship on Thursday.

States

Disabled (distinct placeholder), read-only (selectable, not editable), a draft-preserving validation error, and the grapheme-aware counter near the limit.

Disabled

Read-only

Validation error (try sending the word “spam”)

Counter near the limit (maxLength 80)

3 characters remaining

Installation

$pnpm add @refraction-ui/react

Usage

tsx
import { RefractionComposer, type ComposerTrigger } from '@refraction-ui/react'

const mentions: ComposerTrigger = {
  id: 'mention',
  symbol: '@',
  resolve: (query) => searchTeammates(query), // sync or async
}

export function ChatFooter() {
  return (
    <RefractionComposer
      placeholder="Send a message…"
      triggers={[mentions]}
      maxLength={2000}
      onSubmit={({ plainText, tokens, attachments }) => {
        sendMessage({ plainText, tokens, attachments })
      }}
    />
  )
}

Props

PropTypeDefaultDescription
valuestring--Controlled value. When set, the prop wins over internal edits.
defaultValuestring--Initial value for uncontrolled usage.
onChange(value: string) => void--Fired with the new text after every user-driven change, including the optimistic clear on submit.
onSubmit(submission: ComposerSubmission) => void--Receives `{ plainText, tokens, attachments, replyToMessageId?, editingMessageId? }`. The field clears optimistically; transport is yours.
placeholderstring--Placeholder text (presentational only — the accessible name is independent).
disabledPlaceholderstring--Distinct placeholder shown while disabled. Falls back to `placeholder`.
minLinesnumber1Rows shown when empty.
maxLinesnumber6Auto-grow ceiling; the field scrolls internally beyond it.
maxLengthnumber--Grapheme-cluster budget (never splits emoji/combining clusters). A counter appears within 20% of the limit. Fixed at mount.
maxAttachmentsnumber--Attachment count limit; overflow surfaces via `onAttachmentRejected`.
disabledbooleanfalseDisables the whole composer (distinct from readOnly).
readOnlybooleanfalseValue is selectable/copyable but not editable.
busybooleanfalseStreaming state: swaps the default send button for a stop button and blocks submit.
onStop() => void--Fired by the built-in stop button while `busy`.
surface'outlined' | 'filled''outlined'Resting fill of the pill. `filled` uses a calm muted fill distinct from the page plus a hairline; both keep a tasteful focus-visible ring.
accessoryPanelReact.ReactNode--Host content (e.g. an emoji picker) docked INLINE below the field — never a floating overlay. Adds a toggle button to the action row; open/close is animated + reduced-motion aware.
accessoryPanelOpen / defaultAccessoryPanelOpen / onAccessoryPanelToggleboolean / boolean / (open) => void--Controlled or uncontrolled open state for the inline expression panel.
autoFocusboolean--Focus the textarea on mount.
dir'ltr' | 'rtl' | 'auto'--Text direction passthrough; the layout uses logical properties throughout.
triggersComposerTrigger[]--Inline trigger configs (mention '@', slash '/', emoji ':', tag '#', custom multi-char). Each provides a `resolve(query)` for the suggestion menu. Fixed at mount.
submitOnEnterbooleantrueWhether plain Enter submits (Shift+Enter is always a newline). Defaults to true; a coarse pointer flips the default to false after mount. An explicit prop wins.
stringsPartial<RefractionComposerStrings>--Overridable text bundle (labels, counter, notices) with English defaults.
leadingReact.ReactNode--Rendered at the start of the action row (e.g. an attach button).
trailingReact.ReactNode--Rendered at the end of the action row, before the primary action.
primaryAction({ hasText, canSend, busy }) => React.ReactNode--Replaces the built-in send ⇄ stop primary action.
renderSuggestion(candidate, { active, index }) => React.ReactNode--Custom row content for suggestion menu items.
initialAttachmentsComposerAttachmentDraft[]--Attachments staged at creation (SSR-deterministic).
draftStore / draftKeyComposerDraftStore / string--Injected draft persistence: debounced autosave, restore on mount, cleared on send.
validator(plainText, tokens) => { isValid, reason? }--Blocks submit and surfaces `reason` as the error banner; the draft is kept.
replyToMessageIdstring--Threaded onto every submission.
onEditLastRequested() => void--ArrowUp on an empty field (desktop edit-last affordance).
onEditCancel() => void--Escape pressed while in edit mode (after the draft is restored).
onTypingActivity() => void--Throttled typing signal (leading edge, max one per 3s).
onAttachmentAdd / onAttachmentRejected(attachment) => void / (event) => void--Attachment pipeline callbacks (paste, drop, or `apiRef` additions).
onEvent(event: ComposerEvent) => void--Raw core notice channel: 'paste-trimmed', 'insert-rejected', 'edit-rejected', 'attachment-rejected', 'typing'.
apiRefReact.Ref<ComposerAPI>--Receives the composer core for imperative use — `beginEdit`, `addAttachment`, `setError`, `insertTextAtCursor`, ….
classNamestring--Additional CSS classes for the outer landmark.