Chat

.chat is a scrolling thread of .msg rows, each holding a .bubble. The details worth knowing are the ones that make a run of messages read as a run: corner radii that flatten between consecutive bubbles from the same sender, and an avatar that is hidden rather than removed so the alignment holds.

When to use it

Use it for a conversation between two or more participants where the order is chronological and authorship matters: support threads, comments on a record, a messaging panel. For a list of notes with no back-and-forth, a .list is simpler.

Two from Ada, one from you — note the second avatar and the corner radii
Today
AC
Ada Chen
Did the nightly export finish?
09:12
AC
I can rerun it if not.
09:12
Finished at 04:12. 2,481 rows.
09:14
<div class="chat" style="max-block-size:22rem"> <div class="chat-day">Today</div> <div class="msg"> <span class="avatar">AC</span> <div class="bubble"> <div class="bubble-name">Ada Chen</div> Did the nightly export finish? <div class="bubble-meta">09:12</div> </div> </div> <div class="msg"> <span class="avatar">AC</span> <div class="bubble">I can rerun it if not.<div class="bubble-meta">09:12</div></div> </div> <div class="msg msg-out"> <div class="bubble">Finished at 04:12. 2,481 rows.<div class="bubble-meta">09:14</div></div> </div> </div>
<div class="chat" style="max-block-size:22rem">
  <div class="chat-day">Today</div>
  <div class="msg">
    <span class="avatar">AC</span>
    <div class="bubble">
      <div class="bubble-name">Ada Chen</div>
      Did the nightly export finish?
      <div class="bubble-meta">09:12</div>
    </div>
  </div>
  <div class="msg">
    <span class="avatar">AC</span>
    <div class="bubble">I can rerun it if not.<div class="bubble-meta">09:12</div></div>
  </div>
  <div class="msg msg-out">
    <div class="bubble">Finished at 04:12. 2,481 rows.<div class="bubble-meta">09:14</div></div>
  </div>
</div>

How a run of messages groups

Flattened corners

A second message from the same sender flattens the corner nearest the previous bubble, so the two read as one block rather than two separate statements:

.msg:not(:last-of-type) + .msg .bubble { border-start-start-radius: var(--r-xs); }
.msg-out:not(:last-of-type) + .msg-out .bubble { border-start-end-radius: var(--r-xs); }

Two rules, both logical corners — start-start for an incoming bubble, start-end for an outgoing one — so the flattening happens on the side facing the sender's own edge, in either writing direction.

The avatar that is hidden, not removed

src/24-media.css shrinks the avatar inside a message to 28px, aligns it to flex-end so it sits beside the last line of the bubble, and then:

.msg:not(:last-of-type) + .msg .avatar { visibility: hidden; }

visibility: hidden rather than display: none. The space is kept, so every bubble in the run stays aligned with the first — remove the element and the second message would slide 28px toward the edge. It is a one-word difference with a very visible consequence.

Incoming and outgoing

.msg is align-self: flex-start; .msg-out switches to flex-end and row-reverse, so the avatar moves to the other side of the bubble. Both are capped at min(80%, 34rem) so a long message does not run the full width of a wide thread.

Bubble parts

  • .bubble-name — the sender, in the brand colour on incoming messages and inherited on outgoing ones.
  • .bubble-meta — the timestamp and read state, aligned to the sender's edge.
  • .bubble-attachment — a full-bleed image or file inside the bubble.
  • .bubble-system — a centred, unattributed notice: "Ada joined the conversation".
  • .bubble-typing — the three-dot indicator.
  • .chat-day — a centred date pill separating one day from the next.
A system notice, an attachment and a typing indicator
Yesterday
Marco joined the conversation
MS
Marco Silva
Here is the failing run.
A screenshot of the failed run
17:03
MS
<div class="chat" style="max-block-size:20rem"> <div class="chat-day">Yesterday</div> <div class="bubble bubble-system">Marco joined the conversation</div> <div class="msg"> <span class="avatar">MS</span> <div class="bubble"> <div class="bubble-name">Marco Silva</div> Here is the failing run. <figure class="bubble-attachment"> <img alt="A screenshot of the failed run" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22480%22 height=%22200%22%3E%3Crect width=%22480%22 height=%22200%22 fill=%22%23c7d6d4%22/%3E%3C/svg%3E"> </figure> <div class="bubble-meta">17:03</div> </div> </div> <div class="msg"> <span class="avatar">MS</span> <div class="bubble bubble-typing"><span></span><span></span><span></span></div> </div> </div>
<div class="chat" style="max-block-size:20rem">
  <div class="chat-day">Yesterday</div>
  <div class="bubble bubble-system">Marco joined the conversation</div>
  <div class="msg">
    <span class="avatar">MS</span>
    <div class="bubble">
      <div class="bubble-name">Marco Silva</div>
      Here is the failing run.
      <figure class="bubble-attachment">
        <img alt="A screenshot of the failed run" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22480%22 height=%22200%22%3E%3Crect width=%22480%22 height=%22200%22 fill=%22%23c7d6d4%22/%3E%3C/svg%3E">
      </figure>
      <div class="bubble-meta">17:03</div>
    </div>
  </div>
  <div class="msg">
    <span class="avatar">MS</span>
    <div class="bubble bubble-typing"><span></span><span></span><span></span></div>
  </div>
