XI Conversation

Star on GitHubFeedback
XI ConversationNative HTMLot-msg

Message

No balloons. Of the speech bubble only its tail is left, a leaning hairline. What they wrote is roman; what you wrote sits on the other side, in italic.

Report an issue with Message

Example

Ada Lindqvist

The Halden marks are ready for review. I kept the arc from the first round.

You

Looking now. Can we see it at stamp size too?

Read

Ada

At stamp size the anchor fills in. The flag holds.

You

Then let the anchor go.

Ada Lindqvist

A harbour is where the arc comes to rest.

You

Put that on the cover.

Tail

Ready for review.

Tail, yours

Looking now.

Reversed

Published.

Marked

Read this first.

Typing

Ada is writing

Typing, with the face

Ada is writing

Typing, script
Ada

Ada is writing

Sent
Sent
Read
Read
Script
Ada

The flag holds.

Script, yours
You

Let it go.

Quote
Ada

Where the arc rests.

Quote, yours
You

On the cover.

Install

Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base, avatar, badge, and spinner along.

npx shadcn@latest add https://thedirectors.agency/ui/r/message.json

Usage

This example also uses Button.

npx shadcn@latest add https://thedirectors.agency/ui/r/button.json
message-example.tsx
"use client"import * as React from "react"import { Button } from "@/components/ui/button"import {Message,MessageAvatar,MessageBody,MessageBubble,MessageFooter,MessageHeader,MessageReaction,MessageReactions,MessageStatus,MessageTyping,} from "@/components/ui/message"/** Ask, and Ada writes back: the periods show only while she's writing, then her words write in. */function Reply() {const [said, setSaid] = React.useState<"asked" | "writing" | "sent">("asked")const timer = React.useRef<number>(undefined)React.useEffect(() => () => clearTimeout(timer.current), [])const ask = () => {setSaid("writing")clearTimeout(timer.current)timer.current = window.setTimeout(() => setSaid("sent"), 2600)}return (<>{said === "sent" ? (<Message arriving><MessageAvatar alt="Ada Lindqvist" /><MessageHeader name="Ada Lindqvist" time="09:46" dateTime="2026-09-30T09:46" /><MessageBody><MessageBubble>Here it is, at stamp size.</MessageBubble></MessageBody></Message>) : null}<Message><MessageAvatar alt="Ada Lindqvist" /><MessageBody><MessageTyping writing={said === "writing"} label="Ada is writing" /></MessageBody></Message><Button variant="quiet" className="justify-self-start" disabled={said === "writing"} onClick={ask}>{said === "sent" ? "Ask again" : "Ask Ada"}</Button></>)}export default function Example() {return (<div className="grid gap-(--ot-space-6)"><Message><MessageAvatar alt="Ada Lindqvist" /><MessageHeader name="Ada Lindqvist" time="09:40" dateTime="2026-09-30T09:40" /><MessageBody><MessageBubble>The Halden marks are ready for review. I kept the arc from the first round.</MessageBubble></MessageBody><MessageFooter><MessageReactions><MessageReaction defaultPressed defaultCount={2}>Agreed</MessageReaction><MessageReaction defaultCount={1}>Lovely</MessageReaction></MessageReactions></MessageFooter></Message><Message from="you"><MessageAvatar alt="You" /><MessageHeader name="You" time="09:44" dateTime="2026-09-30T09:44" /><MessageBody><MessageBubble align="end">Looking now. Can we see it at stamp size too?</MessageBubble></MessageBody><MessageFooter><MessageStatus read /></MessageFooter></Message><Reply /><div className="grid gap-(--ot-space-4) pt-(--ot-space-6)"><Message variant="script"><MessageHeader name="Ada" time="09:46" dateTime="2026-09-30T09:46" /><MessageBody><MessageBubble>At stamp size the anchor fills in. The flag holds.</MessageBubble></MessageBody></Message><Message variant="script" from="you"><MessageHeader name="You" time="09:47" dateTime="2026-09-30T09:47" /><MessageBody><MessageBubble align="end">Then let the anchor go.</MessageBubble></MessageBody></Message></div><div className="grid gap-(--ot-space-6) pt-(--ot-space-6)"><Message variant="quote"><MessageHeader name="Ada Lindqvist" time="10:02" dateTime="2026-09-30T10:02" /><MessageBody><MessageBubble>A harbour is where the arc comes to rest.</MessageBubble></MessageBody></Message><Message variant="quote" from="you"><MessageHeader name="You" time="10:05" dateTime="2026-09-30T10:05" /><MessageBody><MessageBubble align="end">Put that on the cover.</MessageBubble></MessageBody></Message></div></div>)}

