Components Chat (AI)

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

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.
Today
How do I install petal_components?
Searched the docs
Installation guide
hexdocs.pm/petal_components

Add the dep and pull it in:

def deps do
[{:petal_components, "~> 4.5"}]
end

Then use PetalComponents in your web module and every component is a plain HEEx tag.

heex
<.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.

What's the weather in Tokyo?
It's 22°C and clear in Tokyo right now, with a light breeze from the south.
heex
<.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.

Can you summarise this in one line?
It's a Phoenix component library that ships an MCP server so AI tools use the real API.
heex
<.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.

Searching the web
get_weather
Tokyo
21°C
☀️
Payment failed
heex
<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
First I considered the user's location, then looked up the current conditions and picked the most relevant detail.
heex
<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")
heex
<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.

heex
<.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.

heex
<.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.

heex
<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.

Today
Pick up where we left off.
Running search_docs
Found 3 matches. Here's the most relevant one.
heex
<.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.

heex
<div class="w-full max-w-xl mx-auto">
<.prompt_input id="showcase-chat-composer" placeholder="Message the assistant..." />
</div>
Properties
<.conversation>
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
<.chat_message>
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
<.streaming_text>
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
<.prompt_input>
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
<.tool_call>
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
<.markdown>
Attribute Type Default Description
class any nil
content* string
id string nil pass a unique id to enable per-code-block copy buttons
<.rich_text>
Attribute Type Default Description
class any nil
content* string
render_widget any nil fn(name :: String.t(), args :: map) -> rendered | nil
<.reasoning>
Attribute Type Default Description
class any nil
label string "Reasoning"
open boolean false
:inner_block slot slot
<.marker>
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
<.message_actions>
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
<.copy_button>
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
<.suggestions>
Attribute Type Default Description
class any nil
items* list
on_select string "suggestion" event pushed with phx-value-prompt
<.chat_error>
Attribute Type Default Description
class any nil
on_retry string nil
retry_label string "Retry"
:inner_block slot slot