Elements

97 / 122 · Thread

Launcher

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

Installation

npx shadcn@latest add "@assistant-ui/elements-launcher-bubble"

Usage

import { LauncherBubble } from "@/components/elements/launcher-bubble";

<LauncherBubble
  open={open}
  unread={2}
  greeting="Need a hand?"
  prompts={prompts}
  onToggle={toggle}
/>

Props

open*booleanWhether the panel is showing. The trigger icon crossfades either way.
unread*numberBadge count on the closed bubble. Hidden at zero and while open.
greeting*stringOpening line in the panel.
prompts*string[]Ways in, so the first message does not have to be invented.
onToggle() => voidCalled when the bubble is pressed.
onPick(prompt: string) => voidCalled when a starter prompt is chosen.
onStart() => voidCalled from the Start a conversation action, for opening an empty thread.
classNamestringExtra classes merged onto the root.

Source

launcher-bubble.tsx
"use client";

import { MessageCircleIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, floating, inkButton, mono } from "./surfaces";

export function LauncherBubble({
  open,
  unread,
  greeting,
  prompts,
  onToggle,
  onPick,
  onStart,
  className,
}: {
  open: boolean;
  unread: number;
  greeting: string;
  prompts: readonly string[];
  onToggle?: () => void;
  onPick?: (prompt: string) => void;
  onStart?: () => void;
  className?: string;
}) {
  return (
    <div
      className={cn(
        "flex w-full max-w-[19rem] flex-col items-end gap-2.5",
        className,
      )}
    >
      {open && (
        <div
          className={cn(
            floating,
            "fade-in zoom-in-95 slide-in-from-bottom-2 animate-in flex w-full flex-col gap-3 rounded-[20px] p-4 duration-200",
          )}
        >
          <div className="flex flex-col gap-1">
            <span className="text-[13.5px] font-medium">{greeting}</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) => (
              <button
                key={prompt}
                type="button"
                onClick={() => onPick?.(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}
              </button>
            ))}
          </div>

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

      <button
        type="button"
        aria-expanded={open}
        aria-label={open ? "Close the assistant" : "Open the assistant"}
        onClick={onToggle}
        className={cn(
          inkButton,
          "relative flex size-12 shrink-0 items-center justify-center rounded-full",
        )}
      >
        <span className="grid">
          <MessageCircleIcon
            className={cn(
              "size-5 transition-[opacity,rotate] duration-200 [grid-area:1/1]",
              open && "rotate-90 opacity-0",
            )}
          />
          <XIcon
            className={cn(
              "size-5 transition-[opacity,rotate] duration-200 [grid-area:1/1]",
              !open && "-rotate-90 opacity-0",
            )}
          />
        </span>

        {unread > 0 && !open && (
          <span
            className={cn(
              mono,
              "bg-background text-foreground absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full tabular-nums",
            )}
          >
            {unread}
          </span>
        )}
      </button>
    </div>
  );
}