Marta Lindqvist took the watch. Anchor watch armed, swing radius 45 m.
Chat bubble
One chat message, set apart by who wrote it.
On this page
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
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
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
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| rolerequired | ChatRole | – | Who wrote the message: the crew (user), Bosun (assistant) or the system. |
| childrenrequired | ReactNode | – | The message: text, whose line breaks are kept, or paragraphs, lists and code the caller renders. |
| author | string | 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. |
| timestamp | string | undefined | – | When the message was written, as the caller displays it, with its time reference. |
| actions | ReactNode | 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. |
| latest | boolean | undefined | false | Whether this is the conversation's latest answer, whose actions always show. |
| streaming | boolean | undefined | false | Whether the answer is still arriving: a caret follows its text, and it is busy to a screen reader. |
| className | string | undefined | – | Classes added to the message's own. |
| motion | MotionProp | 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. |
| ref | Ref<HTMLElement> | undefined | – | The message element. |
Related
- Chat input: the field under the conversation, and a log of messages above it.
- Icon button: the actions under an answer.