ComponentsAgent patterns

Message

Lays out one chat message: an avatar beside a column of bubbles, cards, and actions, with a small name line above and a status line below. Bubbles come in seven fills and can carry a pill of reactions.

Incoming and outgoing

align="start" for others, align="end" for you. Bubbles follow the row, and consecutive bubbles from one sender share one MessageContent, 10px apart. The avatar sits beside the last bubble, above the footer.

AM
Alex Meadow · 10:30
Can you help plan the spring planting for the Meadow project?
Garden assistant
Yes. Let’s start with the planting list.
Tomatoes and basil go in the south bed. Lettuce goes in the shade.

Bubble variants

Seven fills in one size. Each is shown on the side it is used: prompts and your messages on the end, notes and answers on the start.

variant="default"Your outgoing message
Send the investor update to Priya once it's approved.
variant="secondary"Prompts sent to the agent
Create a Customers table with Name, Email, Company, Phone, and Status.
variant="muted"Quiet system notes
Plan only · no setup actions were taken.
variant="tinted"Highlighted suggestions, follows the brand
Tip: mention a table with @ to answer from its rows.
variant="outline"Quoted or forwarded text
Forwarded from #engineering: the deploy window moves to 4pm.
variant="ghost"Agent answers, full width without a fill
Here are the changes for your review. Name is required. Email, Company, and Phone are optional.
variant="destructive"A message that failed to send
Couldn't send. Your message is saved as a draft.

Reactions

Reactions sit on the top or bottom edge, 12px in from the start or end corner. Buttons inside become toggles. The ring matches this card's background through --sprout-message-reactions-ring.

side="bottom" align="start"
Hello from Alex. The beds are ready for planting.
🌱 2
side="top" align="end"
The garden plan is ready.
✓ 1
side="top" align="start"
Weekly digest sent to 14 people.
👀 4
side="bottom" align="end"
Approved. Ship the Customers table.
🎉 3
Reaction toggles
Status can be Prospect, Active, or Inactive. Should I add Churned?

Links and buttons

render turns the bubble into a link or button. It keeps the fill, darkens on hover, and shows a focus ring for the keyboard.

Open result · Investor update draft

Conversation

Prompts are secondary bubbles on the end. Answers are ghost bubbles on the start, with cards and actions beside them. Consecutive prompts share one MessageContent. Enter sends, Shift+Enter adds a line.

  1. Create a Customers table with Name, Email, Company, Phone, and Status.

  2. TulipFarm said

    Here are the changes for your review. Name is required. Email, Company, and Phone are optional. Status can be Prospect, Active, or Inactive.

    Customers

    1 new table · No rows added

Rich messages

Bubbles hold any content: a skill chip above, file references inline, multi-line text that keeps its breaks, and long links that wrap instead of overflowing.

/weekly-report

Summarise Q3 pipeline.xlsx for the team. Keep it under 200 words. https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=0

TulipFarm said

Q3 pipeline closed at $1.2M across 38 deals.

  • Enterprise grew 22%, led by Northwind and Contoso.
  • Two deals slipped to Q4; see stage = "Negotiation".

Want me to post this to #sales?

Plan only · no setup actions

Saved onboarding chat

A header names each speaker. Plain content without a slot keeps its own width, so the user's text can span the row.

TulipFarm guide

Hi! What should we call your workspace?

You

Meadow Growers

TulipFarm guide

Nice. What will your team use TulipFarm for first?

You

Tracking our wholesale orders and keeping the greenhouse schedule in one place. We also want reminders before deliveries.

TulipFarm guide

You are signed in. Let’s finish setting up Meadow Growers.

Ghost bubbles

When content holds a direct child with data-variant="ghost", the header and footer drop their 12px inset to line up with the text.

TulipFarm · Ghost
Your three greenhouse beds are booked for Tuesday. I moved the tulip delivery to Wednesday morning.
TulipFarm · Secondary
Your three greenhouse beds are booked for Tuesday.

Reply status

The footer carries the reply state: queued, working with aria-busy, or failed with a reason and buttons.

Summarise this week’s wholesale orders by customer.

Long content

Bubbles wrap long words and links instead of widening the row.

You
Use the export at https://files.example.com/meadow-growers/exports/wholesale-orders-2026-10-12-final-reviewed.csv for the October report.
TF
TulipFarm · Wholesale orders assistant for Meadow Growers greenhouse operations
Done. wholesale-orders-2026-10-12-final-reviewed.csv has 128 rows across 14 customers.

Scrollable history

Put messages in your own scroll region. Default and secondary bubbles alternate, and adding one keeps the newest in view.

6 messages
Alex Meadow
Bed 1 gets six hours of sun.
You
Moved bed 2 to the shade list.
Alex Meadow
Bed 3 gets six hours of sun.
You
Moved bed 4 to the shade list.
Alex Meadow
Bed 5 gets six hours of sun.
You
Moved bed 6 to the shade list.

Usage

