Skip to content
design
Components

Chat bubble

One chat message, set apart by who wrote it.

On this page
Marta Lindqvist took the watch. Anchor watch armed, swing radius 45 m.
How much water do we have under the keel, and is the tide falling?
4.8 meters under the keel, and the tide is falling. Low water at Morgat is at 04:52 with 1.2 m, so expect about 3.1 m under the keel then. That keeps you above your 2.5 m safety depth all night.
Any traffic heading into the bay?
Two AIS targets within 6 nm:
Voice replies are quiet until 06:00 while the off watch sleeps.
A night watch at anchor: the crew's questions, Bosun's answers and the system's notices.
Code
import { ChatBubble } from "@bosunkit/components/chat-bubble";
import { IconButton } from "@bosunkit/components/icon-button";
import { Copy, RotateCcw, Volume2 } from "lucide-react";
import { useEffect, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A night watch at anchor: system notices, the crew's questions in bubbles and Bosun's answers as plain text with " +
  "their line breaks, the last answer arriving word by word with a caret, then taking its time and its actions.";

const STREAMED_ANSWER: string =
  "Two AIS targets within 6 nm:\n" +
  "• MARIA K, cargo, 4.2 nm west, heading 095° at 11 kn, closest approach 1.4 nm at 02:38.\n" +
  "• AR VRO, fishing, 2.1 nm south, stopped with nets out.\n" +
  "Neither comes inside your 1 nm guard ring. I'll call you if either one changes course toward us.";

const WORDS: readonly string[] = STREAMED_ANSWER.split(/(?<=\s)/);
const WORD_INTERVAL_MILLISECONDS: number = 90;
/** The answer's first line is in place when the example mounts, so the bubble never renders empty. */
const FIRST_WORDS: number = 6;

function ignore(): void {
  // Nothing is pending once the answer is complete.
}

export default function Showcase(): ReactElement {
  const [shown, setShown]: [number, Dispatch<SetStateAction<number>>] = useState<number>(FIRST_WORDS);

  // One more word every 90 ms until the answer is complete; the pending timeout goes with the example.
  useEffect((): (() => void) => {
    if (shown >= WORDS.length) {
      return ignore;
    }
    const timer: number = window.setTimeout((): void => {
      setShown(shown + 1);
    }, WORD_INTERVAL_MILLISECONDS);
    return (): void => {
      window.clearTimeout(timer);
    };
  }, [shown]);

  const finished: boolean = shown >= WORDS.length;
  const actions: ReactElement = (
    <>
      <IconButton variant="ghost" label="Copy" icon={<Copy />} />
      <IconButton variant="ghost" label="Read aloud" icon={<Volume2 />} />
      <IconButton variant="ghost" label="Ask again" icon={<RotateCcw />} />
    </>
  );

  return (
    <div
      role="log"
      aria-label="Conversation with Bosun"
      style={{ width: "100%", display: "flex", flexDirection: "column", gap: "var(--space-md)" }}
    >
      <ChatBubble role="system">
        Marta Lindqvist took the watch. Anchor watch armed, swing radius 45 m.
      </ChatBubble>
      <ChatBubble role="user" author="Marta Lindqvist" timestamp="02:14 UTC">
        How much water do we have under the keel, and is the tide falling?
      </ChatBubble>
      <ChatBubble role="assistant" author="Bosun" timestamp="02:14 UTC" actions={actions}>
        {"4.8 meters under the keel, and the tide is falling.\n" +
          "Low water at Morgat is at 04:52 with 1.2 m, so expect about 3.1 m under the keel then. " +
          "That keeps you above your 2.5 m safety depth all night."}
      </ChatBubble>
      <ChatBubble role="user" author="Marta Lindqvist" timestamp="02:16 UTC">
        Any traffic heading into the bay?
      </ChatBubble>
      <ChatBubble
        role="assistant"
        author="Bosun"
        timestamp={finished ? "02:17 UTC" : undefined}
        actions={finished ? actions : undefined}
        latest
        streaming={!finished}
      >
        {WORDS.slice(0, shown).join("")}
      </ChatBubble>
      <ChatBubble role="system">Voice replies are quiet until 06:00 while the off watch sleeps.</ChatBubble>
    </div>
  );
}

A chat bubble is one message in a conversation, laid out as a chat assistant lays one out. Its role says who wrote it: the crew's message sits in a bubble at the end of the column, no wider than a comfortable line; Bosun's answer is plain text across the column, its lines kept to a readable length; and a system notice is centered and quiet. A message keeps the line breaks of its text, and paragraphs, lists and code the caller renders inside it take the column's spacing.

Under the message a line carries its timestamp, as the caller writes it with its reference, and an answer's actions: the caller's icon buttons, such as Copy, Read aloud and Ask again. They show while the answer has the pointer or the focus, always on a touch screen, and always on the latest answer. While an answer is streaming, a caret follows its text, drawn once and never blinking, and the message is busy to a screen reader.

Each message is an article named by its author, which is not drawn: the side and the shape tell the crew who wrote it. The caller's list of messages carries role="log", so a screen reader announces each message as it arrives.

Examples

System

Ask me anything about the vessel, route, weather, or instruments.
A system notice is centered and quiet, in the caption type, with no timestamp.
Code
import { ChatBubble } from "@bosunkit/components/chat-bubble";
import type { ReactElement } from "react";

export const description: string = "A system notice is centered and quiet, in the caption type, with no timestamp.";

export default function System(): ReactElement {
  return <ChatBubble role="system">Ask me anything about the vessel, route, weather, or instruments.</ChatBubble>;
}

User

What is the depth?
The crew's message sits in a bubble at the end of the column, with the time it was written under it.
Code
import { ChatBubble } from "@bosunkit/components/chat-bubble";
import type { ReactElement } from "react";

export const description: string = "The crew's message sits in a bubble at the end of the column, with the time it was written under it.";

export default function User(): ReactElement {
  return (
    <ChatBubble role="user" author="Marta Lindqvist" timestamp="14:05 UTC">
      What is the depth?
    </ChatBubble>
  );
}

Assistant

12.4 meters under the keel.
Bosun's answer is plain text across the column, with its time and its actions on a line under it.
Code
import { ChatBubble } from "@bosunkit/components/chat-bubble";
import { IconButton } from "@bosunkit/components/icon-button";
import { Copy, RotateCcw, Volume2 } from "lucide-react";
import type { ReactElement } from "react";

export const description: string = "Bosun's answer is plain text across the column, with its time and its actions on a line under it.";

export default function Assistant(): ReactElement {
  return (
    <ChatBubble
      role="assistant"
      author="Bosun"
      timestamp="14:05 UTC"
      latest
      actions={
        <>
          <IconButton variant="ghost" label="Copy" icon={<Copy />} />
          <IconButton variant="ghost" label="Read aloud" icon={<Volume2 />} />
          <IconButton variant="ghost" label="Ask again" icon={<RotateCcw />} />
        </>
      }
    >
      12.4 meters under the keel.
    </ChatBubble>
  );
}

Motion

By default a bubble rises in under the last when the caller adds it inside a MotionPresence, and fades out when removed; the messages there on the first render do not move. motion takes the kit's motion prop: true does the same outside a presence, and false turns it off. The message is readable from the first frame, and under reduced motion it only fades in.

6.2 m under the keel and shoaling.
A conversation in a MotionPresence: by default each new answer rises in under the last, readable from its first frame, and the answer on screen at first does not move.
Code
import { Button } from "@bosunkit/components/button";
import { ChatBubble } from "@bosunkit/components/chat-bubble";
import { MotionPresence } from "@bosunkit/components/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A conversation in a MotionPresence: by default each new answer rises in under the last, readable from its first " +
  "frame, and the answer on screen at first does not move.";

const ANSWERS: readonly string[] = [
  "6.2 m under the keel and shoaling.",
  "Wind 18 kn from the south-west, gusting 24.",
  "High water at Falmouth at 14:12 UTC.",
];

export default function Motion(): ReactElement {
  const [count, setCount]: [number, Dispatch<SetStateAction<number>>] = useState<number>(1);
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)", width: "min(100%, 420px)" }}>
      <MotionPresence initial={false}>
      {ANSWERS.slice(0, count).map(
        (answer: string): ReactElement => (
          <ChatBubble key={answer} role="assistant">
            {answer}
          </ChatBubble>
        ),
      )}
      </MotionPresence>
      <Button
        size="sm"
        disabled={count === ANSWERS.length}
        onClick={(): void => {
          setCount((current: number): number => Math.min(current + 1, ANSWERS.length));
        }}
      >
        Next answer
      </Button>
    </div>
  );
}

