Elements

Elements · Thread

Launcher

The floating entry point, and the panel it opens into.

fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-launcher-bubble"
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 single floating button that opens into a small panel: a greeting, a few starter prompts, and a way to begin. With a runtime the prompts populate the real composer and the button that starts the conversation is the real send; standalone every interaction is a callback you wire yourself.

Getting started

Nothing about the panel is runtime state except what happens when you press something in it. Rebuild the panel's two interactive pieces from ThreadPrimitive.Suggestion and ComposerPrimitive.Send, and the launcher drives the same composer and thread the rest of your app does.

Wire the prompts and the start button to the runtime

components/assistant-ui/elements/launcher.tsx
"use client";

import { useState } from "react";
import { ComposerPrimitive, ThreadPrimitive, useAuiEvent } from "@assistant-ui/react";
import { MessageCircleIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, floating, inkButton, mono } from "@/components/assistant-ui/elements/surfaces";

const PROMPTS = ["Summarize this page", "Find a teammate", "Report a bug"];

export function Launcher() {
  const [open, setOpen] = useState(false);

  // Surface the panel the moment a run starts, even if it was triggered elsewhere.
  useAuiEvent("thread.runStart", () => setOpen(true));

  return (
    <div className="flex w-full max-w-[19rem] flex-col items-end gap-2.5">
      {open && (
        <div className={cn(floating, "flex w-full flex-col gap-3 rounded-[20px] p-4")}>
          <div className="flex flex-col gap-1">
            <span className="text-[13.5px] font-medium">How can I help?</span>
            <span className={cn(mono, "text-foreground/30")}>typically replies in a minute</span>
          </div>

          <div className="flex flex-col gap-1.5">
            {PROMPTS.map((prompt) => (
              <ThreadPrimitive.Suggestion
                key={prompt}
                prompt={prompt}
                className={cn(
                  field,
                  "hover:bg-foreground/[0.07] text-foreground/70 rounded-xl px-3 py-2 text-start text-[13px] transition-colors",
                )}
              >
                {prompt}
              </ThreadPrimitive.Suggestion>
            ))}
          </div>

          <ComposerPrimitive.Send
            className={cn(inkButton, "flex h-8 items-center justify-center rounded-full text-xs font-medium")}
          >
            Start a conversation
          </ComposerPrimitive.Send>
        </div>
      )}

      <button
        type="button"
        aria-expanded={open}
        aria-label={open ? "Close the assistant" : "Open the assistant"}
        onClick={() => setOpen((o) => !o)}
        className={cn(inkButton, "flex size-12 items-center justify-center rounded-full")}
      >
        {open ? <XIcon className="size-5" /> : <MessageCircleIcon className="size-5" />}
      </button>
    </div>
  );
}

ThreadPrimitive.Suggestion defaults to replacing the composer text rather than sending, so picking a prompt loads it for the user to glance at before ComposerPrimitive.Send (which disables itself while the composer is empty) actually starts the run.

Anatomy

<div data-slot="launcher-bubble">
  {/* only in the DOM while open; no closing transition, it just unmounts */}
  <div>
    <span>{/* greeting */}</span>
    <span>{/* "typically replies in a minute" */}</span>
    <div>{/* one button per prompt */}</div>
    <button>{/* "Start a conversation" */}</button>
  </div>
  <button aria-expanded aria-label="Open the assistant | Close the assistant">
    {/* message-circle and X layered in the same cell, cross-fading with a 90deg rotation */}
    <span>{/* unread count, only while closed and unread > 0 */}</span>
  </button>
</div>

The toggle button always renders; the panel above it mounts only while open is true, with an entrance animation (fade, scale, and slide up) and no matching exit, so closing is instant. The unread badge disappears the moment open becomes true regardless of the unread value, so a host app should clear its own unread count on open rather than rely on the badge to do it. Nothing here is disabled: standalone, the prompt buttons and the start button are always clickable, since there is no composer state to gate them against.

Examples

Restyle the panel and the toggle

Both lanes take className on the root, which only affects layout (width, gap, alignment); the panel and toggle button read their surfaces from the shared floating and inkButton tokens, so retheming those two covers this element everywhere it appears.

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

Badging the closed toggle

The runtime has no concept of unread messages; nothing distinguishes "a message the user has seen" from "a message they have not." Track it yourself, for example by diffing s.thread.messages.length against the count you last saw when the panel was open, and pass the difference through the same unread prop the standalone lane uses.

API reference

This element has no dedicated primitive; its two interactive pieces are ThreadPrimitive.Suggestion and ComposerPrimitive.Send, both part of @assistant-ui/react's primitive set. The rest, opening, closing, and the unread count, is component state you own, as in Getting started.

Thread events used above

EventPayloadDescription
thread.runStart{ threadId: string }Fires when a run begins, from any source. Subscribing with useAuiEvent is what lets the panel surface itself even when a message was sent from elsewhere in the app.