Susegad UI
Register
Theme
Palette

Components

Chat thread

A conversation as a list of messages a screen reader can read, instead of a screenshot. Phone numbers, emails and names are taken out at build time and replaced by a marker that says what was removed.

quiet warm playful

Open the live demo npx susegad add chat-thread

Stands on: Core, Core: components, Engine, Tokens

The prompt

the prompt

A conversation shown as text instead of a screenshot: a client's messages on a case study, a support transcript, what guests asked before they booked.

A screenshot of a chat is a picture of words: a screen reader can't read it, it can't be searched, and the phone numbers in it are there for anyone to zoom into. This is the same conversation as a list.

<link rel="stylesheet" href="susegad/components/chat-thread/chat-thread.css">
<script type="module" src="susegad/components/chat-thread/chat-thread.js"></script>

<sg-chat-thread>
  <ol aria-label="A conversation with Aldona Organics">
    <li class="sg-chat-day"><time datetime="2026-03-04">4 March 2026</time></li>
    <li data-from="them"><span class="sg-chat-who">Maya, Aldona Organics</span>
      <p class="sg-chat-text">Can we move the shoot to Friday?</p>
      <time class="sg-chat-time" datetime="2026-03-04T10:12">10:12</time></li>
    <li data-from="me" data-status="read"><span class="sg-chat-who">Studio</span>
      <p class="sg-chat-text">Friday works.</p>
      <time class="sg-chat-time" datetime="2026-03-04T10:31">10:31</time></li>
  </ol>
</sg-chat-thread>

The prompt

Make a web component that shows a chat conversation as real text. The markup is an ordered list with a name; each message is a list item with the sender, the words and a <time>, and a date divider is a list item holding a date. Without JavaScript it must read as a ruled transcript. Take phone numbers, emails and names out before the HTML exists, in a pure function a build step calls, and put a visible marker in their place that says what was removed, so the original text is never in the page. Give it three registers. Quiet: a court record, every message on its own row, the speaker in small capitals, the time at the end, hairline rules between rows; on a narrow screen the speaker and time share a line above the words. Warm: speech on paper, each message in a hand-inked bubble with a small tail on the speaker's side, drawn twice like a nib going round, fitted to the message's measured size. Playful: bright bubbles, mine in the accent colour, theirs in the highlighter, and each one bounces a little as it scrolls into view, once, never under reduced motion. In a run of messages from one person, hide the repeated name from the eye only. Show sent, delivered and read ticks as decoration only. A reply quotes the earlier message inside its bubble as a blockquote that begins with visually hidden words, "Replying to:", then who said it and two lines of what they said: in quiet an indented quote with a ruled edge, in warm a snippet in a pencil bracket, in playful a tinted inset with an accent bar. A reaction is one emoji in a small chip, role="img" with a label such as "Reacted with a heart" and the emoji itself hidden, so it is read once as words; in quiet it sits under the words, in warm and playful on the bubble's lower edge on the speaker's side, with room kept under the bubble so it never covers the next message.

Words to code

When you sayTechniqueWhat happens
an ordered list with a namenative first<ol aria-label>; the element names it from label (or "Conversation") when the markup doesn't.
before the HTML existsbuild-time redactionredact(), redactHtml() and renderThread() in chat-thread.core.js return runs where a redacted run keeps only its kind. The original characters are dropped in Node, so no CSS or script can reveal them. leakIn() warns in the browser if a marker still holds digits, an @ or a sentence.
a court recordCSS gridThree columns (speaker, words, time) with a hairline under each row; a container query folds it to two at 30rem.
hand-inked bubble … fitted to the measured sizenoise, SVGbubbleOutline(w, h, seed, { side, tail }) resamples a rounded rectangle with a tail every 5 px and pushes each point out by seeded Perlin noise; two passes, the second fainter. A ResizeObserver redraws only when a bubble's size changes.
bounces a little … onceWeb Animations, IntersectionObserverarrival(motion, order, from) returns keyframes (transform and opacity only) at full motion, null otherwise. Each message animates the first time it is 20% in view; a batch is staggered and capped.
hide the repeated name from the eye onlyvisually hiddenruns() marks the second message of a run data-run="continue"; warm and playful clip its name to 1 px, so it is still read aloud.
begins with visually hidden words, "Replying to:"a named quoteblockquote.sg-chat-reply with a clipped .sg-chat-sr span; the element adds it if the markup didn't. The check reads Chromium's accessibility tree and finds "Replying to: Maya, Aldona Organics Can we move…".
read once as wordsrole="img"reactionLabel(emoji); the emoji sits in an aria-hidden span, because Chromium exposes an image's text children. The check counts one "Reacted with a heart" and no emoji text.
never covers the next messagelayoutli:has(> .sg-chat-reaction) { margin-bottom }; the check compares the chip's box with every other message and its own words, time and quote at 1280 and 390 px.
ticks as decoration onlyCSS maskdata-status="sent|delivered|read" draws a tick on an empty ::after, with nothing for assistive technology to read.

