Inserts a chat UI element into a Shiny UI, which includes a scrollable section for displaying chat messages, and an input field for the user to enter new messages.
To respond to user input, listen for input$ID_user_input (for example, if
id="my_chat", user input will be at input$my_chat_user_input), and use
chat_append() to append messages to the chat.
Usage
chat_ui(
id,
...,
greeting = NULL,
placeholder = "Enter a message...",
drawer = TRUE,
footer = NULL,
toolbar_input = NULL,
show_history = TRUE,
show_thinking_after_s = 0,
width = "min(clamp(680px, 50vw, 760px), 100%)",
height = "auto",
fill = TRUE,
icon_assistant = NULL,
icon_send = NULL,
enable_cancel = NULL,
submit_key = c("enter", "enter+modifier"),
allow_attachments = NULL,
tool_grouping = c("tool", "none", "all"),
messages = NULL
)Arguments
- id
The ID of the chat element
- ...
Extra HTML attributes to include on the chat element
- greeting
An optional greeting to display when the chat first loads. Can be a
chat_greeting()object, or a plain string (which is auto-wrapped with default options). The greeting is dismissed when the user sends their first message. For example:greeting = chat_greeting("## Hello!\n\nHow can I help you today?")- placeholder
The placeholder text for the chat's user input field
- drawer
Whether to enable the drawer.
TRUE(the default) enables an initially hidden panel with default options,FALSEomits it, andchat_drawer()supplies its initial configuration.Optional HTML content to display in a bottom-pinned, full-width chat region. This can be any HTML content (tags, tag lists, or character strings). Useful for adding disclaimers, attribution, or other information. The footer text is styled slightly smaller and lighter than body text by default. Customize with CSS properties
--shiny-chat-footer-font-sizeand--shiny-chat-footer-coloron the chat container or footer element.- toolbar_input
Optional HTML content to display directly below the chat input. Use
bslib::toolbar()to group toolbar controls.- show_history
Whether to show the built-in history selector. Defaults to
TRUE; setting it toFALSEonly hides its presentation.- show_thinking_after_s
The minimum number of seconds a contiguous thinking block must run before it is displayed. Defaults to
0, which displays thinking immediately. Positive values hide shorter blocks; negative values always hide thinking. Values must not exceed 60 seconds.- width
The CSS width of the chat element
- height
The CSS height of the chat element
- fill
Whether the chat element should try to vertically fill its container, if the container is fillable
- icon_assistant
The icon to use for the assistant chat messages. Can be HTML or a tag in the form of
htmltools::HTML()orhtmltools::tags().NULL(the default) orFALSEomits the assistant icon entirely. PassTRUEto use the built-in robot icon (individual messages can still opt in to a different icon via theiconargument ofchat_append()).- icon_send
The icon to use for the chat input's ready-state submit button. Can be HTML or a tag in the form of
htmltools::HTML()orhtmltools::tags(). IfNULL(the default) orFALSE, a default arrow icon is used. The button provides a filled circular surface (state-colored background, white icon); the supplied icon replaces only the glyph inside it. See the "Customizing the send button" section below for styling patterns.- enable_cancel
Whether to show a stop button during streaming that allows the user to cancel the in-progress response. When using
chat_server(), cancellation is wired up automatically and this defaults toNULL(let the server decide). For manual usage withoutchat_server(), setTRUEorFALSEexplicitly and observeinput$<id>_cancelto handle cancellation (e.g., by callingctrl$cancel()on an ellmerstream_controller()).- submit_key
Controls which key combination submits the chat message.
"enter"(the default): Enter submits, Shift+Enter adds a newline."enter+modifier": Ctrl+Enter (Cmd+Enter on Mac) submits, plain Enter adds a newline.- allow_attachments
Controls the file-attachment affordance (an attach button, plus clipboard paste and drag-and-drop) in the chat input.
NULL(default) defers tochat_server(), which enables attachments automatically. PassTRUEto accept all supported types (PNG, JPEG, GIF, WebP, PDF, and common text/code files such as Markdown, plain text, CSV, JSON, and source files),FALSEto disable, or a character vector of MIME types to restrict what is accepted (each must be one of the supported types).The shape of
input$<id>_user_inputis determined by this argument, so it is predictable for a given app. When attachments are disabled (the default), it is the typed text as a character string, exactly as before. When attachments are enabled, it is always a list of ellmer ellmer::Content objects (the typed text, if any, followed by one content object per attachment) - a list even when no files were attached. Splice the list into a chat method's...with!!!, e.g.client$stream_async(!!!input$<id>_user_input). (Norlang::inject()is needed: ellmer's chat methods collect...with dynamic dots.)The maximum combined size of all attachments in a single message is controlled globally by the
SHINYCHAT_MAX_ATTACHMENT_SIZEenvironment variable (a raw byte count; defaults to approximately 30 MB). Files that would push the total over this cap are rejected in the browser with a notice.- tool_grouping
Controls how tool calls are grouped together in the compact activity rows:
"tool"(default): calls to the same tool within a turn's contiguous tool loop are grouped into one activity row. This groups by tool name across the whole loop, not just consecutive calls – e.g. calls to toolsX,Y,Z,X,Y(in that order) are grouped intoX(2 calls),Y(2 calls), andZ(1 call)."all": every tool call within a contiguous tool loop is summarized in one activity row, regardless of tool name."none": each tool call is shown in its own activity row. Its request and result remain available by drilling into that row; this does not restore an always-visible card stack.
Prose or thinking between tool calls starts a new tool loop, so calls on opposite sides of either boundary never group together. Individual tools can override
"tool"or"all"via a top-levelgroupingtool annotation, e.g.ellmer::tool(..., annotations = ellmer::tool_annotations(grouping = "all")).tool_grouping = "none"takes precedence over every annotation and disables grouping for the whole chat.- messages
Deprecated. A list of messages to prepopulate the chat with. Startup messages can't be recorded by the conversation-history feature. Use
greetingfor a startup message,chat_append()to replay messages from the server, or sethistory = FALSEinchat_server()if you're managing conversation state yourself. Each message can be one of the following:A string, which is interpreted as markdown and rendered to HTML on the client.
To prevent interpreting as markdown, mark the string as
htmltools::HTML().
A UI element.
This includes
htmltools::tagList(), which takes UI elements (including strings) as children. Strings inside a tagList are literal text (HTML-escaped), not markdown. Usehtmltools::HTML()for trusted raw HTML strings.
A named list of
contentandrole. Thecontentcan contain content as described above, and therolecan be "assistant" or "user".Advanced: a
list()mixing bare strings and UI elements interleaves markdown and HTML in one message, in order. This API is provisional and may change in a future release.
Pairing with chat_server()
chat_ui(id) and chat_server(id, client) pair by matching id. This
works the same way at the top level of an app and inside your own Shiny
module — chat_server() is not itself a module, so no NS(id, "chat")
wrapping is required:
# Top-level app, no module
ui <- page_fillable(chat_ui("chat"))
server <- function(input, output, session) {
chat_server("chat", client)
}# Inside your own module: pass the same literal id to both, and call
# chat_server() from inside moduleServer() so it inherits the module's
# already-namespaced `session`
mod_ui <- function(id) {
ns <- NS(id)
chat_ui(ns("chat"))
}
mod_server <- function(id, client) {
moduleServer(id, function(input, output, session) {
chat_server("chat", client)
})
}Greeting
A greeting is an optional welcome message shown before any conversation
messages. It is automatically dismissed when the user sends their first
message (unless created with persistent = TRUE).
Static greeting. Pass a string or chat_greeting() to the greeting
parameter:
chat_ui("chat", greeting = "## Hello!\n\nHow can I help you today?")Dynamic greeting from the server. Leave greeting unset and use
chat_set_greeting() from your server function. This is useful when the
greeting depends on session state or is generated by a model.
greeting_requested input. When the chat is visible on the page, has
no messages, and has no greeting set, Shiny fires
input$<id>_greeting_requested (e.g. input$chat_greeting_requested for
chat_ui("chat")). The value is an event counter suitable for
shiny::observeEvent(). Use it to trigger server-side greeting generation:
observeEvent(input$chat_greeting_requested, {
stream <- chat_client$stream_async("Generate a short welcome message.")
chat_set_greeting("chat", chat_greeting(stream))
})This input fires when the chat component is first viewed on the page and
empty, and again after chat_clear() (greeting = TRUE), enabling a
regenerate pattern where clearing the greeting automatically triggers a
fresh one.
greeting_dismissed input. When the user dismisses the greeting,
input$<id>_greeting_dismissed fires with a Date.now() timestamp. If the
greeting is later cleared after being dismissed, the input resets to NULL.
If you use chat_server(), you can access the greeting_dismissed
reactive from the returned value instead of the raw namespaced input
string.
Thinking display
When a model produces reasoning or "thinking" tokens, shinychat renders them in a collapsible panel above the response. The panel shows a live stream of the model's reasoning while it thinks, then auto-collapses when the response begins.
Thinking display works automatically with any model that supports it. Two paths are supported:
ellmer's
ContentThinkingobjects. Models that provide a structured thinking API (e.g., Claude with extended thinking) emitContentThinkingobjects when you stream withstream = "content". shinychat detects these and routes them to the thinking panel. This is whatchat_append()uses internally when you pass it an ellmer content stream.Raw
<thinking>tags. Many open-source and local models (DeepSeek, QwQ, Qwen, etc.) emit<thinking>...</thinking>tags directly in their markdown output. shinychat detects these tags during streaming and renders the enclosed text in the thinking panel with no extra configuration.
Topic labels
You can optionally get labeled sub-sections within the thinking panel by
asking the model to emit <topic>...</topic> tags in its reasoning. These
are extracted and rendered as section headings inside the thinking display,
and the current topic appears in the collapsed header as a live status.
To use topic labels, add something like this to your system prompt:
When thinking through a problem, wrap brief topic labels in <topic> tags
to indicate what you're currently reasoning about. For example:
<topic>parsing the input</topic>Topic labels are entirely optional. Without them, the thinking panel still works – it just won't have sub-section headings.
Set show_thinking_after_s in chat_ui() to delay the thinking panel until a
contiguous thinking block has run for a minimum number of seconds. The
default, 0, displays thinking immediately; a negative value always hides
it. Values greater than 60 seconds are not supported. Durations are measured
in the browser while streaming, so preloaded or restored thinking is
displayed only with the default.
Customizing the send button
The send button is a filled circle (24px by default) whose background
color reflects the current state (primary when ready, gray when
empty/disabled, danger when cancelling) with a white icon (22px by
default) centered inside. The icon_send parameter swaps the
ready-state icon without changing the button's surface.
Custom icon. Pass an SVG from bsicons::bs_icon() or
fontawesome::fa(). The button provides the surface, so a bare glyph
gets the same filled-circle treatment as the default arrow:
Icon with text. Use htmltools::tagList() to pass an icon and a text
label as siblings (not wrapped in a <span>) so they lay out side by
side, with a Bootstrap margin utility for spacing. Then override the
button to size to its content instead of the default fixed circle:
chat_ui("chat",
icon_send = tagList(
bsicons::bs_icon("airplane-fill"),
span("Send", class = "ms-2")
)
)Per-state color overrides. Each state's color can be set independently via CSS variables on the chat container or any ancestor. These are only read by the component (never set on the button), so inline styles inherit cleanly:
Ghost (outline) style. Make the button transparent at rest with the
state color on the icon and border, filling on hover. Target the button
element (not an ancestor) because the internal --_btn-send-state-color
variable resolves on the button itself:
:root .shiny-chat-btn-send {
--shiny-chat-btn-send-bg: transparent;
--shiny-chat-btn-send-color: var(--_btn-send-state-color);
--shiny-chat-btn-send-border: 1px solid var(--_btn-send-state-color);
--shiny-chat-btn-send-color-hover: #fff;
--shiny-chat-btn-send-bg-hover: var(--_btn-send-state-color);
}Key CSS variables:
--shiny-chat-btn-send-size— Button width and height (default24px)--shiny-chat-input-icon-size— Icon size, shared with the attach button (default22px)--shiny-chat-btn-send-bg— Button background (default: state color)--shiny-chat-btn-send-color— Icon color (default:#fff)--shiny-chat-btn-send-border— Button border (default:none)--shiny-chat-btn-send-color-ready— Override ready/pending color (default:--bs-primary)--shiny-chat-btn-send-color-empty— Override empty/disabled color (default:--bs-gray-500)--shiny-chat-btn-send-color-cancel— Override cancel/cancelling color (default:--bs-danger)
Examples
if (FALSE) { # interactive()
library(shiny)
library(bslib)
library(shinychat)
ui <- page_fillable(
chat_ui("chat", fill = TRUE)
)
server <- function(input, output, session) {
observeEvent(input$chat_user_input, {
# In a real app, this would call out to a chat client or API,
# perhaps using the 'ellmer' package.
response <- paste0(
"You said:\n\n",
"<blockquote>",
htmltools::htmlEscape(input$chat_user_input),
"</blockquote>"
)
chat_append("chat", response)
})
}
shinyApp(ui, server)
}