Elements

100 / 122 · Thread

Mobile composer

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

Installation

npx shadcn@latest add "@assistant-ui/elements-mobile-composer"

Usage

import { MobileComposer } from "@/components/elements/mobile-composer";

<MobileComposer
  value={value}
  keyboardOpen={keyboardOpen}
  running={false}
  actions={actions}
  onValueChange={setValue}
  onSend={send}
/>

Props

value*stringCurrent input. The field is 16px so iOS does not zoom on focus.
keyboardOpen*booleanCollapses the quick actions and the home indicator inset when the keyboard is up.
running*booleanSwaps send for stop, and suppresses Return-to-send while a run is in flight.
actions*string[]Quick actions above the field, scrolled horizontally.
onAttach() => voidCalled from the attachment control. Omit it and the control renders disabled.
onAction(action: string) => voidCalled when a quick action is tapped. Omit it and the chips render disabled, since actions is required and the row would otherwise be inert.
onValueChange(value: string) => voidCalled as the message is typed.
onSend() => voidCalled to send.
onStop() => voidCalled to stop a run in flight.
onFocus() => voidCalled when the field takes focus; drive keyboardOpen from it.
classNamestringExtra classes merged onto the root.

Source

mobile-composer.tsx
"use client";

import { ArrowUpIcon, MicIcon, PlusIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, ghostButton, inkButton, mono } from "./surfaces";

export function MobileComposer({
  value,
  keyboardOpen,
  running,
  actions,
  onAction,
  onAttach,
  onValueChange,
  onSend,
  onStop,
  onFocus,
  className,
}: {
  value: string;
  keyboardOpen: boolean;
  running: boolean;
  actions: readonly string[];
  onAction?: (action: string) => void;
  onAttach?: () => void;
  onValueChange?: (value: string) => void;
  onSend?: () => void;
  onStop?: () => void;
  onFocus?: () => void;
  className?: string;
}) {
  return (
    <div
      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 shadow-[0_-8px_24px_-16px_rgba(0,0,0,0.25)]",
        keyboardOpen ? "pb-3" : "pb-6",
        className,
      )}
    >
      {!keyboardOpen && (
        <div className="fade-in animate-in -mx-3 flex gap-1.5 overflow-x-auto px-3 pb-0.5 duration-200">
          {actions.map((action) => (
            <button
              key={action}
              type="button"
              onClick={() => onAction?.(action)}
              disabled={!onAction}
              className={cn(
                "disabled:pointer-events-none",
                field,
                "text-foreground/60 shrink-0 rounded-full px-3 py-1.5 text-xs whitespace-nowrap",
              )}
            >
              {action}
            </button>
          ))}
        </div>
      )}

      <div className="flex items-end gap-2">
        <button
          type="button"
          aria-label="Add an attachment"
          onClick={onAttach}
          disabled={!onAttach}
          className={cn(
            ghostButton,
            field,
            "size-9 shrink-0 disabled:pointer-events-none disabled:opacity-30",
          )}
        >
          <PlusIcon className="size-4" />
        </button>

        <div
          className={cn(
            field,
            "flex min-w-0 flex-1 items-center gap-2 rounded-[18px] px-3 py-2",
          )}
        >
          <input
            value={value}
            onChange={(event) => onValueChange?.(event.target.value)}
            onFocus={onFocus}
            onKeyDown={(event) => {
              if (event.key !== "Enter" || event.shiftKey) return;
              if (event.nativeEvent.isComposing) return;
              event.preventDefault();
              if (!running && value !== "") onSend?.();
            }}
            placeholder="Message"
            aria-label="Message"
            className="text-foreground/85 placeholder:text-foreground/30 min-w-0 flex-1 bg-transparent text-[16px] outline-none"
          />
          {value === "" && (
            <MicIcon className="text-foreground/35 size-4 shrink-0" />
          )}
        </div>

        <button
          type="button"
          aria-label={running ? "Stop" : "Send"}
          onClick={running ? onStop : onSend}
          disabled={!running && value === ""}
          className={cn(
            inkButton,
            "flex size-9 shrink-0 items-center justify-center rounded-full disabled:pointer-events-none disabled:opacity-25",
          )}
        >
          {running ? (
            <SquareIcon className="size-3 fill-current" />
          ) : (
            <ArrowUpIcon className="size-4" />
          )}
        </button>
      </div>

      {!keyboardOpen && (
        <span
          aria-hidden
          className="bg-foreground/15 mx-auto h-1 w-28 rounded-full"
        />
      )}

      {keyboardOpen && (
        <span className={cn(mono, "text-foreground/25 text-center")}>
          return to send
        </span>
      )}
    </div>
  );
}