A chat message box: text that grows from one line as the person writes, a send button, and room for the rest of a composer, in one frame with one focus outline. Enter sends and Shift+Enter starts a new line, or sendOn="mod-enter" for Ctrl+Enter, and Enter never sends mid-word in an input method. A press anywhere around the text, out to the frame's edge, puts the caret where it lands, as in a text field. actions holds the buttons before Send (emoji, attach, formatting), toolbar a formatting bar above the text while it is on, attachments the files waiting to go, and quote the message a reply quotes, which Escape in the empty box removes. sendDisabledReason turns sending off and says why, while the person can still write, and highlights marks words in the text, as TextArea's do.
On this site
This site's own pages use Composer too. Open one with X-ray pointing it out: The Nautilus's story
Install
Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):
npx shadcn@latest init --base base --preset nova --yes --no-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):
npx shadcn@latest add ourostack/ouro-ui/composer#v1.3.10Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/composerIts tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):
npx shadcn@latest add ourostack/ouro-ui/composer#v1.3.10 ourostack/ouro-ui/composer-tests#v1.3.10import { Composer } from "@/components/ouro/composer/composer";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<Composer label="Message Mia" placeholder="Write a message" onSend={(text) => send(text)} />
<Composer
label="Reply"
value={text}
onValueChange={setText}
onSend={(text) => { send(text); setText(""); }}
highlights={[{ start: 0, end: 22, label: "Suggested by your assistant" }]}
description="Your assistant's suggestions are underlined."
actions={<Button variant="plain" aria-label="Attach a file" onClick={attach}><PaperclipIcon /></Button>}
attachments={files.length > 0 && files.map((file) => <Attachment key={file.name} name={file.name} onRemove={() => remove(file)} />)}
sendDisabledReason={offline ? "You're offline. Sending resumes when you reconnect." : undefined}
/>
<Composer
label="Reply to Ana"
quote={quoted && { author: quoted.author, excerpt: quoted.text, onRemove: () => setQuoted(undefined), onActivate: () => scrollTo(quoted.id) }}
onSend={(text) => { send(text, quoted?.id); setQuoted(undefined); }}
/>
<Composer label="Reply" defaultExpanded={false} toolbar={<Toolbar label="Formatting" items={formatting} />} onSend={(text) => send(text)} />Usage
The text is plain text, so emoji from an image catalogue go in as :shortcode: tokens: an Autocomplete trigger with catalogue inserts them as the person types : and a few letters, and an EmojiPicker with catalogue in actions gives its selection.token to put at the caret. EmojiText draws the tokens as the catalogue's images once the message is sent. Given the catalogue too, Composer puts an emoji typed from the system's emoji panel or pasted in as its token as well.
Use instead when
- Use
TextAreainstead for a long text that is saved rather than sent, andMessagefor a message once it is sent.
Props
Type: ComposerProps.
actions- Type:
ReactNode - Buttons before the send button: emoji, attach a file, show formatting. Ouro
Buttons withvariant="plain"and anaria-label. attachments- Type:
ReactNode - Files waiting to go with the message, shown in a row inside the box under the text:
Attachmentitems withonRemove. Pass it only when there are some; with it, the message can be sent without text. attachmentsLabel- Type:
string - Default:
"Attachments" - A name for the attachments row, for screen readers.
catalogue- Type:
EmojiCatalogue - An image emoji catalogue. With it, an emoji typed into the box (from the system's emoji panel, say) or pasted goes in as its
:shortcode:token, the tokenAutocompleteandEmojiPickerinsert, so the text holds the catalogue's emoji only as tokens, whichEmojiTextdraws as its images. That includes an emoji joined from others and one with a skin tone, which become the catalogue's emoji or tone for it; an emoji the catalogue does not list stays as it is. The caret lands after what went in, and one undo takes it out. className- Type:
string - Layout only: margins, width, grid placement (
mt-2). Appearance classes warn in development. defaultExpanded- Type:
boolean - Whether it starts expanded, for an uncontrolled composer that the expand button opens;
falseto start small. defaultValue- Type:
string - The starting text, for an uncontrolled composer, which clears itself after a send.
description- Type:
ReactNode - A quiet line under the box, read as the text's description: what the marks mean, a character count.
disabled- Type:
boolean - Disables the whole composer: the box and the send button.
editorRef- Type:
Ref<ComposerEditor> - A handle that formats the text and inserts into it, for a formatting bar, an emoji button or a link dialog. Every change it makes, a key's format and a
pasteTextpaste go through the browser's own editing, as typing does, so Undo (⌘Z) and Redo (⌘⇧Z) step through them. WebKit (Safari) can join changes made in a row into one step, as it joins typing. expanded- Type:
boolean - The bigger box for a long message, as a chat app's format button opens: taller (
expandedRows, growing toexpandedMaxRows), with thetoolbar, and Enter starts a new line while Ctrl+Enter (Command+Enter on a Mac) or the send button sends, whateversendOnsays. It is the same text either way, and the caret stays where it was. ⌘⇧X (Ctrl+Shift+X off Apple platforms) expands and collapses it, as in Teams, and the expand button's tooltip shows the key. Escape collapses it, and that Escape goes no further. The box grows and shrinks in height over a moment, and at once with reduced motion. Controlled; pair it withonExpandedChange, or usedefaultExpanded. expandedMaxRows- Type:
number - Default:
20 - Lines the expanded box grows to before it scrolls.
expandedRows- Type:
number - Default:
6 - Lines the expanded box starts with.
expandLabel- Type:
string - Default:
"Expand" - The expand button's name, which its tooltip shows too.
formatting- Type:
boolean - Teams' formatting keys in the box: ⌘B bold, ⌘I italic, ⌘U underline, ⌘⌥X strikethrough and ⌘⇧C code (Ctrl for ⌘, Alt for ⌥, off Apple platforms), each as a
ComposerFormat.composerShortcuts(fromcomposer-styles) has the keys, for your toolbar's tooltips. highlights- Type:
readonly TextAreaHighlight[] - Ranges of the text to mark in place, as TextArea's
highlights: words an assistant suggested, spelling notes. label(required)- Type:
string - What the message box is for, read as its name: "Message Mia", "Reply in thread". Required: the box shows
placeholder, which is not a label. maxRows- Type:
number - Default:
10 - Lines it grows to before it scrolls.
name- Type:
string - The form field name the text submits under.
onExpandedChange- Type:
((expanded: boolean) => void) - Called when the person expands or collapses it: the expand button, or Escape. With it, or with
defaultExpanded, the expand button shows first among the actions. onHighlightsChange- Type:
((highlights: TextAreaHighlight[]) => void) - Called with the highlights moved to fit the text after the person edits it, as TextArea's.
onSend(required)- Type:
(text: string, event: ComposerSendEvent) => void - Called with the text when the person sends: Enter in the box (see
sendOn) or the send button. The event says which, andevent.isTrustedthat a person did it. It is not called while the text is blank (unless there areattachments), whilesending, or withsendDisabledReasonset. Withoutvalue, the box clears after it. onValueChange- Type:
((value: string) => void) - Called with the new text when it changes.
pasteText- Type:
((data: DataTransfer) => string | undefined) - Turns a paste into the box's text, such as formatted text into Markdown: return the text, or
undefinedfor the browser's own paste. placeholder- Type:
string - An example of what to write, shown on the first line while the box is empty.
quote- Type:
ComposerQuote - The message this one replies to, shown inside the box above the text until it is sent or removed: a bar, the author, a line of what they wrote and a remove button. Screen readers hear it as part of the text's description. Escape in the empty box removes it, as the remove button does, and that Escape goes no further, so a dialog around the composer stays open. The quote fades in (opacity only, and not at all with reduced motion) and takes no focus: focus stays in the box. It stays after a send, so clear it in
onSend. ref- Type:
Ref<HTMLTextAreaElement> - The
<textarea>. rows- Type:
number - Default:
1 - Lines the box starts with; it grows as the person writes.
sendDisabledReason- Type:
string - Sending is off, and why: "Sending is off while you review the draft". The person can still write; the send button is disabled, the reason shows under the box and is read with it, and pressing Enter says it again.
sending- Type:
boolean - The message is on its way: the send button shows a spinner, and nothing more sends until it is done.
sendingLabel- Type:
string - Default:
"Sending" - What screen readers hear when
sendingturns on. sendLabel- Type:
string - Default:
"Send" - The send button's name, which its tooltip shows too.
sendOn- Type:
"enter" | "mod-enter" - Default:
"enter" - Which key sends.
enter: Enter sends and Shift+Enter starts a new line, as chat apps do.mod-enter: Ctrl+Enter (Command+Enter on a Mac) sends and Enter starts a new line, for long messages. Enter never sends while an input method is composing a word or while a suggestion list (Autocomplete) is open, and anexpandedcomposer sends on Ctrl+Enter. toolbar- Type:
ReactNode - Tools shown above the text, inside the box: a formatting bar. In a composer that expands, it shows only while expanded; otherwise pass it only while it should show.
value- Type:
string - The text, for a controlled composer; pair it with
onValueChange, and clear it yourself once the message is sent.
Native attributes
Also accepts the attributes in React's HTMLAttributes and TextareaHTMLAttributes (id, aria-*, event handlers), passed to the rendered element, except those named on this page.
Don't use
These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.
enterToSend: Not a prop. Ouro UI usessendOn:<Composer sendOn="mod-enter">.expandable: Not a prop. A composer that expands takesdefaultExpandedoronExpandedChange:<Composer defaultExpanded={false}>.loading: Not a prop. Ouro UI usessending:<Composer sending>.onSubmit: Not a prop. Sending callsonSend:<Composer onSend={(text) => send(text)}>.replyTo: Not a prop. The message a reply quotes isquote:<Composer quote={{ author: "Ana", excerpt: "Lunch?", onRemove }}>.sendDisabled: Not a prop. Ouro UI usessendDisabledReason:<Composer sendDisabledReason="Sending is off">.submitOnEnter: Not a prop. Ouro UI usessendOn:<Composer sendOn="mod-enter">.
className is for layout only
Pass margins, width and grid placement (mt-4, w-full, col-span-2).
Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.
Alignment classes (text-center) warn too: set alignment on a parent element.
Types
ComposerEditor
What an app's formatting bar, emoji button or link dialog changes the text with: editorRef.
interface ComposerEditor {
/** Applies a format to the selection, or takes it away when the selection has it already. */
format: (format: ComposerFormat) => void;
/** Puts text in place of the selection, or at the caret: an emoji, a link. */
insert: (text: string) => void;
}ComposerFormat
A format Composer applies to the selected text, as light Markdown: bold (**), italic (_), underline (++), strike (~~) and code (a backtick) wrap it in marks, bullets, numbers and quote mark its lines, and codeBlock fences them.
type ComposerFormat = "bold" | "italic" | "underline" | "strike" | "code" | "bullets" | "numbers" | "quote" | "codeBlock";ComposerQuote
The message a reply quotes, for Composer's quote: who wrote it and a line of what they wrote.
interface ComposerQuote {
/** Who wrote the quoted message: "Ana Lind", or "You". */
author: string;
/**
* What they wrote, or its start. One line shows, cut with an ellipsis.
*/
excerpt: string;
/**
* What the quote shows in place of `author`, when plain text cannot draw it: a name with its
* emoji as pictures (`<EmojiText text={name} catalogue={catalogue} />`). `author` stays the
* name in the remove button's and screen readers' words.
*/
authorContent?: ReactNode;
/**
* What the quote shows in place of `excerpt`, when plain text cannot draw it: the line with its
* emoji as pictures (`<EmojiText text={line} catalogue={catalogue} />`), on one line, cut with an
* ellipsis. `excerpt` stays what screen readers hear.
*/
excerptContent?: ReactNode;
/** The author's picture, before the name: an `Avatar` element, `<Avatar name="Ana" src={url} alt="" size="xs" />`. */
avatar?: ReactElement | undefined;
/** Removes the quote: called by the remove button, and by Escape while the box is empty. Set `quote` to `undefined` in it. */
onRemove: () => void;
/** Makes the quote a button that shows the quoted message, as by scrolling to it. */
onActivate?: (() => void) | undefined;
/** What screen readers hear in place of the author's name, before the excerpt. @default `Quoting ${author}` */
label?: string | undefined;
/** The remove button's name, which its tooltip shows too. @default `Remove quote from ${author}` */
removeLabel?: string | undefined;
}ComposerSendEvent
What sent the message: the Enter key in the box, or a press of the send button.
type ComposerSendEvent = KeyboardEvent<HTMLTextAreaElement> | MouseEvent<HTMLButtonElement>;EmojiCatalogue
An image-based emoji set, by category: what EmojiPicker, ReactionBar, Autocomplete and EmojiText draw instead of the platform's emoji font when an app passes it. Put an organization's custom emoji in a category of their own, labelled by the app. The image addresses are used as they are, so an app that loads images its own way (a content security policy, a custom scheme, signed or blob: URLs) puts the addresses it can load in the catalogue when it builds it. Ouro indexes a catalogue's emoji once, to find them in text. Build the catalogue once and pass that object, or give each copy the same id: a catalogue of new emoji objects on every render is indexed again each time, and warns in development. Adding or removing emoji is seen in a new catalogue object, even in the same array; to edit an emoji's shortcodes or text, pass a new emoji object, or a new id.
interface EmojiCatalogue {
/**
* Names this set of emoji, for an app that makes a new catalogue object, emoji and all, each
* time it draws: catalogues with the same id share one index of their emoji, so change it when
* the emoji change (`"contoso-2026-11"`), an emoji edited in place included. Without one, a
* new catalogue object shares the index when it holds the same emoji objects in the same order,
* and a catalogue of new emoji objects builds its own.
*/
id?: string | undefined;
/** The categories, in the order they show. An emoji in more than one counts once, by its id. */
categories: readonly EmojiCatalogueCategory[];
}EmojiCatalogueCategory
A category of a catalogue: its name, the icon its button shows, and its emoji in order.
interface EmojiCatalogueCategory {
/** Identifies the category, unique in the catalogue: `"people"`, `"custom"`. */
id: string;
/** Its name, shown as its heading and read for its button: `"People"`, or the organization's name for its custom emoji. */
label: string;
/** What its button in a picker's category bar shows: an icon element. Defaults to its first emoji's image. */
icon?: ReactNode;
/** Its emoji, in the order they show. */
items: readonly EmojiItem[];
}EmojiFilmstrip
An animation drawn as a filmstrip: square frames stacked top to bottom in one image, so the frames are the image's height over its width.
interface EmojiFilmstrip {
/** The filmstrip image's address. */
src: string;
/** Frames a second. */
fps: number;
}EmojiItem
One emoji in an image catalogue: its id, its name and the image that draws it. An app maps its own emoji set to this, whether a standard set or an organization's custom emoji.
interface EmojiItem {
/** Identifies the emoji, unique in the catalogue: `"party"`. Reactions, recents and `:id:` tokens use it. */
id: string;
/** Its name, which is the image's `alt`, what screen readers hear and what the search matches first: `"Party popper"`. */
name: string;
/**
* The still image's address. It shows whenever the emoji is not playing, and always with reduced motion.
*/
src: string;
/** Sizes of the still image for sharper screens, as `<img srcset>`: `"party-2x.png 2x"`. */
srcSet?: string | undefined;
/**
* An animated image (GIF, animated WebP or PNG), which plays while the pointer is on the emoji
* or on the button around it, or that button has focus. With reduced motion it never plays.
*/
animatedSrc?: string | undefined;
/**
* The animation as a filmstrip, square frames stacked top to bottom in one image, for an
* emoji without `animatedSrc`: while the emoji plays, it steps through the frames at `fps`;
* otherwise, and always with reduced motion, `src` shows.
*/
filmstrip?: EmojiFilmstrip | undefined;
/** The still image's width in pixels, given to the `<img>` so the page keeps its shape while it loads. It shows at the text's size. */
width?: number | undefined;
/** The still image's height in pixels, as `width`. */
height?: number | undefined;
/** Short names typed between colons, without them: `["party", "tada"]`. The first makes the emoji's token, `:party:`. */
shortcodes?: readonly string[] | undefined;
/** More words the search matches: `["celebrate", "hooray"]`. */
keywords?: readonly string[] | undefined;
/**
* The emoji as plain text, such as its Unicode character (`"🎉"`), for places an image cannot
* go (a copied message, a notification). `EmojiText` finds it in messages and draws the image;
* Ouro never draws it as text.
*/
text?: string | undefined;
/** It is the organization's or app's own emoji rather than one of the standard set. Ouro draws it the same; apps can label or treat it apart. */
custom?: boolean | undefined;
/**
* The emoji in Unicode's five skin tones, light to dark; leave it out for an emoji without tones.
*/
skinTones?: readonly EmojiItemTone[] | undefined;
}EmojiItemTone
An emoji's image in one skin tone: skinTones on an EmojiItem.
interface EmojiItemTone {
/** Identifies the emoji in this tone, unique in the catalogue: `"thumbs-up-tone3"`. Reactions and tokens use it. */
id: string;
/** The still image's address. */
src: string;
/** Sizes of the still image for sharper screens, as `<img srcset>`: `"wave-2x.png 2x"`. */
srcSet?: string | undefined;
/** An animated image (GIF, animated WebP or PNG), shown while the emoji plays; the still one shows otherwise. */
animatedSrc?: string | undefined;
/** The animation as a filmstrip of square frames, played while the emoji plays, for a tone without `animatedSrc`. */
filmstrip?: EmojiFilmstrip | undefined;
/**
* The emoji in this tone as text (`"👍🏽"`), which `EmojiText` finds in messages; Ouro never draws it as text.
*/
text?: string | undefined;
}TextAreaHighlight
A range of a TextArea's text that it marks: words an assistant suggested in the person's draft, a possible spelling mistake. start and end are JavaScript string indexes, and end is exclusive, so a mark covers text.slice(start, end).
interface TextAreaHighlight {
/** Where the mark starts: the index of its first character in the text. */
start: number;
/** Where it ends: the index just after its last character. */
end: number;
/**
* What the mark means, which screen readers hear with the words it marks: "Suggested by
* your assistant", "Possible spelling mistake". Required, so no mark is shown only to sighted people.
*/
label: string;
/**
* The mark's colour, a theme tone. `destructive` and `warning` draw a wavy underline, as
* spelling and grammar checkers do; the others a dotted underline on a faint tint.
* @default "primary"
*/
tone?: "primary" | "secondary" | "destructive" | "success" | "warning" | (string & { 'danger is "destructive"; accent is "primary"; neutral is "secondary"': never }) | undefined;
/** Your own id for the mark. An edit inside a mark splits it, and both pieces keep its id. */
id?: string | undefined;
}