Chat (AI)
AI Elements for Phoenix - a composition-first family for streaming chat UIs, the kind you'd reach for assistant-ui or Vercel AI Elements for in React, without a client AI SDK. Tokens stream over the LiveView socket you already have: the model streams to your process, you push each delta to the bubble, and the component owns its own DOM so nothing clobbers the text as it arrives.
Three-minute setup
-
The Chat family is not
pulled in by
use PetalComponents(its generic names likemarkdown/1would clash with your own helpers). Alias it:alias PetalComponents.Chat, then call it namespaced -<Chat.conversation>. -
Register the bundled JS hooks in
app.js(import PetalComponents from "../../deps/petal_components/assets/js/petal_components"thenhooks: { ...PetalComponents }) - they drive token streaming, the composer, and copy buttons. -
For rendered markdown, add the optional
{:mdex, "~> 0.12"}dependency.
<.conversation id="showcase-chat-flagship" class="w-full max-w-xl mx-auto">
<.marker variant="separator">Today</.marker>
<.chat_message role="user">How do I install petal_components?</.chat_message>
<.tool_call name="search_docs" status={:complete} label="Searched the docs">
<div class="flex items-center gap-3 text-sm">
<.icon name="hero-book-open" class="w-8 h-8 text-primary-500" />
<div>
<div class="font-medium text-gray-900 dark:text-gray-100">Installation guide</div>
<div class="text-xs text-gray-500 dark:text-gray-400">hexdocs.pm/petal_components</div>
</div>
</div>
</.tool_call>
<.chat_message role="assistant">
<.markdown
id="showcase-chat-flagship-md"
content={"Add the dep and pull it in:\n\n```elixir\ndef deps do\n [{:petal_components, \"~> 4.5\"}]\nend\n```\n\nThen `use PetalComponents` in your web module and every component is a plain HEEx tag."}
/>
<:actions>
<.message_actions visible="always">
<.copy_button
id="showcase-chat-flagship-copy"
text={"{:petal_components, \"~> 4.5\"}"}
icon
/>
<.action_button icon="hero-hand-thumb-up" label="Good response" phx-click="noop" />
<.action_button icon="hero-hand-thumb-down" label="Bad response" phx-click="noop" />
<.action_button icon="hero-arrow-path" label="Regenerate" phx-click="noop" />
</.message_actions>
</:actions>
</.chat_message>
<:footer>
<.suggestions
class="mb-2"
items={["What makes this different from React AI kits?", "Show me a tool call"]}
on_select="noop"
/>
<.prompt_input
id="showcase-chat-flagship-composer"
placeholder="Ask about petal_components..."
/>
</:footer>
</.conversation>
That's the whole family assembled, rendered statically. For the live version - real token streaming, edit-and-fork, the works - see the streaming-chat recipe . Everything below is one of the pieces it's built from.
Conversation
The default plain variant - full-width turns, the ChatGPT / Claude look. Messages are just slots.
<.conversation id="showcase-chat-plain" class="w-full max-w-xl mx-auto">
<.chat_message role="user">What's the weather in Tokyo?</.chat_message>
<.chat_message role="assistant">
It's 22°C and clear in Tokyo right now, with a light breeze from the south.
</.chat_message>
</.conversation>
Bubbles
Pass variant="bubbles" for the messaging-app layout.
<.conversation id="showcase-chat-bubbles" variant="bubbles" class="w-full max-w-xl mx-auto">
<.chat_message role="user">Can you summarise this in one line?</.chat_message>
<.chat_message role="assistant">
It's a Phoenix component library that ships an MCP server so AI tools use the real API.
</.chat_message>
</.conversation>
Tool calls
Generative UI. The model emits data, you map the tool name to a real Phoenix component. status drives the header: running spins, complete checks, error warns.
<div class="w-full max-w-xl mx-auto space-y-3">
<.tool_call name="search_web" status={:running} label="Searching the web" />
<.tool_call name="get_weather" status={:complete}>
<div class="flex items-center justify-between px-4 py-3 text-white rounded-lg bg-gradient-to-br from-sky-500 to-indigo-600">
<div>
<div class="text-sm font-medium opacity-90">Tokyo</div>
<div class="text-2xl font-bold">21°C</div>
</div>
<div class="text-4xl">☀️</div>
</div>
</.tool_call>
<.tool_call name="charge_card" status={:error} label="Payment failed" />
</div>
Reasoning
A collapsible thinking block for reasoning-model output.
Thought for 2s
<div class="w-full max-w-xl mx-auto">
<.reasoning label="Thought for 2s" open>
First I considered the user's location, then looked up the current conditions and picked the most relevant detail.
</.reasoning>
</div>
Markdown
Render a committed assistant reply as sanitized, syntax-highlighted markdown. Needs the optional :mdex dep.
Forecast
Tokyo is 21°C and sunny.
- Light breeze
- UV index moderate
IO.puts("pack light")
<div class="w-full max-w-xl mx-auto">
<.markdown content={"## Forecast\n\nTokyo is **21°C** and sunny.\n\n- Light breeze\n- UV index moderate\n\n```elixir\nIO.puts(\"pack light\")\n```"} />
</div>
Message actions
A row of actions under a reply. copy_button copies text client-side via a bundled hook.
<.message_actions class="max-w-xl mx-auto">
<.copy_button id="showcase-chat-copy" text="The full assistant reply, copied to the clipboard." />
<button type="button" class="pc-chat__action" phx-click="noop">Regenerate</button>
</.message_actions>
Suggestions
Prompt-starter chips for the empty state. Each pushes on_select with phx-value-prompt.
<.suggestions
class="max-w-xl mx-auto"
items={["What is Phoenix LiveView?", "Show me a markdown demo", "Write a haiku"]}
on_select="suggestion"
/>
Error
An error notice with an optional retry button.
<div class="w-full max-w-xl mx-auto">
<.chat_error on_retry="retry">
Something went wrong generating a response.
</.chat_error>
</div>
Markers
Section dividers between turns - a date, a "new messages" line, or a tool-call header.
<.conversation id="showcase-chat-markers" class="w-full max-w-xl mx-auto">
<.marker variant="separator">Today</.marker>
<.chat_message role="user">Pick up where we left off.</.chat_message>
<.marker variant="border" icon="hero-wrench-screwdriver">Running search_docs</.marker>
<.chat_message role="assistant">Found 3 matches. Here's the most relevant one.</.chat_message>
</.conversation>
Prompt input
The composer - an autogrowing textarea with the arrow-up send button. Enter submits, Shift+Enter adds a line.
<div class="w-full max-w-xl mx-auto">
<.prompt_input id="showcase-chat-composer" placeholder="Message the assistant..." />
</div>
Properties
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
id
|
string |
defaults to a generated id so multiple threads can coexist | |
rest
|
global |
||
variant
|
string |
"plain"
|
plain is the AI convention (ChatGPT/Claude): assistant text sits on the surface, only the user gets a bubble. bubbles puts both sides in bubbles (messenger style)
one of: "plain", "bubbles"
|
:footer
slot
|
slot |
pinned below the scroll area, e.g. a prompt_input | |
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
rest
|
global |
||
role
|
string |
"assistant"
|
one of: "user", "assistant", "system"
|
:actions
slot
|
slot |
an action bar rendered below the message, outside the bubble - message_actions/1. Works on any role: copy/edit under a user message, copy/feedback/regenerate under an assistant one | |
:avatar
slot
|
slot |
optional leading avatar/icon | |
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
event
|
string |
"pc-chat-token"
|
push_event name the hook listens for |
format
|
string |
"text"
|
"text" appends raw token deltas; "markdown" replaces innerHTML with rendered HTML you push (see `to_html/1`)
one of: "text", "markdown"
|
id*
|
string |
| Attribute | Type | Default | Description |
|---|---|---|---|
aria_label
|
string |
"Message"
|
accessible label for the textarea |
class
|
any |
nil
|
|
edit_label
|
string |
"Editing message"
|
label shown in the edit banner |
editing
|
boolean |
false
|
show the edit-mode banner above the field (set while editing a past message) |
id
|
string |
defaults to a generated id so multiple composers can coexist | |
loading
|
boolean |
false
|
|
name
|
string |
"prompt"
|
|
on_cancel_edit
|
string |
nil
|
event pushed when the edit banner's cancel (X) is clicked |
on_stop
|
string |
nil
|
event pushed when the stop button is clicked while loading |
placeholder
|
string |
"Send a message..."
|
|
rest
|
global |
||
submit_label
|
string |
nil
|
text for the send button; the default is the arrow-up icon convention |
value
|
string |
""
|
initial textarea value. The field is uncontrolled after mount (phx-update=ignore, so keystrokes never re-render and lose focus); set it later - edit, quote, clear - by pushing a `pc-chat-set-input` event (`%{value: text}`, optional `%{id: composer_id}`) to the PetalChatComposer hook |
:actions
slot
|
slot |
extra controls left of the send button |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
label
|
string |
nil
|
human label; defaults to the tool name |
name*
|
string |
||
status
|
atom |
:complete
|
one of: :running, :complete, :error
|
:inner_block
slot
|
slot |
the rendered widget / tool result |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
content*
|
string |
||
id
|
string |
nil
|
pass a unique id to enable per-code-block copy buttons |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
content*
|
string |
||
render_widget
|
any |
nil
|
fn(name :: String.t(), args :: map) -> rendered | nil |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
label
|
string |
"Reasoning"
|
|
open
|
boolean |
false
|
|
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
icon
|
string |
nil
|
heroicon name rendered before the text |
loading
|
boolean |
false
|
spinner + role=status for in-progress work |
rest
|
global |
||
variant
|
string |
"inline"
|
inline note, centred labelled separator, or a full-width bordered row
one of: "inline", "separator", "border"
|
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
visible
|
string |
"always"
|
hover reveals the bar on message-row hover/focus; always shows on touch
one of: "always", "hover"
|
:inner_block
slot
|
slot |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
icon
|
boolean |
false
|
icon-only (clipboard -> check feedback) |
id*
|
string |
||
label
|
string |
"Copy"
|
|
text*
|
string |
the text to copy |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
items*
|
list |
||
on_select
|
string |
"suggestion"
|
event pushed with phx-value-prompt |
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
|
on_retry
|
string |
nil
|
|
retry_label
|
string |
"Retry"
|
|
:inner_block
slot
|
slot |