Message
A chat row pairing an author and avatar with message content.
Installation
Included in poetry-ui — available as
poetry_message
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add message
Default
Open Default standalone
<div class="w-full max-w-md">
<%= poetry_message do |message| %>
<% message.with_avatar { poetry_avatar(src: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNjM2NmYxIi8+PHN0b3Agb2Zmc2V0PSIuNTUiIHN0b3AtY29sb3I9IiM4YjVjZjYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNlYzQ4OTkiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIGZpbGw9InVybCgjZykiLz48Y2lyY2xlIGN4PSIzMiIgY3k9IjI1IiByPSIxMCIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48cGF0aCBkPSJNMTIgNjRjMi0xMyAxMC0xOSAyMC0xOXMxOCA2IDIwIDE5eiIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48L3N2Zz4=", label: "Assistant") { "AI" } } %>
<% message.with_header { "Assistant" } %>
<%= poetry_bubble(variant: :muted) { "Here's the plan for today." } %>
<% end %>
</div>
Actions
Open Actions standalone
<%# Message-level actions live in the footer slot - ghost icon buttons that
act on the bubble above them. Icon-only buttons need label: for their
accessible name. %>
<div class="w-full max-w-md">
<%= poetry_message do |message| %>
<% message.with_avatar { poetry_avatar(src: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNjM2NmYxIi8+PHN0b3Agb2Zmc2V0PSIuNTUiIHN0b3AtY29sb3I9IiM4YjVjZjYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNlYzQ4OTkiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIGZpbGw9InVybCgjZykiLz48Y2lyY2xlIGN4PSIzMiIgY3k9IjI1IiByPSIxMCIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48cGF0aCBkPSJNMTIgNjRjMi0xMyAxMC0xOSAyMC0xOXMxOCA2IDIwIDE5eiIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48L3N2Zz4=", label: "Assistant") { "AI" } } %>
<% message.with_header { "Assistant" } %>
<% message.with_footer do %>
<div class="flex items-center gap-1">
<%= poetry_button(variant: :ghost, size: :"icon-xs", label: "Copy reply") { poetry_icon(name: :copy) } %>
<%= poetry_button(variant: :ghost, size: :"icon-xs", label: "Regenerate reply") { poetry_icon(name: :"refresh-cw") } %>
<%= poetry_button(variant: :ghost, size: :"icon-xs", label: "Good response") { poetry_icon(name: :"thumbs-up") } %>
<%= poetry_button(variant: :ghost, size: :"icon-xs", label: "Bad response") { poetry_icon(name: :"thumbs-down") } %>
</div>
<% end %>
<%= poetry_bubble(variant: :muted) { "Deploys usually take about four minutes. I'll ping you the moment it's live." } %>
<% end %>
</div>
Align end
Open Align end standalone
<div class="w-full max-w-md">
<%= poetry_message(align: :end) do |message| %>
<% message.with_footer { "Read 10:42" } %>
<%= poetry_bubble { "Sounds good!" } %>
<% end %>
</div>
Attachment
Open Attachment standalone
<%# A message can carry a file chip alongside its bubble - poetry_attachment
renders the name, type, and size in the content column beneath the text. %>
<div class="w-full max-w-md">
<%= poetry_message(align: :end) do |message| %>
<% message.with_footer { "Sent 09:15" } %>
<%= poetry_bubble { "Here's the signed contract for your review." } %>
<%= poetry_attachment do |attachment| %>
<% attachment.with_media { poetry_icon(name: :"file-text") } %>
<% attachment.with_title { "service-agreement.pdf" } %>
<% attachment.with_description { "248 KB" } %>
<% end %>
<% end %>
</div>
Conversation
Open Conversation standalone
<%# A short back-and-forth between two people - each side carries its
own avatar (the two stand-in photos), incoming rows at the start,
the local user's replies mirrored via align: :end on the Message
(never on the Bubbles inside it). %>
<div class="flex w-full max-w-md flex-col gap-4">
<%= poetry_message do |message| %>
<% message.with_avatar { poetry_avatar(src: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjMGQ5NDg4Ii8+PHN0b3Agb2Zmc2V0PSIuNTUiIHN0b3AtY29sb3I9IiMwMjg0YzciLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM2NWEzMGQiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIGZpbGw9InVybCgjZykiLz48Y2lyY2xlIGN4PSIzMiIgY3k9IjI1IiByPSIxMCIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48cGF0aCBkPSJNMTIgNjRjMi0xMyAxMC0xOSAyMC0xOXMxOCA2IDIwIDE5eiIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48L3N2Zz4=", label: "Sam Rivera") { "SR" } } %>
<% message.with_header { "Sam Rivera" } %>
<%= poetry_bubble(variant: :muted) { "Are we still on for the design sync at noon?" } %>
<% end %>
<%= poetry_message(align: :end) do |message| %>
<% message.with_avatar { poetry_avatar(src: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNjM2NmYxIi8+PHN0b3Agb2Zmc2V0PSIuNTUiIHN0b3AtY29sb3I9IiM4YjVjZjYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNlYzQ4OTkiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIGZpbGw9InVybCgjZykiLz48Y2lyY2xlIGN4PSIzMiIgY3k9IjI1IiByPSIxMCIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48cGF0aCBkPSJNMTIgNjRjMi0xMyAxMC0xOSAyMC0xOXMxOCA2IDIwIDE5eiIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48L3N2Zz4=", label: "You") { "ME" } } %>
<% message.with_footer { "Delivered 11:58" } %>
<%= poetry_bubble { "Yes - I just moved it to the larger room." } %>
<% end %>
<%= poetry_message do |message| %>
<% message.with_avatar { poetry_avatar(src: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjMGQ5NDg4Ii8+PHN0b3Agb2Zmc2V0PSIuNTUiIHN0b3AtY29sb3I9IiMwMjg0YzciLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM2NWEzMGQiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIGZpbGw9InVybCgjZykiLz48Y2lyY2xlIGN4PSIzMiIgY3k9IjI1IiByPSIxMCIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48cGF0aCBkPSJNMTIgNjRjMi0xMyAxMC0xOSAyMC0xOXMxOCA2IDIwIDE5eiIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48L3N2Zz4=", label: "Sam Rivera") { "SR" } } %>
<%= poetry_bubble(variant: :muted) { "Perfect, see you there." } %>
<% end %>
</div>
Grouped
Open Grouped standalone
<%# One sender's consecutive run - a single Message carries the avatar and
header, and poetry_bubble_group stacks the bubbles beneath them so the
identity is shown once for the whole group. %>
<div class="w-full max-w-md">
<%= poetry_message do |message| %>
<% message.with_avatar { poetry_avatar(src: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIxIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNjM2NmYxIi8+PHN0b3Agb2Zmc2V0PSIuNTUiIHN0b3AtY29sb3I9IiM4YjVjZjYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNlYzQ4OTkiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIGZpbGw9InVybCgjZykiLz48Y2lyY2xlIGN4PSIzMiIgY3k9IjI1IiByPSIxMCIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48cGF0aCBkPSJNMTIgNjRjMi0xMyAxMC0xOSAyMC0xOXMxOCA2IDIwIDE5eiIgZmlsbD0iI2ZmZiIgb3BhY2l0eT0iLjkiLz48L3N2Zz4=", label: "Ops Bot") { "OB" } } %>
<% message.with_header { "Ops Bot" } %>
<%= poetry_bubble_group do %>
<%= poetry_bubble(variant: :muted) { "The nightly backup completed." } %>
<%= poetry_bubble(variant: :muted) { "42 GB archived across three volumes." } %>
<%= poetry_bubble(variant: :muted) { "Next run is scheduled for 02:00." } %>
<% end %>
<% end %>
</div>
Header and footer
Open Header and footer standalone
<%# Upstream message-header-footer: the header names the sender above
the bubble; the footer carries delivery state below it. %>
<div class="flex w-full max-w-md flex-col gap-8">
<%= poetry_message do |message| %>
<% message.with_header { "Olivia" } %>
<%= poetry_bubble(variant: :muted) { "I already checked the logs." } %>
<% end %>
<%= poetry_message(align: :end) do |message| %>
<% message.with_footer do %>
<div>Read <span class="font-normal">Yesterday</span></div>
<% end %>
<%= poetry_bubble { "Send the report to the team. Ping @shadcn if you need help." } %>
<% end %>
</div>
Markdown
Open Markdown standalone
<%# Upstream message-markdown, the Rails way: render assistant markdown
app-side (Commonmarker, Kramdown...) and place the HTML in a ghost
bubble so the response reads unframed; user messages stay plain
bubbles. Static HTML stands in for the rendered markdown here. %>
<div class="flex w-full max-w-md flex-col gap-8">
<%= poetry_message(align: :end) do |message| %>
<%= poetry_bubble { "How do I render markdown in a message?" } %>
<% end %>
<%= poetry_message do |message| %>
<%= poetry_bubble(variant: :ghost) do %>
<div class="space-y-2">
<p>Here's how to render markdown in a message:</p>
<ol class="list-decimal space-y-1 pl-5">
<li>Render assistant text through your app-side <strong>markdown pipeline</strong>.</li>
<li>Keep user messages as plain text.</li>
<li>Use a <code>ghost</code> bubble so the response is unframed.</li>
</ol>
</div>
<% end %>
<% end %>
</div>
API
Poetry::Ui::Message::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| align: | Symbol | one of: start, end; defaults to :start |
Which side the row sits on; :end mirrors it for the local user's side. |
Slots
| Writer | Description |
|---|---|
| with_avatar | The sender's avatar, kept beside the content column - decorative context; put meaningful sender identity in the header. |
| with_footer | Timestamps / delivery state below the bubbles. |
| with_header | The sender identity line above the bubbles. |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=message] | The chat-row root - avatar plus a content column; align: :end mirrors the row for the local user's side |
| [data-slot=message-avatar] | The avatar slot's box, kept out of the content column |
| [data-slot=message-content] | The content column - header, the body block (the Bubbles), footer |
| [data-slot=message-header] | Sender identity line above the bubbles (header slot) |
| [data-slot=message-footer] | Timestamps / delivery state below the bubbles (footer slot - it lifts the avatar) |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| message | data-align | always - the resolved align | start · end |