Skip to content

Chat

hc-chat renders a conversation transcript as a plain <ol> in chronological DOM order — no column-reverse trick, which would reverse reading order for assistive tech. The root is a role="log" live region, so each complete message appended by the server is announced once. A message that is still streaming carries aria-busy="true", which defers its announcement: screen-reader users don’t hear token-by-token noise, they hear the finished message when the final swap removes the attribute. Following the newest message is not CSS’s job either — that’s installChatScroll().

  1. AB
    Show me the failing checks.
  2. Context switched to project “ops”.
  3. Two checks are red:

    lint: ok
    unit: FAIL

Give the list a max-block-size (or size the .hc-chat container) so it scrolls. The avatar and the __meta row are optional — a message without an avatar collapses the first grid column. The __body holds server-rendered HTML; code blocks inside it are plain hc-code markup.

data-role on a message decides its alignment and bubble color:

data-roleMeaningPresentation
userWhat the person typed.Accent bubble, aligned inline-end.
assistantThe reply.Muted bubble, aligned inline-start.
systemContext notes — joins, mode switches.Centered muted line, no bubble.

Alignment uses logical properties, so RTL flips for free. The role is visual shorthand only — the message text (or the avatar’s label) should make the speaker clear without color.

While a reply is being generated, mark the message data-state="streaming" and aria-busy="true". The stylesheet appends a caret after the body — pure CSS, a ::after pseudo-element, with the blink animation removed under prefers-reduced-motion. When the final content arrives, the swap that replaces the message drops both attributes.

  1. The unit failure is
  2. Generation failed — connection lost.

data-state="error" tints the bubble via the shared status tokens — use it when a stream aborts or a message fails to send.

A transcript should follow the newest message while the reader is at the bottom — and stop following the moment they scroll up to re-read. installChatScroll() does exactly that:

  • On install, every .hc-chat list is pinned to the bottom.
  • New content re-pins the list — a MutationObserver watches childList and characterData, so streamed text chunks count — but only while pinned.
  • Scrolling more than ~24 px above the bottom releases the pin; scrolling back down re-arms it.
  • The pin state is reflected as data-stuck="true|false" on the .hc-chat root. The stylesheet shows the .hc-chat__jump button only when it is "false", and a click on it re-pins. Without the behavior the attribute is absent and the button stays hidden.
<div class="hc-chat" role="log" aria-label="Conversation">
<ol class="hc-chat__list" tabindex="0" style="max-block-size:24rem">
</ol>
<button class="hc-chat__jump hc-button" data-size="sm" type="button">↓ Latest</button>
</div>
import { installChatScroll } from '@hypermedia-components/core';
installChatScroll();

installChatScroll() is idempotent and returns an uninstaller. The zero-config @hypermedia-components/core/behaviors entry auto-installs it and picks up content swapped in by htmx. It never touches the network — scroll pinning is its whole job.

Messages arrive as server-rendered <li class="hc-chat__message"> fragments appended to the list with beforeend:

<form data-hx-post="/chat/messages"
data-hx-target="#chat-log" data-hx-swap="beforeend">
<input class="hc-input" type="text" name="message" aria-label="Message">
<button class="hc-button" data-variant="primary" type="submit">Send</button>
</form>
<div class="hc-chat" role="log" aria-label="Conversation">
<ol class="hc-chat__list" tabindex="0" id="chat-log"></ol>
</div>

The chat-messages recipe owns the composer round trip (append the user message + the aria-busy placeholder from one POST). For the push channel — the assistant’s reply arriving from the server — see the sse-updates recipe; the streaming-response recipe owns the reply contract (chunk / done / error).

  • The root is role="log" (the role is not allowed on <ol>; the list keeps plain list semantics and tabindex="0" so the scrollable region is keyboard-reachable) — appended complete messages are announced politely, without moving focus. Name the conversation with aria-label on the .hc-chat (and keep the list an <ol>: order is meaning here).
  • The streaming message carries aria-busy="true", deferring its announcement until the final swap removes it — one announcement per message, not one per token. The caret is a CSS pseudo-element, purely visual.
  • DOM order is chronological — the kit never uses column-reverse, which reverses reading order for assistive tech.
  • The jump control is a real <button type="button"> — focusable and keyboard-operable whenever it is shown.

Component tokens (in component.tokens.json):

Token pathPurpose
chat.gapList padding and gap between messages.
chat.bubble-radius / bubble-max-inlineBubble corner radius / message max width.
chat.bubble-padding-block / bubble-padding-inlineBubble padding.
chat.user-bg / user-fgUser bubble.
chat.assistant-bg / assistant-fgAssistant bubble.
chat.system-fgSystem line text.
chat.meta-fgMeta row (timestamp) text.
chat.caret-colorStreaming caret.
chat.error-bg / error-fgError-state bubble.
Show the generated CSS variables
--hc-chat-gap
--hc-chat-bubble-radius | -bubble-max-inline | -bubble-padding-block | -bubble-padding-inline
--hc-chat-user-bg | -user-fg
--hc-chat-assistant-bg | -assistant-fg
--hc-chat-system-fg | -meta-fg | -caret-color
--hc-chat-error-bg | -error-fg
  • Timeline — history as a list, when the events aren’t a dialogue.
  • Avatar — the speaker identity in a message’s first column.
  • Code — code blocks inside message bodies.
  • Toast — transient notifications that don’t belong in the transcript.
  • Scroll area — the same thin themed scrollbars for non-chat regions.