</div>

The composer

.chat-composer is the row at the bottom of a thread. Deck styles it and supplies nothing else — no send handling, no auto-grow beyond what .textarea already does, no draft persistence.

The textarea grows as you type — that is field-sizing, not the composer
<div class="chat-composer"> <label class="sr-only" for="dx-chat-in">Message</label> <textarea class="textarea" id="dx-chat-in" placeholder="Write a message"></textarea> <button class="btn btn-primary btn-icon" aria-label="Send"> <svg class="icon mirror-rtl" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#send"></use></svg> </button> </div>
<div class="chat-composer">
  <label class="sr-only" for="dx-chat-in">Message</label>
  <textarea class="textarea" id="dx-chat-in" placeholder="Write a message"></textarea>
  <button class="btn btn-primary btn-icon" aria-label="Send">
    <svg class="icon mirror-rtl" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#send"></use></svg>
  </button>
</div>

Classes

The chat component proper is three classes; .msg and .bubble are separate roots in the same file, documented here because a thread is all three together.

Classes, generated from the stylesheet source
Class Layer What it does Source
.chat deck.components Conversation view. Consecutive messages from one side group together and only the last one in a run gets the tail. 24-media.css:212
.chat-composer deck.components Composer pinned to the bottom 24-media.css:323
.chat-day deck.components A centred date pill between days. Styling, not structure — a screen reader hears ordinary text. 24-media.css:222
.msg deck.components One message row. .msg-out flips it to the other edge and reverses the row so the avatar swaps sides. The incoming/outgoing distinction is position and colour only, so name the sender in text for anyone not seeing it. 24-media.css:236
.msg-out deck.components Sets align-self, flex-direction. 24-media.css:243
.bubble deck.components The message body. Its corner radii are logical, which is what lets the grouping flatten the correct side in either writing direction. 24-media.css:247
.bubble-attachment deck.components Sets margin, border-start-start-radius, border-start-end-radius, overflow. 24-media.css:283
.bubble-meta deck.components Sets display, align-items, gap, margin-block-start, and 4 more. 24-media.css:269
.bubble-name deck.components Sets font-weight, font-size, margin-block-end, color. 24-media.css:280
.bubble-system deck.components Sets align-self, background, border, color, and 3 more. 24-media.css:294
.bubble-typing deck.components Typing indicator 24-media.css:305

Tokens

Design tokens this component reads
Token Live Declared value
--surface light-dark(var(--ink-0), var(--ink-950))
--surface-2 light-dark(var(--ink-25), var(--ink-900))
--brand light-dark(var(--brand-600), var(--brand-400))
--brand-600 oklch(51.6% var(--chroma-brand) var(--hue-brand))
--text-on-brand oklch(99% .01 var(--hue-brand))
--text-faint light-dark(var(--ink-500), var(--ink-500))
--text-xs .75rem
--r-full 999px
--r-xs 4px
--space-1 .25rem
--space-2 .5rem
--space-3 .75rem
--space-4 1rem

Accessibility

  • Deck supplies no semantics, and a thread needs some. The classes style <div>s. Use a <ul> of <li class="msg">, or an <article> per message, so the thread has structure rather than being a wall of text.
  • The hidden avatar is correctly hidden. visibility: hidden removes it from the accessibility tree as well as the screen, so a run of three messages is not announced as three identical avatars.
  • But the sender is then lost. Only the first bubble in a run carries .bubble-name. A screen-reader user reading the second and third messages has no attribution. Add .sr-only sender text to every message, or accept that the grouping is visual only.
  • Incoming and outgoing look different and sound identical. Position and colour carry the distinction, and neither reaches a screen reader. This is the most important thing to fix in a real thread: each message needs its sender named in text somewhere.
  • New messages need announcing. A live thread should have aria-live="polite" on the container — but on the container only, and not on a thread that loads a hundred messages at once, or every one is read aloud.
  • The typing indicator is decorative — three empty spans. It says nothing to a screen reader, which is arguably right, and means an .sr-only "Marco is typing" is needed if that information matters.
  • .chat-day is styling, not structure. Same as .list-header: it looks like a heading and is announced as ordinary text.
  • The composer's send button is icon-only and needs aria-label. Also consider that Enter-to-send in a <textarea> takes away the ability to write a second line — offer both, and say which is which.

