Elements

Elements · Thread

Mobile composer

The bottom sheet: keyboard-aware, quick actions above, thumb-sized targets.

fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-mobile-composer"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A composer shaped for a phone: a row of quick actions that collapses out of the way once the keyboard is up, an attach button, a single-line input, and a send button that becomes stop mid-run. With a runtime every piece is a composer primitive already wired to send, cancel, and attachments; standalone you own the text, the running flag, and every callback.

Getting started

ComposerPrimitive.Root, .Input, .AddAttachment, .Send, and .Cancel already own the text, the attach flow, and the send/cancel gating, so this bar needs no manual value, onValueChange, onSend, or onStop at all; it only needs the layout and the running flag to pick between Send and Cancel.

Compose the bar from composer primitives

components/assistant-ui/elements/mobile-composer-bar.tsx
"use client";

import { ArrowUpIcon, MicIcon, PlusIcon, SquareIcon } from "lucide-react";
import { ComposerPrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { field, ghostButton, inkButton, mono } from "@/components/assistant-ui/elements/surfaces";

const ACTIONS = ["Summarize", "Translate", "Explain"];

export function MobileComposerBar({ keyboardOpen }: { keyboardOpen: boolean }) {
  const isRunning = useAuiState((s) => s.thread.isRunning);
  const isEmpty = useAuiState((s) => s.composer.isEmpty);

  return (
    <ComposerPrimitive.Root
      className={cn(
        "bg-background border-foreground/[0.07] flex w-full max-w-[19rem] flex-col gap-2.5 rounded-t-[20px] border-t px-3 pt-3",
        keyboardOpen ? "pb-3" : "pb-6",
      )}
    >
      {!keyboardOpen && (
        <div className="-mx-3 flex gap-1.5 overflow-x-auto px-3 pb-0.5">
          {ACTIONS.map((action) => (
            <ThreadPrimitive.Suggestion
              key={action}
              prompt={action}
              className={cn(field, "text-foreground/60 shrink-0 rounded-full px-3 py-1.5 text-xs whitespace-nowrap")}
            >
              {action}
            </ThreadPrimitive.Suggestion>
          ))}
        </div>
      )}

      <div className="flex items-end gap-2">
        <ComposerPrimitive.AddAttachment className={cn(ghostButton, field, "size-9 shrink-0")}>
          <PlusIcon className="size-4" />
        </ComposerPrimitive.AddAttachment>

        <div className={cn(field, "flex min-w-0 flex-1 items-center gap-2 rounded-[18px] px-3 py-2")}>
          <ComposerPrimitive.Input
            placeholder="Message"
            className="text-foreground/85 placeholder:text-foreground/30 min-w-0 flex-1 resize-none bg-transparent text-[16px] outline-none"
          />
          {isEmpty && <MicIcon className="text-foreground/35 size-4 shrink-0" />}
        </div>

        {isRunning ? (
          <ComposerPrimitive.Cancel className={cn(inkButton, "flex size-9 shrink-0 items-center justify-center rounded-full")}>
            <SquareIcon className="size-3 fill-current" />
          </ComposerPrimitive.Cancel>
        ) : (
          <ComposerPrimitive.Send className={cn(inkButton, "flex size-9 shrink-0 items-center justify-center rounded-full")}>
            <ArrowUpIcon className="size-4" />
          </ComposerPrimitive.Send>
        )}
      </div>

      {!keyboardOpen ? (
        <span aria-hidden className="bg-foreground/15 mx-auto h-1 w-28 rounded-full" />
      ) : (
        <span className={cn(mono, "text-foreground/25 text-center")}>return to send</span>
      )}
    </ComposerPrimitive.Root>
  );
}

ComposerPrimitive.Root renders a <form>, so ComposerPrimitive.Input's own Enter-to-send already fires through it; nothing here reimplements the key handling the standalone element writes by hand. keyboardOpen still has to come from outside, typically from the input's native focus and blur events or a visualViewport listener, since no runtime state tracks whether an on-screen keyboard is up.

Anatomy

<div data-slot="mobile-composer">
  {/* quick actions, hidden entirely while keyboardOpen */}
  <div>{/* one button per action, disabled together when onAction is absent */}</div>
  <div>
    <button aria-label="Add an attachment" />
    <input aria-label="Message" placeholder="Message" />
    {/* mic icon, only while the input is empty */}
    <button aria-label="Stop | Send" />
  </div>
  {/* a grabber handle, or "return to send", never both */}
</div>

The standalone element renders a single-line <input>; the runtime composition above renders ComposerPrimitive.Input, an auto-resizing <textarea> that can grow past one line, which is the same trade every composer in the catalog makes. Enter triggers a send only when !running && value !== "". While not running, the button shares that same emptiness gate (disabled only when value === ""); while running, both the key and the button stop meaning "send" at all, since the button's label, icon, and handler have already swapped to Stop. The attach button and every action chip disable together based only on whether their handler prop was passed at all (onAttach, onAction); none of them react to running or value on their own, so gating them during a run is the caller's responsibility. The mic icon and the send/stop icon swap are purely presentational: nothing here starts dictation, and stop icon does not imply cancellation happened, only that onStop was called.

Examples

Restyle the bar

Both lanes take className on the root. Attach, input, and action-chip surfaces all read the shared field token and the send button reads inkButton, so retheming those two covers the whole bar.

<MobileComposer className="max-w-xs" /* ... */ />

Wiring the keyboard-aware layout

Since keyboardOpen is not runtime state, drive it from the platform: toggle it on the input's focus and blur, or, for a more accurate signal on mobile web, from window.visualViewport's resize event comparing the viewport height against the layout height.

API reference

ComposerPrimitive

PartRendersNotes
RootformHandles submit-to-send and focuses the input on a blank-area click.
Inputtextarea (auto-resizing)Controls its own value against s.composer.text; Enter submits the closest form unless Shift is held.
AddAttachmentbuttonOpens a file picker and adds each file as an attachment. Disabled while the composer is not in editing mode.
SendbuttonSends the composer. Disabled whenever s.composer.canSend is false (empty, not editing, or the thread blocks sending) or a run is in flight without queue support.
CancelbuttonCancels the in-flight run. Disabled whenever s.composer.canCancel is false, which covers "no run to cancel".

Quick action chips are ThreadPrimitive.Suggestion, the same primitive the launcher uses for its starter prompts; by default it replaces the composer text rather than sending.

Thread and composer state

SelectorTypeDescription
s.thread.isRunningbooleanWhether to show Cancel in place of Send.
s.composer.isEmptybooleanWhether to show the mic affordance in place of nothing.