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.
<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.
<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.
<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.
| 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
| 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: hiddenremoves 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-onlysender 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-dayis 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.
<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
.listor 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.