Message usageTSX
import {
  Message,
  MessageAvatar,
  MessageBubble,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@tulipfarm/sprout/message";

<MessageGroup>
  <Message align="start">
    <MessageAvatar>
      <Avatar><AvatarFallback>AM</AvatarFallback></Avatar>
    </MessageAvatar>
    <MessageContent>
      <MessageHeader>Alex Meadow · 10:30</MessageHeader>
      <MessageBubble variant="secondary">Can you help plan the garden?</MessageBubble>
      <MessageFooter>Delivered</MessageFooter>
    </MessageContent>
  </Message>
  <Message align="end">
    <MessageContent>
      <MessageBubble>Yes. Let's start with the planting list.</MessageBubble>
      <MessageBubble>Tomatoes and basil go in the south bed.</MessageBubble>
    </MessageContent>
  </Message>
</MessageGroup>

Put bubbles, text, cards, or buttons inside MessageContent. Consecutive bubbles from one sender go in the sameMessageContent.

align is "start", the default, or "end". An end message puts the avatar on the trailing side and moves every direct child of MessageContent that has a data-slot to the end. A child without a slot, such as a plain div, keeps its own width. The footer text also moves to the end. There are no sizes: text is 14px, the header and footer are 12px.

MessageGroup stacks messages 8px apart. Inside a message, the avatar and content are 8px apart and the content's children are 10px apart. MessageAvatar is at least 32px wide and sits at the bottom of the message. When the content ends with a MessageFooter, the avatar moves up so it sits beside the last bubble.

The header and footer are inset 12px so they line up with padded bubble text. When a direct child of the content has data-variant="ghost", a bubble without padding, the inset drops to 0.

Every part takes the props of a div, including ref, so you can read the rendered bubbles from MessageContentfor copy actions.

Bubbles

Bubble variantsTSX
<Message align="end">
  <MessageContent>
    <MessageBubble variant="secondary">
      Create a Customers table with Name, Email, Company, Phone, and Status.
    </MessageBubble>
  </MessageContent>
</Message>

<Message>
  <MessageContent>
    <MessageBubble variant="ghost">{answer}</MessageBubble>
  </MessageContent>
</Message>

MessageBubble is at most 80% of its column and as wide as its text. There is one size: 14px text with 8px by 12px padding and the panel radius. A bubble has no side of its own. It moves to the end insideMessage align="end", or inside any element withdata-slot="message" and data-align="end".

variant sets the fill. "default" uses the primary color for your own messages. "secondary" is for prompts sent to an agent. "muted" is a lighter fill for quiet notes. "tinted" mixes in the primary color so it follows a brand theme. "outline" draws a border for quoted text."ghost" drops the fill and padding and may span the full width, for agent answers. "destructive" marks a message that failed.

Reactions

A reaction pillTSX
import { MessageBubble, MessageReactions } from "@tulipfarm/sprout/message";

<MessageBubble
  variant="secondary"
  reactions={<MessageReactions align="start" side="bottom">🌱 2</MessageReactions>}
>
  Hello from Alex.
</MessageBubble>

Pass a MessageReactions to the reactions prop. It pins a pill over the "top" or "bottom" edge, 12px in from the "start" or "end" corner. The defaults are side="bottom" and align="end". With reactions, the bubble and pill are wrapped in an element withdata-slot="message-bubble-frame".

Leave room around a bubble with reactions. The pill hangs three quarters outside it. Its 3px ring uses --sprout-message-reactions-ring, which defaults to the surface color. Set it to the background behind the bubble.

Links and buttons

Interactive bubblesTSX
<MessageBubble variant="outline" render={<a href="/results/investor-update" />}>
  Open result · Investor update draft
</MessageBubble>

<Message align="end">
  <MessageContent>
    <MessageBubble
      variant="destructive"
      render={<button type="button" />}
      onClick={retry}
    >
      Couldn't send. Retry
    </MessageBubble>
  </MessageContent>
</Message>

render swaps the bubble element. A link or button keeps the variant fill, gets a slightly stronger fill on hover, and shows a focus ring when reached with the keyboard. Reactions sit outside the bubble element, so a bubble rendered as a button can still hold reaction buttons. Buttons inside MessageReactions become 24px toggles. Set aria-pressed and an aria-label with the count.

Every bubble has data-slot="message-bubble", so a copy action can collect the text of each bubble in a message.

Reply status

Failed reply footerTSX
<Message align="end">
  <MessageContent>
    <MessageBubble variant="secondary">{text}</MessageBubble>
    <MessageFooter>
      <p role="status"><Danger aria-hidden="true" /> The reply didn't finish.</p>
      <Button size="sm" variant="outline" onClick={retry}>Retry reply</Button>
    </MessageFooter>
  </MessageContent>
</Message>

<Message align="start" aria-busy="true">
  <MessageContent>
    <span className="sr-only">TulipFarm said</span>
    {streamedText}
  </MessageContent>
</Message>

Accessibility

Message and its bubbles add no role and are not focusable. Put messages in a list or a labelled region and let links, buttons, and bubbles rendered as either take focus in reading order. Make a scrolling conversation a focusable, labelled region so it can be scrolled with the keyboard. When a reply has no visible name, add visually hidden text such as "TulipFarm said". Mark a reply that is still streaming with aria-busy, and announce status changes with role="status" or role="alert".

Colors and fills change in 120ms and stop when reduced motion is requested. In forced colors mode every filled bubble and reaction pill gets a border. The layout uses logical sides, so end messages flip in right-to-left text.

Theme tokens

The header and footer use --sprout-muted. The avatar slot uses --sprout-secondary behind images that do not fill it. Text uses --sprout-text, --sprout-text-sm, and--sprout-text-xs.

Bubble fills use --sprout-primary,--sprout-secondary, --sprout-surface,--sprout-line, and --sprout-danger-subtle. Radius follows --sprout-radius-panel. The reaction ring uses--sprout-message-reactions-ring.