Right to left

align-self: flex-start and flex-end follow the writing direction, so incoming messages sit on the right and outgoing on the left in an Arabic thread — which is correct, and needs no rule. The flattened corners are logical (border-start-start-radius), so they flatten on the correct side too.

The send icon is directional and needs .mirror-rtl, as in the composer example. Deck's icon set mirrors arrows, chevrons and #send, and leaves checkmarks and clocks alone.

Incoming on the right, outgoing on the left
ا
آدا تشين
هل اكتمل التصدير؟
٠٩:١٢
نعم، في الساعة ٠٤:١٢.
٠٩:١٤
<div dir="rtl" class="chat" style="max-block-size:16rem"> <div class="msg"> <span class="avatar">ا</span> <div class="bubble"><div class="bubble-name">آدا تشين</div>هل اكتمل التصدير؟<div class="bubble-meta">٠٩:١٢</div></div> </div> <div class="msg msg-out"> <div class="bubble">نعم، في الساعة ٠٤:١٢.<div class="bubble-meta">٠٩:١٤</div></div> </div> </div>
<div dir="rtl" class="chat" style="max-block-size:16rem">
  <div class="msg">
    <span class="avatar">ا</span>
    <div class="bubble"><div class="bubble-name">آدا تشين</div>هل اكتمل التصدير؟<div class="bubble-meta">٠٩:١٢</div></div>
  </div>
  <div class="msg msg-out">
    <div class="bubble">نعم، في الساعة ٠٤:١٢.<div class="bubble-meta">٠٩:١٤</div></div>
  </div>
</div>

Reduced motion

The typing indicator's three dots animate. Under prefers-reduced-motion: reduce the global reset collapses animations to a single .01ms iteration, so the dots stop — and since the indicator is decorative and unannounced, a reader with reduced motion is left with three static dots that mean nothing.

That is a real edge: the animation is the message. If a typing state matters, pair the dots with .sr-only text, which works regardless of motion preference.

Printing

A thread prints in full, and more care has gone into this than into most of Deck's print handling. A second @layer deck.print block at the end of src/24-media.css carries five rules for it:

.chat { overflow: visible; max-block-size: none; }
.chat-composer { display: none !important; }
.msg { break-inside: avoid; }
.bubble { border: 1px solid #bbb !important; background: #fff !important; color: #000 !important; }
.msg-out .bubble { background: #f2f2f2 !important; }

The first line is the important one: without it a printed thread would show only what was scrolled into view. The composer is dropped because you cannot type on paper, no message is split across a page boundary, and the bubbles are given explicit black-on-white borders — because backgrounds are dropped when printing, and a bubble with no background and no border is not a bubble at all.

The one thing left to you is the direction of the conversation: outgoing messages print on a light grey rather than in the brand colour, so who said what is carried by the alignment and the tail rather than by hue. On a long transcript, a name beside each run is worth more than either.

Overriding it

@layer app.components {
  /* Wider bubbles on a big screen */
  .msg { max-inline-size: min(70%, 44rem); }

  /* A quieter outgoing bubble */
  .msg-out .bubble { background: var(--surface-2); color: var(--text); }

  /* Sender name on every message rather than the first of a run */
  .msg:not(:last-of-type) + .msg .bubble-name { display: block; }
}

When not to use it

  • Not for a comment thread with replies. Bubbles imply a linear conversation. Nested replies need indentation and a tree, which this is not — use a .list or cards.
  • Not for a log. A stream of system events is a table or a list with timestamps. Bubbles imply authorship, and a log has none.
  • Not for two long messages. The bubble shape suits short turns. A thread of paragraphs is easier to read as plain blocks with a name above each.
  • Not without naming senders in text. The visual left/right distinction is invisible to a screen reader, and the grouping removes the name from all but the first message of a run.
  • Not as the archive. The panel scrolls and prints partially. If the conversation is a record, it needs a page of its own.