ComponentsAI

Flo composer

The one box for talking to Flo: a rounded island with the message, the files, attach, the mic and a round Send that turns into Stop while Flo answers.

Playground

Change the controls to try each prop. The code under the preview updates as you go and lists only the props you changed; copy it into your page.

States
import { FloComposer } from '@/ui/flo-composer';<FloComposer placeholder="Ask Flo, or drop a CV" />

Default

The panel composer: 15/24 text in a 16px-radius island with a soft shadow. The box grows with its text up to 160px, then scrolls. Enter sends and Shift+Enter breaks a line; Send stays off until there is something to send.

import { FloComposer } from '@/ui/flo-composer';
import { toast } from '@/ui/toast';

export default function FloComposerDefault() {
  return (
    <div className="w-full max-w-92">
      <FloComposer onSend={(message) => toast.add({ title: 'Sent to Flo', description: message })} />
    </div>
  );
}

Files

Pass onFiles and the clip, a drop and a paste all feed the same queue. Dragging files over the island shows "Drop files for Flo" on a dashed green edge. The chips sit inside the island above the text (Attachment chip, variant="composer"). Send waits while a file uploads. Past five files the clip turns off and extra files are refused in words. Try dropping a file.

  • Maya Chen CV.pdf184 KB
import { useState } from 'react';
import { AttachmentChip, AttachmentList } from '@/ui/attachment-chip';
import { FloComposer } from '@/ui/flo-composer';
import { toast } from '@/ui/toast';

type Pending = { key: string; name: string; bytes: number; status: 'uploading' | 'ready' | 'failed'; error?: string };
const limit = 20 * 1024 * 1024;

export default function FloComposerFiles() {
  const [files, setFiles] = useState<Pending[]>([
    { key: 'cv', name: 'Maya Chen CV.pdf', bytes: 188416, status: 'ready' },
  ]);
  // The app uploads each file as it lands; here a timer stands in for the upload.
  function add(chosen: File[]) {
    const added = chosen.map((file): Pending => {
      const tooBig = file.size > limit;
      return {
        key: crypto.randomUUID(),
        name: file.name,
        bytes: file.size,
        status: tooBig ? 'failed' : 'uploading',
        error: tooBig ? `Too big · ${Math.round(file.size / 1024 / 1024)} MB, limit 20 MB` : undefined,
      };
    });
    setFiles((before) => [...before, ...added]);
    setTimeout(() => setFiles((before) => before.map((file) => (file.status === 'uploading' ? { ...file, status: 'ready' } : file))), 1200);
  }
  return (
    <div className="w-full max-w-92">
      <FloComposer
        defaultValue="What does she lack for Halcyon?"
        onFiles={add}
        accept=".pdf,.docx,.png,.jpg,.csv,.xlsx"
        fileCount={files.length}
        uploading={files.some((file) => file.status === 'uploading')}
        attachments={
          files.length ? (
            <AttachmentList label="Files for Flo">
              {files.map(({ key, ...file }) => (
                <AttachmentChip key={key} variant="composer" {...file} onRemove={() => setFiles((before) => before.filter((other) => other.key !== key))} />
              ))}
            </AttachmentList>
          ) : null
        }
        onSend={(message) => {
          toast.add({ title: 'Sent to Flo', description: [message, ...files.filter((file) => file.status === 'ready').map((file) => file.name)].filter(Boolean).join(' · ') });
          setFiles([]);
        }}
      />
    </div>
  );
}

Flo working

While Flo answers, an empty box turns Send into Stop (a white square on ink); Esc stops too. You can keep typing: Send comes back, and the message waits at the end of the thread as FloQueuedMessage, "Sends when Flo finishes".

Add her to the Halcyon shortlist tooSends when Flo finishes
Flo is working · you can keep typing
import { useState } from 'react';
import { FloComposer, FloQueuedMessage } from '@/ui/flo-composer';

export default function FloComposerWorking() {
  const [working, setWorking] = useState(true);
  const [queued, setQueued] = useState(['Add her to the Halcyon shortlist too']);
  return (
    <div className="flex w-full max-w-92 flex-col gap-300">
      {queued.map((message, at) => (
        <FloQueuedMessage key={at}>{message}</FloQueuedMessage>
      ))}
      <FloComposer
        working={working}
        onStop={() => setWorking(false)}
        onSend={(message) => (working ? setQueued((before) => [...before, message]) : setWorking(true))}
      />
    </div>
  );
}

Listening

