AI
Chat message
One turn in a conversation with an assistant: a user bubble, assistant prose or a quiet system note.
- Built on
<article>- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.5 kB
- Dependencies
- React
Code
import { ChatMessage } from "@/components/equip/chat-message";
export default function Demo() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8, width: 224 }}>
<ChatMessage from="user">Why did the deploy fail?</ChatMessage>
<ChatMessage from="assistant">
<p>
The health check on <code>eu-west-1</code> timed out after 30 seconds.
</p>
</ChatMessage>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-direction:column;gap:8px;width:224px">
<article aria-label="You said" class="eq-chat-message" data-from="user">
<div class="eq-chat-message-main">
<div class="eq-chat-message-body">Why did the deploy fail?</div>
</div>
</article>
<article aria-label="Assistant said" class="eq-chat-message" data-from="assistant">
<div class="eq-chat-message-main">
<div class="eq-chat-message-body">
<p>The health check on <code>eu-west-1</code> timed out after 30 seconds.</p>
</div>
</div>
</article>
</div>Examples
A conversation with actions
Each message is an article named "You said" or "Assistant said", so screen reader users can jump between turns.
Code
import { Button } from "@/components/equip/button";
import { ChatMessage, ChatMessageActions } from "@/components/equip/chat-message";
const copyIcon = (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="5.25" y="5.25" width="8" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.75 3.25v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
export default function Example() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16, maxWidth: 560 }}>
<ChatMessage from="system">Conversation started from the deploy log of api-gateway.</ChatMessage>
<ChatMessage from="user" name="Ada" time="14:02">
Can I roll back without dropping the queue?
</ChatMessage>
<ChatMessage from="assistant" name="Deploy assistant" time="14:02">
<p>Yes. A rollback swaps the running image and leaves the queue workers alone:</p>
<ol>
<li>Pause new deploys for the service.</li>
<li>
Run <code>equip rollback api-gateway --to 412</code>.
</li>
<li>Watch the error rate settle before you resume.</li>
</ol>
<p>
Jobs already in flight finish on the old build. See the <a href="#runbook">rollback runbook</a> for the details.
</p>
<ChatMessageActions>
<Button variant="ghost" size="s" lead={copyIcon}>
Copy
</Button>
<Button variant="ghost" size="s">
Regenerate
</Button>
</ChatMessageActions>
</ChatMessage>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-direction:column;gap:16px;max-width:560px">
<article aria-label="Note" class="eq-chat-message" data-from="system">
<div class="eq-chat-message-main">
<div class="eq-chat-message-body">Conversation started from the deploy log of api-gateway.</div>
</div>
</article>
<article aria-label="You said" class="eq-chat-message" data-from="user">
<div class="eq-chat-message-main">
<p class="eq-chat-message-meta">
<span class="eq-chat-message-name">Ada</span>
<span class="eq-chat-message-time">14:02</span>
</p>
<div class="eq-chat-message-body">Can I roll back without dropping the queue?</div>
</div>
</article>
<article aria-label="Deploy assistant said" class="eq-chat-message" data-from="assistant">
<div class="eq-chat-message-main">
<p class="eq-chat-message-meta">
<span class="eq-chat-message-name">Deploy assistant</span>
<span class="eq-chat-message-time">14:02</span>
</p>
<div class="eq-chat-message-body">
<p>Yes. A rollback swaps the running image and leaves the queue workers alone:</p>
<ol>
<li>Pause new deploys for the service.</li>
<li>Run <code>equip rollback api-gateway --to 412</code>.</li>
<li>Watch the error rate settle before you resume.</li>
</ol>
<p>Jobs already in flight finish on the old build. See the <a href="#runbook">rollback runbook</a> for the details.</p>
<div role="group" aria-label="Message actions" class="eq-chat-message-actions"><button type="button" class="eq-button" data-variant="ghost" data-size="s"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="5.25" y="5.25" width="8" height="8" rx="1.5" stroke="currentColor" stroke-width="1.5" /><path d="M10.75 3.25v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg><span class="eq-button-label">Copy</span></button><button type="button" class="eq-button" data-variant="ghost" data-size="s"><span class="eq-button-label">Regenerate</span></button></div>
</div>
</div>
</article>
</div>Sending and failed
A failed message keeps its text and offers the retry right beside the reason.
Code
import { Button } from "@/components/equip/button";
import { ChatMessage } from "@/components/equip/chat-message";
export default function Example() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16, maxWidth: 480 }}>
<ChatMessage from="user" status="sending">
Summarise the incidents from last week.
</ChatMessage>
<ChatMessage
from="user"
status="failed"
retry={
<Button variant="ghost" size="s">
Retry
</Button>
}
>
Which regions are still on build 411?
</ChatMessage>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-direction:column;gap:16px;max-width:480px">
<article aria-label="You said" class="eq-chat-message" data-from="user" data-status="sending" aria-busy="true">
<div class="eq-chat-message-main">
<div class="eq-chat-message-body">Summarise the incidents from last week.</div>
<p class="eq-chat-message-status">Sending</p>
</div>
</article>
<article aria-label="You said" class="eq-chat-message" data-from="user" data-status="failed">
<div class="eq-chat-message-main">
<div class="eq-chat-message-body">Which regions are still on build 411?</div>
<p class="eq-chat-message-error">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5" />
<path d="M8 4.75v3.75M8 11h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<span>Not sent.</span>
<button type="button" class="eq-button" data-variant="ghost" data-size="s">
<span class="eq-button-label">Retry</span>
</button>
</p>
</div>
</article>
</div>Code and quotes
Code
import { ChatMessage } from "@/components/equip/chat-message";
export default function Example() {
return (
<div style={{ maxWidth: 560 }}>
<ChatMessage from="assistant">
<blockquote>Health check failed: GET /ready returned 503 after 30s.</blockquote>
<p>The service starts slower than the probe allows. Raise the timeout in the deploy config:</p>
<pre>
<code>{`health:\n path: /ready\n timeout: 60s`}</code>
</pre>
</ChatMessage>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="max-width:560px">
<article aria-label="Assistant said" class="eq-chat-message" data-from="assistant">
<div class="eq-chat-message-main">
<div class="eq-chat-message-body">
<blockquote>Health check failed: GET /ready returned 503 after 30s.</blockquote>
<p>The service starts slower than the probe allows. Raise the timeout in the deploy config:</p>
<pre><code>health:
path: /ready
timeout: 60s</code></pre>
</div>
</div>
</article>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
from | "user" | "assistant" | "system" | none | Who wrote it. Sets alignment, look and the accessible name. |
name | string | none | Display name shown above the message and used in the accessible name of assistant messages. |
avatar | ReactNode | none | An Avatar or icon beside user and assistant messages. Hidden from assistive tech. |
time | string | none | Time, already formatted, e.g. "14:02". |
status | "sending" | "failed" | none | Delivery state. Sending dims the text and sets aria-busy; failed shows a danger note. |
retry | ReactNode | none | Action placed in the failure note, usually a Retry button. |
children | ReactNode | none | The message. Paragraphs, lists, code, links and quotes are styled. |
...rest | ComponentProps<"article"> | none | Every native article attribute, including aria-label to override the name. |
ChatMessageActions | ComponentProps<"div"> | none | A row of small ghost buttons under a message. Pass Buttons or plain buttons as children. |
Anatomy
| root | .eq-chat-message | An article named "You said", "Assistant said" or "Note". |
|---|---|---|
| avatar | .eq-chat-message-avatar | |
| main | .eq-chat-message-main | Column holding meta, body and status. |
| meta | .eq-chat-message-meta | |
| name | .eq-chat-message-name | |
| time | .eq-chat-message-time | |
| body | .eq-chat-message-body | The bubble for user messages, plain prose for the assistant. |
| status | .eq-chat-message-status | The word Sending while status is sending. |
| error | .eq-chat-message-error | Failure note with the retry slot. |
| actions | .eq-chat-message-actions | ChatMessageActions, a labelled group. |
Notes
- Each message is an article with a name, so screen reader users can move turn by turn with their article or landmark keys.
- Prose styles use zero-specificity selectors, so a Button, Citation or code block nested inside keeps its own look.
- Assistant prose is capped at 68 characters a line for comfortable reading; user bubbles at 80% of the column.
- The failure note is not a live region; announce a failed send yourself if it happens out of view.
- Renders on the server with no client JavaScript.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/chat-message.tsx86 lines
import type { ComponentProps, ReactNode } from "react";
import "./chat-message.css";
export type ChatMessageProps = ComponentProps<"article"> & {
/** Who wrote it. Sets alignment, look and the accessible name. */
from: "user" | "assistant" | "system";
/** Display name shown above the message, e.g. "Ada" or "Deploy assistant". */
name?: string;
/** An Avatar or icon shown beside user and assistant messages. */
avatar?: ReactNode;
/** Time, already formatted, e.g. "14:02". */
time?: string;
/** Delivery state of the message. */
status?: "sending" | "failed";
/** Action shown in the failure note, usually a "Retry" button. */
retry?: ReactNode;
};
function spokenName(from: ChatMessageProps["from"], name?: string) {
if (from === "user") return "You said";
if (from === "system") return "Note";
return `${name ?? "Assistant"} said`;
}
export function ChatMessage({
from,
name,
avatar,
time,
status,
retry,
className,
children,
...rest
}: ChatMessageProps) {
const showMeta = from !== "system" && (name != null || time != null);
return (
<article
aria-label={spokenName(from, name)}
{...rest}
className={["eq-chat-message", className].filter(Boolean).join(" ")}
data-from={from}
data-status={status}
aria-busy={status === "sending" || undefined}
>
{avatar != null && from !== "system" && (
<span className="eq-chat-message-avatar" aria-hidden="true">
{avatar}
</span>
)}
<div className="eq-chat-message-main">
{showMeta && (
<p className="eq-chat-message-meta">
{name != null && <span className="eq-chat-message-name">{name}</span>}
{time != null && <span className="eq-chat-message-time">{time}</span>}
</p>
)}
<div className="eq-chat-message-body">{children}</div>
{status === "sending" && <p className="eq-chat-message-status">Sending</p>}
{status === "failed" && (
<p className="eq-chat-message-error">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 4.75v3.75M8 11h.01" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<span>Not sent.</span>
{retry}
</p>
)}
</div>
</article>
);
}
export type ChatMessageActionsProps = ComponentProps<"div">;
export function ChatMessageActions({ className, ...rest }: ChatMessageActionsProps) {
return (
<div
role="group"
aria-label="Message actions"
{...rest}
className={["eq-chat-message-actions", className].filter(Boolean).join(" ")}
/>
);
}components/equip/chat-message.css256 lines
@layer equip {
.eq-chat-message {
display: flex;
align-items: flex-start;
gap: var(--eq-space-2);
min-width: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.55 var(--eq-font);
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring);
}
/* A new message lands rather than appears. */
@starting-style {
.eq-chat-message {
opacity: 0;
translate: 0 4px;
}
}
.eq-chat-message[data-from="user"] {
flex-direction: row-reverse;
}
.eq-chat-message[data-from="system"] {
justify-content: center;
}
.eq-chat-message-avatar {
flex: none;
display: inline-flex;
margin-block-start: 2px;
}
.eq-chat-message-main {
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-chat-message[data-from="user"] .eq-chat-message-main {
align-items: flex-end;
max-inline-size: 80%;
}
.eq-chat-message[data-from="assistant"] .eq-chat-message-main {
max-inline-size: 68ch;
}
.eq-chat-message-meta {
display: flex;
align-items: baseline;
gap: var(--eq-space-2);
margin: 0;
font-size: var(--eq-text-xs);
line-height: 1.3;
color: var(--eq-ink-2);
}
.eq-chat-message-name {
font-weight: var(--eq-weight-strong);
color: var(--eq-ink);
}
.eq-chat-message-time {
font-variant-numeric: tabular-nums;
color: var(--eq-ink-3);
}
.eq-chat-message-body {
min-width: 0;
overflow-wrap: anywhere;
}
.eq-chat-message[data-from="user"] .eq-chat-message-body {
padding: var(--eq-space-2) var(--eq-space-3);
border-radius: var(--eq-radius-l);
border-end-end-radius: var(--eq-radius-s);
background: var(--eq-tint-strong);
}
.eq-chat-message[data-from="system"] .eq-chat-message-body {
max-inline-size: 52ch;
text-align: center;
font-size: var(--eq-text-s);
color: var(--eq-ink-2);
}
/* Dimmed with a quieter ink rather than opacity, so the text keeps its contrast. */
.eq-chat-message[data-status="sending"] .eq-chat-message-body {
color: var(--eq-ink-2);
}
.eq-chat-message[data-status="failed"][data-from="user"] .eq-chat-message-body {
box-shadow: inset 0 0 0 1px var(--eq-danger);
}
.eq-chat-message-status,
.eq-chat-message-error {
display: flex;
align-items: center;
gap: var(--eq-space-1);
margin: 0;
font-size: var(--eq-text-xs);
line-height: 1.3;
}
.eq-chat-message-status {
color: var(--eq-ink-3);
}
.eq-chat-message-error {
color: var(--eq-danger);
}
.eq-chat-message-error > svg {
flex: none;
}
.eq-chat-message-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
margin-inline-start: calc(-1 * var(--eq-space-2));
color: var(--eq-ink-2);
}
.eq-chat-message[data-from="user"] .eq-chat-message-actions {
margin-inline: 0 calc(-1 * var(--eq-space-2));
}
/* Plain buttons passed in get the ghost look; parts like Button keep their own. */
:where(.eq-chat-message-actions > button:not([class])) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--eq-space-1);
min-width: var(--eq-h-s);
height: var(--eq-h-s);
padding: 0 var(--eq-space-2);
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: inherit;
font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
:where(.eq-chat-message-actions > button:not([class]):hover:not(:disabled)) {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
:where(.eq-chat-message-actions > button:not([class]):active:not(:disabled)) {
scale: var(--eq-press);
}
:where(.eq-chat-message-actions > button:not([class]):focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
:where(.eq-chat-message-actions > button:not([class]):disabled) {
opacity: 0.45;
cursor: not-allowed;
}
:where(.eq-chat-message-actions > button:not([class]) > svg) {
width: 16px;
height: 16px;
}
/* Prose. Zero specificity, so a nested part keeps its own look. */
:where(.eq-chat-message-body) > :where(:first-child) {
margin-block-start: 0;
}
:where(.eq-chat-message-body) > :where(:last-child) {
margin-block-end: 0;
}
:where(.eq-chat-message-body :is(p, ul, ol, pre, blockquote)) {
margin-block: 0 var(--eq-space-3);
}
:where(.eq-chat-message-body :is(ul, ol)) {
padding-inline-start: var(--eq-space-5);
}
:where(.eq-chat-message-body li + li) {
margin-block-start: var(--eq-space-1);
}
:where(.eq-chat-message-body li::marker) {
color: var(--eq-ink-3);
}
:where(.eq-chat-message-body a) {
color: var(--eq-ink);
text-decoration: underline;
text-decoration-color: var(--eq-line-strong);
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
transition: text-decoration-color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
:where(.eq-chat-message-body a:hover) {
text-decoration-color: currentColor;
}
}
:where(.eq-chat-message-body a:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
border-radius: 2px;
}
:where(.eq-chat-message-body :is(code, pre)) {
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-size: 0.9em;
}
:where(.eq-chat-message-body :not(pre) > code) {
padding: 0.1em 0.35em;
border-radius: var(--eq-radius-s);
background: var(--eq-tint-strong);
}
:where(.eq-chat-message-body pre) {
overflow-x: auto;
padding: var(--eq-space-3);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-m);
background: var(--eq-tint);
line-height: 1.5;
tab-size: 2;
}
:where(.eq-chat-message-body blockquote) {
margin-inline: 0;
padding-inline-start: var(--eq-space-3);
border-inline-start: 2px solid var(--eq-line-strong);
color: var(--eq-ink-2);
}
}