Contract

  • Underneath: native.
  • Anatomy: <article class="ot-msg" data-from="them | you" data-variant="script | quote"> holding an ot-avatar, .ot-msg-head, .ot-msg-body (optionally a <p class="ot-bubble" data-variant="ink | mark">), and .ot-msg-foot with .ot-msg-status (data-read). Reactions are <span class="ot-reactions"> of <button class="ot-tag" aria-pressed> with .ot-reaction-count.
  • No balloons. Of the speech bubble, only its tail is left: a leaning hairline. What you wrote sits on the other side, in italic. Sent is a ring; read, it fills to a dot. New messages write in from the inline start (ot-message-write, from the right on a right-to-left page). A reaction you add turns italic and its count rolls. A bubble can be ink (reversed out of a block, the tail beneath it) or mark (a highlighter behind the words; selected inside it, the words reverse to ink, as in a mark).
  • Script, after the printed play text. No face and no tail. The name stands in the margin in spaced capitals with a full stop, the time under it in parentheses like a stage direction, and the words sit in one column for everyone, on the name's baseline. Nobody is placed on a side: who is speaking shows only in the type, theirs roman and yours italic, which is the house rule with nothing else holding it up. Narrow (below 30rem) the name runs in above the words. Right to left, the margin is on the right.
  • Quote, after the typographer's own speech balloon, the quotation mark, set at the scale of "It has to be design.". No face and no tail: a large pencil italic mark (::before, --ot-ff, aria-hidden by its empty alt text) stands where the face was. Theirs opens (“) at the top of the start; yours closes (”) at the foot of the end, so an exchange is framed from both sides. Arriving, the mark lands with spiccato before the words write in. Right to left the mark is mirrored. Forced colours: the mark is GrayText.
  • Typing (MessageTyping, <p class="ot-bubble ot-typing" role="status" data-writing>): the other side is writing. Of a message only the tail and three periods are there, where the words will be, sketched in pencil until the message inks them in; the periods breathe in turn, as a busy button's do (ot-dots). It shows only while writing is true, which the caller drives from the real typing state, so it is not motion that plays by itself. Not writing, it stays mounted as an empty, silent status (.ot-sr) that takes no room, so its label ("Ada is writing") is heard when writing starts. Alone it stands after the last message; given a face (a Message holding a MessageAvatar and a MessageBody with the typing in place of a bubble) that message folds away with it while nobody writes. In the script variant the periods stand in the column after the name, a line about to be spoken. Reduced motion: the periods trail off and dim, as the spinner's do.

Props

Props of Message
PropTypeDefaultWhat it does
Message from"them" | "you""them"Whose it is. Yours sits at the end of the row, in italic, with the time before the name.
Message variant"default" | "script" | "quote""default"script: a play text. No face and no tail; the name stands in capitals in the margin with the time after it in parentheses, the words in one column for everyone, and only the type says who is speaking (theirs roman, yours italic). Narrow, the name runs in above the words. quote: no face and no tail; a large italic quotation mark opens theirs at the top of the start, and closes yours at the foot of the end.
Message arrivingbooleanfalseWrites the body in from the left when it mounts.
MessageAvatarAvatar propsThe face beside the message. It is a direct child of Message, aria-hidden since the header names the person.
MessageHeader name, time, dateTimeReactNode, ReactNode, stringThe name in weight, the time in pencil. dateTime is the machine-readable time.
MessageBodydiv propsHolds one or more bubbles or plain paragraphs.
MessageBubble align"start" | "end""start"Which side the tail leans to. Use end for what you wrote.
MessageBubble variant"default" | "ink" | "mark""default"Ink is reversed type in a block, the tail beneath. Mark is a highlighter behind the words.
MessageTyping writingbooleanThe other side is writing. True shows the tail and three periods in pencil where their words will be, breathing in turn as a busy button's dots do; false leaves an empty, silent status that takes no room. Keep it mounted and drive writing from the real typing state, so a screen reader hears it start. Put it after the last message on its own, or give it their face: a Message holding only a MessageAvatar and a MessageBody with the typing in place of a bubble, which folds away with it while nobody writes. When their message arrives, set writing to false and add the new Message, arriving, before it.
MessageTyping label, alignstring, "start" | "end""Writing", "start"label is what a screen reader hears while it shows ("Ada is writing", "Thinking"). align leans the tail as a bubble's does.
MessageFooterfooter propsHolds the status or the reactions.
MessageStatus readbooleanfalseSent is a ring; read, it fills to a dot. Children replace the word.
MessageReactionsspan propsA group of reactions. Its aria-label defaults to Reactions.
MessageReaction defaultPressed, defaultCountboolean, numberfalse, 0A pressable tag with aria-pressed and a count. Pressed, its label turns italic and the count rolls.
MessageReaction onPressedChange(pressed, count) => voidCalled after each press with the new state.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
Message"It has to be design."Theirs roman, yours italic; only the tail
Script messageThe printed play textThe name in the margin; only the type says who speaks
Quote messageThe quotation mark as the printed balloon; "It has to be design." scaleA large mark opens theirs and closes yours
TypingThe ellipsis; the busy button's periodsOnly the tail and three periods where their words will be, in pencil

How it moves

How it moves
ComponentArticulationWhat moves
MessageWrittenNew messages write in; the status ring fills to a dot
ReactionsRollYours turns italic; the count rolls
Quote messageSpiccatoArriving, the quotation mark lands, then the words write in
TypingBreathOnly while they write: the periods lift as they brighten, in turn