Accessibility

  • A named ordered list; every message carries its sender's name in the DOM.
  • Redaction markers are text ("number removed"), so everyone learns the same thing.
  • Nothing in the thread is focusable except the builder's own links.
  • The bounce runs only at full motion (playful, no reduced motion) and never hides a message before it arrives.
  • Forced colours: bubbles get a system-colour border; ticks and redaction bars stay visible.

<sg-chat-thread> shows a conversation as a list of messages instead of a screenshot. It enhances an <ol>.

Use

Write the markup by hand, or build it (Astro frontmatter, a Node script) with renderThread(), which also redacts:

import { renderThread } from './susegad/components/chat-thread/chat-thread.core.js';

const html = renderThread([
  { day: '2026-03-04' },
  { from: 'them', who: 'Maya, Aldona Organics', text: 'Call Rohan on +91 98765 43210', time: '2026-03-04T10:12' },
  { from: 'me', who: 'Studio', text: 'Will do.', time: '2026-03-04T10:31', status: 'read' },
], { label: 'A conversation with Aldona Organics', redact: { names: ['Rohan'] } });

The page then holds "Call <span class="sg-redacted">name removed</span> on <span class="sg-redacted">number removed</span>". The original is gone before the HTML is written. Never redact in the browser: by then the text has already been sent.

Markup

ElementWhat it is
<ol aria-label>the conversation; one <li> per message or divider
<li class="sg-chat-day"><time datetime>a date divider
<li data-from="them|me" data-status="sent|delivered|read">a message; data-status draws ticks on my messages, decoration only
.sg-chat-whothe sender's name
.sg-chat-textthe words; one or more <p>
<time class="sg-chat-time" datetime>when it was sent
.sg-redactedwhat was taken out, in words
blockquote.sg-chat-replya reply's quote of an earlier message: <span class="sg-chat-sr">Replying to: </span><span class="sg-chat-reply-who">Maya</span> <span class="sg-chat-reply-text">…</span>, before the words
.sg-chat-reactiona reaction on this message: <span class="sg-chat-reaction" role="img" aria-label="Reacted with a heart"><span aria-hidden="true">❤️</span></span>, after the time

renderThread() writes both from reply: { who, text } and reaction: '❤️' (the reply is redacted like the words). If the markup leaves out the "Replying to:" words, the role, the label or the hidden emoji, the element adds them: reactionLabel() names ❤️ 👍 😂 🙏 😮 😢 🎉 🔥 and says "an emoji" for the rest. The emoji is hidden because Chromium otherwise exposes it as text under the image, so it would be read twice.

Anything else in a message (an <sg-voice-note>, an image with alt text) sits under the words.

Attributes

AttributeValuesDefault
labelthe list's name, if the <ol> has none"Conversation"
registerquiet, warm, playfulinherited

A server can append messages (htmx hx-swap="beforeend" on the <ol>); runs, bubbles and bounces follow.

Registers

LookMotion
quieta ruled transcript: speaker in small capitals, words, timenone
warmhand-inked bubbles on paper, a tail on the first of a runnone
playfulbright bubbles, mine in the accent coloura small bounce as each message comes into view, once
A reply's quoteA reaction
quietan indented quote with a ruled edgea small chip under the words
warma snippet in a pencil bracket, the name in the handa chip on the bubble's lower edge, on the speaker's side
playfula tinted inset with a bar in the accent colourthe same chip

A quote shows two lines of the earlier message; all of it is read aloud. A bubble with a reaction keeps room under it so the chip never sits on the next message.

Reduced motion: no bounce. Without JavaScript: the quiet transcript.

Core exports

redact(text, { phones, emails, names, labels }), redactHtml(), renderThread(items, { label, redact, register }), runs(), clockOf(), dayOf(), leakIn(), bubbleOutline(), arrival(), STRINGS.

The phone pattern takes a leading + with 8 to 15 digits, or 10 to 15 digits without one, so dates, times, prices and order numbers stay.

Accessibility

  • Every message names its sender in the DOM; a repeated name is hidden from the eye only.
  • Redaction markers say what was removed.
  • Contrast for the playful bubbles (text and soft text on the highlighter, on-accent on accent) is tested for every palette and theme.