Props

PropTypeDefaultDescription
rolerequiredChatRole–Who wrote the message: the crew (user), Bosun (assistant) or the system.
childrenrequiredReactNode–The message: text, whose line breaks are kept, or paragraphs, lists and code the caller renders.
authorstring | undefined–Who wrote it, as a screen reader names the message ("Bosun", "Marta Lindqvist"), translated by the caller. It is not drawn: the side and the shape of a message tell the crew who wrote it.
timestampstring | undefined–When the message was written, as the caller displays it, with its time reference.
actionsReactNode | undefined–What the crew can do with the message, such as Copy, Read aloud or Ask again, as the caller's icon buttons: a row under it that shows while the message has the pointer or the focus, on a touch screen always, and always on the latest answer.
latestboolean | undefinedfalseWhether this is the conversation's latest answer, whose actions always show.
streamingboolean | undefinedfalseWhether the answer is still arriving: a caret follows its text, and it is busy to a screen reader.
classNamestring | undefined–Classes added to the message's own.
motionMotionProp | undefined–How the message moves. By default it rises in as it arrives and fades out when removed, inside a MotionPresence, where the caller adds the conversation's messages; the messages there on the first render do not move. true does the same outside a presence too; a preset (fade, rise, scale) or { enter, exit } chooses; false turns it off. The message is readable from the first frame.
refRef<HTMLElement> | undefined–The message element.
  • Chat input: the field under the conversation, and a log of messages above it.
  • Icon button: the actions under an answer.