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
Why did the deploy fail?

The health check on eu-west-1 timed out after 30 seconds.

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.

Conversation started from the deploy log of api-gateway.

Ada14:02

Can I roll back without dropping the queue?

Deploy assistant14:02

Yes. A rollback swaps the running image and leaves the queue workers alone:

  1. Pause new deploys for the service.
  2. Run equip rollback api-gateway --to 412.
  3. Watch the error rate settle before you resume.

Jobs already in flight finish on the old build. See the rollback runbook for the details.

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.

Summarise the incidents from last week.

Sending

Which regions are still on build 411?

Not sent.

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

Health check failed: GET /ready returned 503 after 30s.

The service starts slower than the probe allows. Raise the timeout in the deploy config:

health:
  path: /ready
  timeout: 60s
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

PropTypeDefaultWhat it does
from"user" | "assistant" | "system"noneWho wrote it. Sets alignment, look and the accessible name.
namestringnoneDisplay name shown above the message and used in the accessible name of assistant messages.
avatarReactNodenoneAn Avatar or icon beside user and assistant messages. Hidden from assistive tech.
timestringnoneTime, already formatted, e.g. "14:02".
status"sending" | "failed"noneDelivery state. Sending dims the text and sets aria-busy; failed shows a danger note.
retryReactNodenoneAction placed in the failure note, usually a Retry button.
childrenReactNodenoneThe message. Paragraphs, lists, code, links and quotes are styled.
...restComponentProps<"article">noneEvery native article attribute, including aria-label to override the name.
ChatMessageActionsComponentProps<"div">noneA row of small ghost buttons under a message. Pass Buttons or plain buttons as children.

Anatomy

root.eq-chat-messageAn article named "You said", "Assistant said" or "Note".
avatar.eq-chat-message-avatar
main.eq-chat-message-mainColumn holding meta, body and status.
meta.eq-chat-message-meta
name.eq-chat-message-name
time.eq-chat-message-time
body.eq-chat-message-bodyThe bubble for user messages, plain prose for the assistant.
status.eq-chat-message-statusThe word Sending while status is sending.
error.eq-chat-message-errorFailure note with the retry slot.
actions.eq-chat-message-actionsChatMessageActions, 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);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index