With voice, the mic sits beside Send. While it listens the box is read only and shows the words as they are heard, and the status line shows the listening mark and how to finish. The first session shows the privacy notice under the box. See Voice input.

Your voice is transcribed by Soniox. Neither Soniox nor Ascern keeps the audio.

Learn more (opens in a new tab)
0:00Listening · Enter to send, Esc to cancel
import { useState } from 'react';
import { FloComposer } from '@/ui/flo-composer';
import { VoiceNotice } from '@/ui/voice-input';

export default function FloComposerListening() {
  const [startedAt, setStartedAt] = useState<number | null>(() => Date.now());
  const [notice, setNotice] = useState(true);
  return (
    <div className="w-full max-w-92">
      <FloComposer
        value={startedAt ? 'Move Maya Chen to interview and remind me to call Sara on Friday' : ''}
        voice={{ listening: startedAt !== null, startedAt: startedAt ?? undefined, onToggle: () => setStartedAt((at) => (at ? null : Date.now())) }}
        notice={notice ? <VoiceNotice learnMoreHref="https://soniox.com" onDismiss={() => setNotice(false)} /> : null}
      />
    </div>
  );
}

Over the limit

A message longer than Flo reads (8,000 characters; 80 here) waits in the box with a red line saying how long it is, and Send stays off.

import { FloComposer } from '@/ui/flo-composer';

export default function FloComposerOverLimit() {
  return (
    <div className="w-full max-w-92">
      <FloComposer
        maxLength={80}
        defaultValue="Find every candidate we placed at Halcyon Bank in the last two years, then draft a note to each of their managers asking how they are settling in."
      />
    </div>
  );
}

Viewing a file

With a file open beside the chat, a chip says "Viewing Maya Chen CV.pdf" and the placeholder asks about it.

Viewing Maya Chen CV.pdf
import { FloComposer } from '@/ui/flo-composer';

export default function FloComposerViewing() {
  return (
    <div className="w-full max-w-92">
      <FloComposer viewing="Maya Chen CV.pdf" onFiles={() => {}} />
    </div>
  );
}

On the Flo page

size="page": 16/24 text, 32px buttons and a little more padding, in the page's 680px column.

import { FloComposer } from '@/ui/flo-composer';

export default function FloComposerPage() {
  return (
    <div className="w-full max-w-170">
      <FloComposer size="page" placeholder="Ask a follow-up" onFiles={() => {}} voice={{ listening: false, onToggle: () => {} }} />
    </div>
  );
}

Inline on a screen

Where Flo edits what is on screen, such as the CV review, the composer takes no files and has no Stop: while Flo works, Send shows a spinner.

import { useState } from 'react';
import { FloComposer } from '@/ui/flo-composer';

export default function FloComposerInline() {
  const [busy, setBusy] = useState(false);
  return (
    <div className="w-full max-w-130">
      <FloComposer
        placeholder="Tell Flo what to change in this CV"
        working={busy}
        onSend={() => {
          setBusy(true);
          setTimeout(() => setBusy(false), 1500);
        }}
      />
    </div>
  );
}

Send

Press Send, Enter or Replay: the text leaves the box and moves into the thread as your message. Press Reduced motion to see the fallback.

import { useRef, useState } from 'react';
import { FloComposer } from '@/ui/flo-composer';
import { FloUserMessage } from '@/ui/flo-message';
import { sendWithMorph } from '@/ui/motion';

const question = 'Who on the Northwind shortlist has not heard back this week?';

export default function FloComposerSend() {
  const [text, setText] = useState(question);
  const [sent, setSent] = useState<string[]>([]);
  const thread = useRef<HTMLDivElement>(null);
  const composer = useRef<HTMLFormElement>(null);
  const send = (message: string) =>
    sendWithMorph(composer.current?.querySelector('textarea') ?? null, () => {
      setSent((before) => [...before, message]);
      setText('');
    }, thread.current ?? document);
  return (
    <div className="flex h-72 w-full max-w-120 flex-col justify-end gap-200">
      <div ref={thread} className="flex flex-col gap-150">
        {sent.map((message, index) => (
          <FloUserMessage key={index}>{message}</FloUserMessage>
        ))}
      </div>
      <FloComposer ref={composer} value={text} onValueChange={setText} onSend={send} />
      <button
        type="button"
        hidden
        data-replay
        onClick={() => {
          setSent([]);
          setText(question);
          requestAnimationFrame(() => requestAnimationFrame(() => send(question)));
        }}
      />
    </div>
  );
}

On this page