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.
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)
Installation
pnpm add @refraction-ui/reactUsage
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
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | -- | Controlled value. When set, the prop wins over internal edits. |
defaultValue | string | -- | 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. |
placeholder | string | -- | Placeholder text (presentational only — the accessible name is independent). |
disabledPlaceholder | string | -- | Distinct placeholder shown while disabled. Falls back to `placeholder`. |
minLines | number | 1 | Rows shown when empty. |
maxLines | number | 6 | Auto-grow ceiling; the field scrolls internally beyond it. |
maxLength | number | -- | Grapheme-cluster budget (never splits emoji/combining clusters). A counter appears within 20% of the limit. Fixed at mount. |
maxAttachments | number | -- | Attachment count limit; overflow surfaces via `onAttachmentRejected`. |
disabled | boolean | false | Disables the whole composer (distinct from readOnly). |
readOnly | boolean | false | Value is selectable/copyable but not editable. |
busy | boolean | false | Streaming 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. |
accessoryPanel | React.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 / onAccessoryPanelToggle | boolean / boolean / (open) => void | -- | Controlled or uncontrolled open state for the inline expression panel. |
autoFocus | boolean | -- | Focus the textarea on mount. |
dir | 'ltr' | 'rtl' | 'auto' | -- | Text direction passthrough; the layout uses logical properties throughout. |
triggers | ComposerTrigger[] | -- | Inline trigger configs (mention '@', slash '/', emoji ':', tag '#', custom multi-char). Each provides a `resolve(query)` for the suggestion menu. Fixed at mount. |
submitOnEnter | boolean | true | Whether 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. |
strings | Partial<RefractionComposerStrings> | -- | Overridable text bundle (labels, counter, notices) with English defaults. |
leading | React.ReactNode | -- | Rendered at the start of the action row (e.g. an attach button). |
trailing | React.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. |
initialAttachments | ComposerAttachmentDraft[] | -- | Attachments staged at creation (SSR-deterministic). |
draftStore / draftKey | ComposerDraftStore / 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. |
replyToMessageId | string | -- | 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'. |
apiRef | React.Ref<ComposerAPI> | -- | Receives the composer core for imperative use — `beginEdit`, `addAttachment`, `setError`, `insertTextAtCursor`, …. |
className | string | -- | Additional CSS classes for the outer landmark. |