Connection lost. Reconnecting… attempt 1 of 8
Paused. Your work is held on the server.
Could not reconnect.
This session has expired on the server.
An unhandled error has occurred.
Sedna.UI
v0.17.0 · main

Chat

tier 2 — classes

A conversation: messages in bubbles, mine on the end and theirs on the start, with the day, the author, the time, what happened to the message and how people reacted to it — the composer under it, and the launcher that keeps a chat one tap away on every page. Every piece of state is the app's; the two things that need the platform use it rather than a script.

The thread is a log role="log" with aria-live="polite" on the .chat: a message that arrives is announced once, after whatever the reader is doing, and the history is one region to navigate. Give it an aria-label naming the conversation. Every icon — a status check, a reaction, an action — carries a name; the emoji in a reaction is not one.

A conversation

.chat-message is avatar and .chat-body; --mine puts it on the end with no avatar, --continued is the same author's next message and drops the .chat-meta above it, so a run reads as one turn. The .chat-bubble is on the neutral surface for theirs and the brand tint for mine, both in the page's own text colour. .chat-day and .chat-unread are lines across the thread; .chat-status is what happened to my message — one check sent, two delivered, --read in the brand — and .chat-bubble--failed with .chat-status--failed and a retry is one that did not go. .chat-typing is the three dots, with the words for a screen reader beside them, visually hidden.

Yesterday
AF
Alex Fischer
Can you reroute ORD-4209 to EU-West? Hamburg has no capacity until Thursday.
AF
Alex Fischer
The customer is fine with Friday.
Done — it leaves Rotterdam at 12:20 tomorrow.
edited
Today
New messages
AF
Alex Fischer
Thanks. Can you also confirm the label was reprinted?
Yes, both labels — the old one is void.
Not sent
AF
Alex Fischer is typing
<div class="card" style="max-width:560px">
    <div class="card-body">
        <div class="chat" role="log" aria-live="polite" aria-label="Chat with Alex Fischer">
            <div class="chat-day">Yesterday</div>

            <div class="chat-message">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-12T17:52">17:52</time></div>
                    <div class="chat-bubble">Can you reroute ORD-4209 to EU-West? Hamburg has no capacity until Thursday.</div>
                </div>
            </div>
            <div class="chat-message chat-message--continued">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-12T17:52">17:52</time></div>
                    <div class="chat-bubble">The customer is fine with Friday.</div>
                </div>
            </div>

            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">Done — it leaves Rotterdam at 12:20 tomorrow.</div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-12T18:03">18:03</time><span class="chat-edited">edited</span><i class="ri-check-double-line chat-status chat-status--read" aria-label="Read" role="img"></i></div>
                </div>
            </div>

            <div class="chat-day">Today</div>
            <div class="chat-unread">New messages</div>

            <div class="chat-message">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T09:41">09:41</time></div>
                    <div class="chat-bubble">Thanks. Can you also confirm the label was reprinted?</div>
                </div>
            </div>

            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble chat-bubble--failed">Yes, both labels — the old one is void.</div>
                    <div class="chat-meta">
                        <i class="ri-error-warning-line chat-status chat-status--failed" aria-label="Not sent" role="img"></i>
                        <span>Not sent</span>
                        <button class="btn-bare btn-bare--link" type="button">Retry</button>
                    </div>
                </div>
            </div>

            <div class="chat-message">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-bubble"><span class="chat-typing" aria-hidden="true"><i></i><i></i><i></i></span><span class="visually-hidden">Alex Fischer is typing</span></div>
                </div>
            </div>
        </div>
    </div>
</div>

A group

The same markup with more people: every message carries its avatar and .chat-author, a .chat-system line tells what the app did, a .chat-mention names someone, a .chat-quote at the top of a bubble is the message it answers, and .chat-seen after the last message is who has read up to here. Colour the avatars with .avatar--viz-1 to --viz-6, so four people are four colours and none of them is a semantic one.

Dispatch — EU-Central

4 members
Priya Nair added Tomás Ferreira
PN
Priya Nair
Carrier capacity in Hamburg is exhausted until Thursday. @Alex, can your team take the EU-West reroutes?
AF
Alex Fischer
Priya NairCarrier capacity in Hamburg is exhausted until Thursday… Yes — send them over, we have two slots on Friday's 12:20.
I will reroute ORD-4209, 4213 and 4250 now and post the labels here.
TF
Tomás Ferreira
4213 has a changed address — hold that one until the customer confirms.
Seen by PN AF
<div class="card" style="max-width:600px">
    <div class="card-head"><h3>Dispatch — EU-Central</h3><span class="badge">4 members</span></div>
    <div class="card-body">
        <div class="chat" role="log" aria-live="polite" aria-label="Dispatch — EU-Central">
            <div class="chat-system">Priya Nair added Tomás Ferreira</div>

            <div class="chat-message">
                <span class="avatar avatar-sm avatar--viz-2">PN</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Priya Nair</span><time class="chat-time" datetime="2026-09-13T08:12">08:12</time></div>
                    <div class="chat-bubble">Carrier capacity in Hamburg is exhausted until Thursday. <a class="chat-mention" href="#">&#64;Alex</a>, can your team take the EU-West reroutes?</div>
                    <div class="chat-reactions">
                        <button class="chat-reaction" type="button" aria-pressed="true" aria-label="Thumbs up, 3, including you">👍 3</button>
                        <button class="chat-reaction" type="button" aria-pressed="false" aria-label="Eyes, 1">👀 1</button>
                        <button class="chat-reaction" type="button" aria-label="Add a reaction"><i class="ri-emotion-line"></i></button>
                    </div>
                </div>
            </div>

            <div class="chat-message">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T08:15">08:15</time></div>
                    <div class="chat-bubble">
                        <span class="chat-quote"><strong>Priya Nair</strong>Carrier capacity in Hamburg is exhausted until Thursday…</span>
                        Yes — send them over, we have two slots on Friday's 12:20.
                    </div>
                </div>
            </div>

            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">I will reroute ORD-4209, 4213 and 4250 now and post the labels here.</div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-13T08:16">08:16</time><i class="ri-check-double-line chat-status" aria-label="Delivered" role="img"></i></div>
                </div>
            </div>

            <div class="chat-message">
                <span class="avatar avatar-sm avatar--viz-4">TF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Tomás Ferreira</span><time class="chat-time" datetime="2026-09-13T08:20">08:20</time></div>
                    <div class="chat-bubble">4213 has a changed address — hold that one until the customer confirms.</div>
                    <div class="chat-reactions">
                        <button class="chat-reaction" type="button" aria-pressed="false" aria-label="Check, 2">✅ 2</button>
                        <button class="chat-reaction" type="button" aria-label="Add a reaction"><i class="ri-emotion-line"></i></button>
                    </div>
                </div>
            </div>

            <div class="chat-seen">
                Seen by
                <span class="avatar-group">
                    <span class="avatar avatar-sm avatar--viz-2">PN</span>
                    <span class="avatar avatar-sm">AF</span>
                </span>
            </div>
        </div>
    </div>
</div>

Reactions

Live — open the picker. .chat-reactions under a bubble is a pill per emoji with its count, aria-pressed="true" on the ones that are mine and an aria-label that says the emoji's name and the count — a screen reader does not read “👍 3” as anything. The last pill adds one: it opens a .popover holding a .chat-emoji-grid of .chat-emoji buttons, each named. Which emoji the picker offers is the app's list; the platform's own picker is a keyboard away and needs no help.

<div class="sedna-col sedna-gap-3" style="max-width:560px">
    <div class="chat-reactions">
        <button class="chat-reaction" type="button" aria-pressed="true" aria-label="Thumbs up, 3, including you">👍 3</button>
        <button class="chat-reaction" type="button" aria-pressed="false" aria-label="Party, 2">🎉 2</button>
        <button class="chat-reaction" type="button" aria-pressed="false" aria-label="Eyes, 1">👀 1</button>
        <button class="chat-reaction" type="button" aria-label="Add a reaction" popovertarget="ex-emoji"><i class="ri-emotion-line"></i></button>
    </div>

    <div class="popover" id="ex-emoji" popover aria-label="Pick a reaction">
        <div class="chat-emoji-grid">
            <button class="chat-emoji" type="button" aria-label="Thumbs up">👍</button>
            <button class="chat-emoji" type="button" aria-label="Heart">❤️</button>
            <button class="chat-emoji" type="button" aria-label="Laughing">😂</button>
            <button class="chat-emoji" type="button" aria-label="Surprised">😮</button>
            <button class="chat-emoji" type="button" aria-label="Sad">😢</button>
            <button class="chat-emoji" type="button" aria-label="Party">🎉</button>
            <button class="chat-emoji" type="button" aria-label="Eyes">👀</button>
            <button class="chat-emoji" type="button" aria-label="Check">✅</button>
            <button class="chat-emoji" type="button" aria-label="Fire">🔥</button>
            <button class="chat-emoji" type="button" aria-label="Thinking">🤔</button>
            <button class="chat-emoji" type="button" aria-label="Clap">👏</button>
            <button class="chat-emoji" type="button" aria-label="Pray">🙏</button>
            <button class="chat-emoji" type="button" aria-label="Rocket">🚀</button>
            <button class="chat-emoji" type="button" aria-label="Hundred">💯</button>
            <button class="chat-emoji" type="button" aria-label="Warning">⚠️</button>
            <button class="chat-emoji" type="button" aria-label="Plus one">➕</button>
        </div>
    </div>
</div>

Actions on a message

Live — hover a message, or tab into it. .chat-actions is a row of ghost icon buttons in the gap above the bubble's far corner, shown on hover and whenever anything in the message has focus, so the keyboard reaches it. React and reply are buttons; more is a .menu with data-menu-toggle, and the destructive item is .menu-item--danger. On a phone, where there is no hover, a long press on the bubble is the app's way in — it opens the same menu.

AF
Alex Fischer
Thanks. Can you also confirm the label was reprinted?
Yes, both labels — the old one is void.
<div class="card" style="max-width:560px">
    <div class="card-body">
        <div class="chat" role="log" aria-label="Chat with Alex Fischer">
            <div class="chat-message">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T09:41">09:41</time></div>
                    <div class="chat-bubble">Thanks. Can you also confirm the label was reprinted?</div>
                </div>
                <div class="chat-actions" role="toolbar" aria-label="Message actions">
                    <button class="btn btn-sm btn-ghost btn-icon" type="button" aria-label="React"><i class="ri-emotion-line"></i></button>
                    <button class="btn btn-sm btn-ghost btn-icon" type="button" aria-label="Reply"><i class="ri-reply-line"></i></button>
                    <span class="menu-anchor">
                        <button class="btn btn-sm btn-ghost btn-icon" type="button" aria-label="More" aria-expanded="false" data-menu-toggle><i class="ri-more-2-fill"></i></button>
                        <div class="menu" hidden>
                            <button class="menu-item" type="button"><i class="ri-file-copy-line"></i> Copy text</button>
                            <button class="menu-item" type="button"><i class="ri-pushpin-line"></i> Pin in this chat</button>
                            <button class="menu-item" type="button"><i class="ri-flag-line"></i> Report</button>
                        </div>
                    </span>
                </div>
            </div>

            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">Yes, both labels — the old one is void.</div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-13T09:44">09:44</time><i class="ri-check-double-line chat-status chat-status--read" aria-label="Read" role="img"></i></div>
                </div>
                <div class="chat-actions" role="toolbar" aria-label="Message actions">
                    <button class="btn btn-sm btn-ghost btn-icon" type="button" aria-label="React"><i class="ri-emotion-line"></i></button>
                    <span class="menu-anchor">
                        <button class="btn btn-sm btn-ghost btn-icon" type="button" aria-label="More" aria-expanded="false" data-menu-toggle><i class="ri-more-2-fill"></i></button>
                        <div class="menu menu--start" hidden>
                            <button class="menu-item" type="button"><i class="ri-pencil-line"></i> Edit</button>
                            <button class="menu-item" type="button"><i class="ri-file-copy-line"></i> Copy text</button>
                            <button class="menu-item menu-item--danger" type="button"><i class="ri-delete-bin-line"></i> Delete for everyone</button>
                        </div>
                    </span>
                </div>
            </div>
        </div>
    </div>
</div>

The composer

.chat-composer is what is being answered, what is attached, the field and the send. .chat-composer-reply quotes the message being replied to with a way to stop; .chat-composer-files is the pending attachments as chips, each removable; the .chat-composer-row holds the .form-input textarea between attach, emoji and send. Enter sends and Shift+Enter breaks a line — the app's handler, because the library does not know which the app wants. The field grows with the text from its rows to about six lines and scrolls after that (field-sizing: content; a browser without it keeps the rows). .chat-closed takes the composer's place when nothing can be sent any more.

Alex Fischer Can you also confirm the label was reprinted?
label-ORD-4209.pdf
<div class="card" style="max-width:560px">
    <div class="card-body">
        <form class="chat-composer">
            <div class="chat-composer-reply">
                <i class="ri-reply-line"></i>
                <span><strong>Alex Fischer</strong> Can you also confirm the label was reprinted?</span>
                <button class="btn btn-sm btn-ghost btn-icon" type="button" aria-label="Stop replying"><i class="ri-close-line"></i></button>
            </div>
            <div class="chat-composer-files">
                <span class="chip"><i class="ri-file-pdf-2-line"></i> label-ORD-4209.pdf <button class="chip-dismiss" type="button" aria-label="Remove label-ORD-4209.pdf"><i class="ri-close-line"></i></button></span>
            </div>
            <div class="chat-composer-row">
                <button class="btn btn-ghost btn-icon" type="button" aria-label="Attach a file"><i class="ri-attachment-2"></i></button>
                <textarea class="form-input" rows="1" placeholder="Message Alex Fischer" aria-label="Message">Both labels are attached — the old one is void.</textarea>
                <button class="btn btn-ghost btn-icon" type="button" aria-label="Insert an emoji"><i class="ri-emotion-line"></i></button>
                <button class="btn btn-primary btn-icon" type="submit" aria-label="Send"><i class="ri-send-plane-2-line"></i></button>
            </div>
        </form>
    </div>
</div>

Quick replies, and a closed conversation

.chat-quick-replies under a message is a row of chips that are buttons — the answers a bot or an assistant offers, each sending itself as the reply; the app removes the row once one is taken. .chat-closed stands where the composer was when the conversation cannot continue, with the way to a new one.

Support
Your reservation rsv-20419 was approved. Is there anything else?
No, thanks
The conversation was closed
This conversation was closed on 12 September. Start a new one
<div class="card" style="max-width:560px">
    <div class="card-body">
        <div class="chat" role="log" aria-live="polite" aria-label="Support">
            <div class="chat-message">
                <span class="avatar avatar-sm avatar--muted"><i class="ri-robot-2-line"></i></span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Support</span><time class="chat-time" datetime="2026-09-12T15:30">15:30</time></div>
                    <div class="chat-bubble">Your reservation rsv-20419 was approved. Is there anything else?</div>
                    <div class="chat-quick-replies">
                        <button class="chip" type="button">Change the dates</button>
                        <button class="chip" type="button">Add a colleague</button>
                        <button class="chip" type="button">No, thanks</button>
                    </div>
                </div>
            </div>
            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">No, thanks</div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-12T15:31">15:31</time><i class="ri-check-double-line chat-status chat-status--read" aria-label="Read" role="img"></i></div>
                </div>
            </div>
            <div class="chat-system">The conversation was closed</div>
        </div>
    </div>
    <div class="chat-closed">
        <i class="ri-lock-line"></i>
        This conversation was closed on 12 September.
        <a href="#">Start a new one</a>
    </div>
</div>

Files and pictures

A file in a bubble is the .file-item every other file list uses — an <a> with the .file-icon, the name and the size, so it downloads like any link, on the bubble's own surface — and .chat-bubble--media is a bubble that is a picture or a <video>, filled to its corners with the caption in the next bubble. Give the image its width and height, or the thread jumps as it loads.

AF
Alex Fischer
A warehouse floor stacked with yellow crates, staff working in the aisle
Reprinted and back on the floor. Photo by Adrian Sulyok on Unsplash.
<div class="card" style="max-width:560px">
    <div class="card-body">
        <div class="chat" role="log" aria-label="Chat with Alex Fischer">
            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">
                        <a class="file-item" href="#" download>
                            <span class="file-icon file-icon--sm file-icon--pdf"><i class="ri-file-pdf-2-line"></i></span>
                            <span class="file-item-name">label-ORD-4209.pdf</span>
                            <span class="file-item-size">84 KB</span>
                        </a>
                    </div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-13T09:46">09:46</time><i class="ri-check-line chat-status" aria-label="Sent" role="img"></i></div>
                </div>
            </div>

            <div class="chat-message">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T09:52">09:52</time></div>
                    <div class="chat-bubble chat-bubble--media">
                        <img src="https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=480&amp;h=300&amp;fit=crop&amp;q=70&amp;auto=format"
                             width="480" height="300" alt="A warehouse floor stacked with yellow crates, staff working in the aisle" loading="lazy" />
                    </div>
                    <div class="chat-bubble">Reprinted and back on the floor. Photo by Adrian Sulyok on Unsplash.</div>
                </div>
            </div>
        </div>
    </div>
</div>

Pictures, and looking at one large

Live — open a picture. Several pictures in one message are a .gallery--tight inside a media bubble, two across, each a .media-open button around a square .media-frame that opens the .lightbox — a modal dialog with the picture as large as the screen allows, the sender and time in its meta line, and the way to the next one. A file travels beside them as a .file-item. In the composer, a picture waiting to be sent is a .file-thumb with its remove button over the corner, next to the chips for the other files.

AF
Alex Fischer
Reprinted and on the belt — both bays. Photos by CHUTTERSNAP and Adrian Sulyok on Unsplash. pod-ord-4209.pdf 84 KB
The crates on the floor, attached stock-check.xlsx
<div class="card" style="max-width:560px">
    <div class="card-body">
        <div class="chat" role="log" aria-label="Chat with Alex Fischer">
            <div class="chat-message">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T09:52">09:52</time></div>
                    <div class="chat-bubble chat-bubble--media">
                        <div class="gallery gallery--tight">
                            <button class="media-open" type="button" aria-label="Open the photo of the racking bay"
                                    onclick="document.getElementById('lb-chat-1').showModal()">
                                <span class="media-frame media-frame--square media-frame--cover">
                                    <img src="https://images.unsplash.com/photo-1587293852726-70cdb56c2866?w=280&amp;h=280&amp;fit=crop&amp;q=70&amp;auto=format"
                                         width="280" height="280" loading="lazy" alt="A warehouse aisle of tall pallet racking" />
                                </span>
                            </button>
                            <button class="media-open" type="button" aria-label="Open the photo of the crates"
                                    onclick="document.getElementById('lb-chat-2').showModal()">
                                <span class="media-frame media-frame--square media-frame--cover">
                                    <img src="https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=280&amp;h=280&amp;fit=crop&amp;q=70&amp;auto=format"
                                         width="280" height="280" loading="lazy" alt="A warehouse floor stacked with yellow crates" />
                                </span>
                            </button>
                        </div>
                    </div>
                    <div class="chat-bubble">
                        Reprinted and on the belt — both bays. Photos by CHUTTERSNAP and Adrian Sulyok on Unsplash.
                        <a class="file-item sedna-mt-2" href="#" download>
                            <span class="file-icon file-icon--sm file-icon--pdf"><i class="ri-file-pdf-2-line"></i></span>
                            <span class="file-item-name">pod-ord-4209.pdf</span>
                            <span class="file-item-size">84 KB</span>
                        </a>
                    </div>
                </div>
            </div>
        </div>

        <form class="chat-composer">
            <div class="chat-composer-files">
                <span class="file-thumb">
                    <img src="https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=96&amp;h=96&amp;fit=crop&amp;q=70&amp;auto=format" width="48" height="48" alt="The crates on the floor, attached" />
                    <button class="chip-dismiss" type="button" aria-label="Remove the photo"><i class="ri-close-line"></i></button>
                </span>
                <span class="chip"><i class="ri-file-excel-2-line"></i> stock-check.xlsx <button class="chip-dismiss" type="button" aria-label="Remove stock-check.xlsx"><i class="ri-close-line"></i></button></span>
            </div>
            <div class="chat-composer-row">
                <button class="btn btn-ghost btn-icon" type="button" aria-label="Attach a file"><i class="ri-attachment-2"></i></button>
                <textarea class="form-input" rows="1" placeholder="Message Alex Fischer" aria-label="Message">Stock check from this morning, and the belt right now.</textarea>
                <button class="btn btn-primary btn-icon" type="submit" aria-label="Send"><i class="ri-send-plane-2-line"></i></button>
            </div>
        </form>
    </div>
</div>

<dialog class="lightbox" id="lb-chat-1" aria-labelledby="lb-chat-1-title">
    <div class="lightbox-head">
        <strong class="lightbox-title" id="lb-chat-1-title">racking-bay-4209.jpg</strong>
        <span class="lightbox-meta">1 of 2 · Alex Fischer, 09:52</span>
        <div class="lightbox-actions">
            <form method="dialog"><button class="btn btn-ghost btn-icon" aria-label="Close"><i class="ri-close-line"></i></button></form>
        </div>
    </div>
    <div class="lightbox-stage">
        <img src="https://images.unsplash.com/photo-1587293852726-70cdb56c2866?w=1600&amp;q=80&amp;auto=format" alt="A warehouse aisle of tall pallet racking" />
    </div>
    <button class="lightbox-prev" type="button" aria-label="Previous" disabled><i class="ri-arrow-left-s-line"></i></button>
    <button class="lightbox-next" type="button" aria-label="Next"
            onclick="document.getElementById('lb-chat-1').close(); document.getElementById('lb-chat-2').showModal()"><i class="ri-arrow-right-s-line"></i></button>
    <p class="lightbox-caption">Photo by CHUTTERSNAP on Unsplash.</p>
</dialog>
<dialog class="lightbox" id="lb-chat-2" aria-labelledby="lb-chat-2-title">
    <div class="lightbox-head">
        <strong class="lightbox-title" id="lb-chat-2-title">crates-4209.jpg</strong>
        <span class="lightbox-meta">2 of 2 · Alex Fischer, 09:52</span>
        <div class="lightbox-actions">
            <form method="dialog"><button class="btn btn-ghost btn-icon" aria-label="Close"><i class="ri-close-line"></i></button></form>
        </div>
    </div>
    <div class="lightbox-stage">
        <img src="https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=1600&amp;q=80&amp;auto=format" alt="A warehouse floor stacked with yellow crates" />
    </div>
    <button class="lightbox-prev" type="button" aria-label="Previous"
            onclick="document.getElementById('lb-chat-2').close(); document.getElementById('lb-chat-1').showModal()"><i class="ri-arrow-left-s-line"></i></button>
    <button class="lightbox-next" type="button" aria-label="Next" disabled><i class="ri-arrow-right-s-line"></i></button>
    <p class="lightbox-caption">Photo by Adrian Sulyok on Unsplash.</p>
</dialog>

What a bubble holds

.chat-forwarded above the text says where a message came from; .chat-bubble--deleted keeps a taken-back message's place and says so. A <pre>, a <code> and a list fit the bubble and take its ink, and so does a .markdown-body inside it — which is how an assistant's answer, rendered from Markdown, reads as a message rather than a document.

AF
Alex Fischer
Forwarded from Dispatch — EU-Central Cut-off for Friday's 12:20 is Thursday 16:00 — anything later goes Monday.
This message was deleted.
The reroute call is POST /orders/4213/reroute with the hub in the body:
{ "hub": "EU-West", "reason": "capacity" }
Assistant

Three orders are affected by the Hamburg capacity hold:

  • ORD-4209 — rerouted to EU-West, label reprinted
  • ORD-4213 — held, address change pending
  • ORD-4250 — rerouted to EU-West

Nothing else in the queue is due before Thursday.

<div class="card" style="max-width:600px">
    <div class="card-body">
        <div class="chat" role="log" aria-label="Chat with Alex Fischer">
            <div class="chat-message">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T10:02">10:02</time></div>
                    <div class="chat-bubble">
                        <span class="chat-forwarded"><i class="ri-share-forward-line"></i> Forwarded from Dispatch — EU-Central</span>
                        Cut-off for Friday's 12:20 is Thursday 16:00 — anything later goes Monday.
                    </div>
                </div>
            </div>

            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble chat-bubble--deleted">This message was deleted.</div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-13T10:04">10:04</time></div>
                </div>
            </div>

            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">
                        The reroute call is <code>POST /orders/4213/reroute</code> with the hub in the body:
<pre><code>{ "hub": "EU-West", "reason": "capacity" }</code></pre>
                    </div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-13T10:06">10:06</time><i class="ri-check-double-line chat-status chat-status--read" aria-label="Read" role="img"></i></div>
                </div>
            </div>

            <div class="chat-message">
                <span class="avatar avatar-sm avatar--muted"><i class="ri-robot-2-line"></i></span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Assistant</span><time class="chat-time" datetime="2026-09-13T10:07">10:07</time></div>
                    <div class="chat-bubble">
                        <div class="markdown-body">
                            <p>Three orders are affected by the Hamburg capacity hold:</p>
                            <ul>
                                <li><strong>ORD-4209</strong> — rerouted to EU-West, label reprinted</li>
                                <li><strong>ORD-4213</strong> — held, address change pending</li>
                                <li><strong>ORD-4250</strong> — rerouted to EU-West</li>
                            </ul>
                            <p>Nothing else in the queue is due before Thursday.</p>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

A .link-preview unfurls a link the message carries — the picture, the title, a line and the host in .link-preview-body; the app fetches the preview. A voice message is an .audio-wave: play, the .audio-wave-bars with each bar's height in --h and the ones already heard as .audio-wave-heard, and the .audio-wave-time. The <audio> element is the app's and hidden; the play button names the length. A file still uploading is a .file-item with a .progress under the name and the percentage in the size column.

0:07 / 0:19
stock-check.xlsx 64%
Sending…
<div class="card" style="max-width:600px">
    <div class="card-body">
        <div class="chat" role="log" aria-label="Chat with Alex Fischer">
            <div class="chat-message">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T10:12">10:12</time></div>
                    <div class="chat-bubble">
                        The dock schedule for week 38 is up.
                        <a class="link-preview" href="#">
                            <img src="https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=176&amp;h=176&amp;fit=crop&amp;q=70&amp;auto=format" width="88" height="88" alt="" loading="lazy" />
                            <span class="link-preview-body">
                                <strong>Dock schedule — week 38</strong>
                                <span>Inbound and outbound slots for all four docks, with the Thursday cut-off marked.</span>
                                <small>ops.example.com</small>
                            </span>
                        </a>
                    </div>
                </div>
            </div>

            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">
                        <div class="audio-wave">
                            <button class="btn btn-sm btn-primary btn-icon" type="button" aria-label="Play the voice message, 19 seconds"><i class="ri-play-fill"></i></button>
                            <span class="audio-wave-bars" aria-hidden="true">
                                <i class="audio-wave-heard" style="--h:35%"></i><i class="audio-wave-heard" style="--h:60%"></i><i class="audio-wave-heard" style="--h:85%"></i><i class="audio-wave-heard" style="--h:50%"></i><i class="audio-wave-heard" style="--h:30%"></i><i class="audio-wave-heard" style="--h:70%"></i><i class="audio-wave-heard" style="--h:95%"></i><i class="audio-wave-heard" style="--h:55%"></i><i class="audio-wave-heard" style="--h:40%"></i>
                                <i style="--h:65%"></i><i style="--h:80%"></i><i style="--h:45%"></i><i style="--h:25%"></i><i style="--h:60%"></i><i style="--h:90%"></i><i style="--h:50%"></i><i style="--h:35%"></i><i style="--h:70%"></i><i style="--h:40%"></i><i style="--h:55%"></i><i style="--h:30%"></i><i style="--h:65%"></i><i style="--h:45%"></i><i style="--h:25%"></i>
                            </span>
                            <span class="audio-wave-time">0:07 / 0:19</span>
                        </div>
                    </div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-13T10:15">10:15</time><i class="ri-check-double-line chat-status" aria-label="Delivered" role="img"></i></div>
                </div>
            </div>

            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">
                        <span class="file-item" role="status" aria-label="Uploading stock-check.xlsx, 64 percent">
                            <span class="file-icon file-icon--sm file-icon--sheet"><i class="ri-file-excel-2-line"></i></span>
                            <span class="file-item-main">
                                <span class="file-item-name">stock-check.xlsx</span>
                                <span class="progress" aria-hidden="true"><span class="progress-bar" style="width:64%"></span></span>
                            </span>
                            <span class="file-item-size">64%</span>
                        </span>
                    </div>
                    <div class="chat-meta"><span>Sending…</span></div>
                </div>
            </div>
        </div>
    </div>
</div>

The launcher and the flyover

Live — it is in the corner of this page. .chat-launcher is the round brand button in the corner, above the home indicator, with the unread count on it as .chat-launcher-count; it is the page's one floating action where a chat is the page's job, so it takes the FAB's corner and rung. It opens the .chat-flyover with popovertarget: a popover="manual" panel, so it is in the top layer and stays open while the reader works, and the close button in its .chat-flyover-head is the way out. Inside it the thread and the composer are the ones above; on a phone it is the whole screen.

The launcher is in the corner of this page, where it lives; this opens the same flyover.
<div class="sedna-row sedna-gap-3">
    <button class="btn btn-primary" type="button" popovertarget="ex-flyover"><i class="ri-chat-3-line"></i> Open the chat</button>
    <span class="text-muted text-sm">The launcher is in the corner of this page, where it lives; this opens the same flyover.</span>
</div>

<button class="chat-launcher" type="button" popovertarget="ex-flyover" aria-label="Open the chat, 2 unread">
    <i class="ri-chat-3-line"></i>
    <span class="chat-launcher-count" aria-hidden="true">2</span>
</button>

<div class="chat-flyover" id="ex-flyover" popover="manual" role="dialog" aria-label="Chat with Alex Fischer">
    <div class="chat-flyover-head">
        <span class="avatar avatar-sm">AF</span>
        <strong>Alex Fischer</strong>
        <span class="health-badge health-badge--live"><span class="health-dot"></span> Online</span>
        <button class="btn btn-sm btn-ghost btn-icon" type="button" aria-label="Close the chat" popovertarget="ex-flyover" popovertargetaction="hide"><i class="ri-close-line"></i></button>
    </div>
    <div class="chat" role="log" aria-live="polite" aria-label="Messages">
        <div class="chat-day">Today</div>
        <div class="chat-message">
            <span class="avatar avatar-sm">AF</span>
            <div class="chat-body">
                <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T09:41">09:41</time></div>
                <div class="chat-bubble">Can you also confirm the label was reprinted?</div>
            </div>
        </div>
        <div class="chat-message chat-message--mine">
            <div class="chat-body">
                <div class="chat-bubble">Yes, both labels — the old one is void.</div>
                <div class="chat-meta"><time class="chat-time" datetime="2026-09-13T09:44">09:44</time><i class="ri-check-double-line chat-status chat-status--read" aria-label="Read" role="img"></i></div>
            </div>
        </div>
        <div class="chat-message">
            <span class="avatar avatar-sm">AF</span>
            <div class="chat-body">
                <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T09:45">09:45</time></div>
                <div class="chat-bubble">Perfect, thanks!</div>
            </div>
        </div>
    </div>
    <form class="chat-composer">
        <div class="chat-composer-row">
            <button class="btn btn-ghost btn-icon" type="button" aria-label="Attach a file"><i class="ri-attachment-2"></i></button>
            <textarea class="form-input" rows="1" placeholder="Message Alex Fischer" aria-label="Message"></textarea>
            <button class="btn btn-primary btn-icon" type="submit" aria-label="Send"><i class="ri-send-plane-2-line"></i></button>
        </div>
    </form>
</div>

A page of it

Live — scroll the thread. .chat-pane is the chat as a page's whole height: give it a height — 100% inside a .page--fill, or one of its own — and the thread takes what the .chat-pinned bar above and the composer below leave, scrolling on its own with each .chat-day sticking as it passes. The thread that scrolls carries tabindex="0", so the keyboard can scroll it too. .chat-jump floats over the thread's bottom edge for the moment the reader has scrolled up and something arrives; the app shows it and scrolls on the click.

Pinned Cut-off for Friday's 12:20 is Thursday 16:00 — anything later goes Monday.
Yesterday
PN
Priya Nair
Carrier capacity in Hamburg is exhausted until Thursday.
Then EU-West takes the reroutes. I will post the labels here.
TF
Tomás Ferreira
4213 has a changed address — hold that one until the customer confirms.
Held. 4209 and 4250 are rerouted.
Today
AF
Alex Fischer
Customer confirmed the new address for 4213 — it can go.
Released. Label is on the belt.
PN
Priya Nair
Thanks both. Hamburg is back to normal capacity from Thursday.
PN
Priya Nair
I will unpin the cut-off note then.
<div class="card" style="max-width:600px; height:460px">
    <div class="chat-pane">
        <div class="chat-pinned">
            <i class="ri-pushpin-line"></i>
            <span><strong>Pinned</strong> Cut-off for Friday's 12:20 is Thursday 16:00 — anything later goes Monday.</span>
            <button class="btn btn-sm btn-ghost btn-icon" type="button" aria-label="Unpin"><i class="ri-close-line"></i></button>
        </div>

        <div class="chat" role="log" aria-live="polite" aria-label="Dispatch — EU-Central" tabindex="0">
            <div class="chat-day">Yesterday</div>
            <div class="chat-message">
                <span class="avatar avatar-sm avatar--viz-2">PN</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Priya Nair</span><time class="chat-time" datetime="2026-09-12T16:40">16:40</time></div>
                    <div class="chat-bubble">Carrier capacity in Hamburg is exhausted until Thursday.</div>
                </div>
            </div>
            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">Then EU-West takes the reroutes. I will post the labels here.</div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-12T16:44">16:44</time><i class="ri-check-double-line chat-status chat-status--read" aria-label="Read" role="img"></i></div>
                </div>
            </div>
            <div class="chat-message">
                <span class="avatar avatar-sm avatar--viz-4">TF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Tomás Ferreira</span><time class="chat-time" datetime="2026-09-12T17:02">17:02</time></div>
                    <div class="chat-bubble">4213 has a changed address — hold that one until the customer confirms.</div>
                </div>
            </div>
            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">Held. 4209 and 4250 are rerouted.</div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-12T17:05">17:05</time><i class="ri-check-double-line chat-status chat-status--read" aria-label="Read" role="img"></i></div>
                </div>
            </div>

            <div class="chat-day">Today</div>
            <div class="chat-message">
                <span class="avatar avatar-sm">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T09:41">09:41</time></div>
                    <div class="chat-bubble">Customer confirmed the new address for 4213 — it can go.</div>
                </div>
            </div>
            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <div class="chat-bubble">Released. Label is on the belt.</div>
                    <div class="chat-meta"><time class="chat-time" datetime="2026-09-13T09:44">09:44</time><i class="ri-check-double-line chat-status" aria-label="Delivered" role="img"></i></div>
                </div>
            </div>
            <div class="chat-message">
                <span class="avatar avatar-sm avatar--viz-2">PN</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Priya Nair</span><time class="chat-time" datetime="2026-09-13T09:52">09:52</time></div>
                    <div class="chat-bubble">Thanks both. Hamburg is back to normal capacity from Thursday.</div>
                </div>
            </div>
            <div class="chat-message chat-message--continued">
                <span class="avatar avatar-sm avatar--viz-2">PN</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Priya Nair</span><time class="chat-time" datetime="2026-09-13T09:52">09:52</time></div>
                    <div class="chat-bubble">I will unpin the cut-off note then.</div>
                </div>
            </div>
        </div>

        <button class="chat-jump" type="button">2 new messages <i class="ri-arrow-down-line"></i></button>

        <form class="chat-composer">
            <div class="chat-composer-row">
                <button class="btn btn-ghost btn-icon" type="button" aria-label="Attach a file"><i class="ri-attachment-2"></i></button>
                <textarea class="form-input" rows="1" placeholder="Message Dispatch — EU-Central" aria-label="Message"></textarea>
                <button class="btn btn-primary btn-icon" type="submit" aria-label="Send"><i class="ri-send-plane-2-line"></i></button>
            </div>
        </form>
    </div>
</div>

Flat, like a channel

.chat--flat on the thread is a channel rather than a conversation: no bubbles, every message full width on the start with the author beside the time, the row lit on hover. Mine is not on the end here — in a channel of forty people, whose a message is is the name. .chat-replies under a message is the thread it started: who answered, how many, and when.

# dispatch-eu-central

41 members
PN
Priya Nair
Carrier capacity in Hamburg is exhausted until Thursday. @Alex, can your team take the EU-West reroutes?
AF TF 3 replieslast today at 08:20
AF
Alex Fischer
Rerouting ORD-4209, 4213 and 4250 now — labels follow here.
Tomás Ferreira pinned a message
TF
Tomás Ferreira
4213 has a changed address — hold that one until the customer confirms.
<div class="card" style="max-width:640px">
    <div class="card-head"><h3># dispatch-eu-central</h3><span class="badge">41 members</span></div>
    <div class="card-body">
        <div class="chat chat--flat" role="log" aria-live="polite" aria-label="dispatch-eu-central">
            <div class="chat-message">
                <span class="avatar avatar--viz-2">PN</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Priya Nair</span><time class="chat-time" datetime="2026-09-13T08:12">08:12</time></div>
                    <div class="chat-bubble">Carrier capacity in Hamburg is exhausted until Thursday. <a class="chat-mention" href="#">&#64;Alex</a>, can your team take the EU-West reroutes?</div>
                    <div class="chat-reactions">
                        <button class="chat-reaction" type="button" aria-pressed="true" aria-label="Thumbs up, 3, including you">👍 3</button>
                        <button class="chat-reaction" type="button" aria-label="Add a reaction"><i class="ri-emotion-line"></i></button>
                    </div>
                    <a class="chat-replies" href="#">
                        <span class="avatar-group">
                            <span class="avatar avatar-sm">AF</span>
                            <span class="avatar avatar-sm avatar--viz-4">TF</span>
                        </span>
                        <strong>3 replies</strong><span>last today at 08:20</span>
                    </a>
                </div>
            </div>

            <div class="chat-message chat-message--mine">
                <span class="avatar">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T08:16">08:16</time></div>
                    <div class="chat-bubble">Rerouting ORD-4209, 4213 and 4250 now — labels follow here.</div>
                </div>
            </div>
            <div class="chat-message chat-message--mine chat-message--continued">
                <span class="avatar">AF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Alex Fischer</span><time class="chat-time" datetime="2026-09-13T08:17">08:17</time></div>
                    <div class="chat-bubble">
                        <a class="file-item" href="#" download>
                            <span class="file-icon file-icon--sm file-icon--pdf"><i class="ri-file-pdf-2-line"></i></span>
                            <span class="file-item-name">labels-ORD-4209-4250.pdf</span>
                            <span class="file-item-size">212 KB</span>
                        </a>
                    </div>
                </div>
            </div>

            <div class="chat-system">Tomás Ferreira pinned a message</div>

            <div class="chat-message">
                <span class="avatar avatar--viz-4">TF</span>
                <div class="chat-body">
                    <div class="chat-meta"><span class="chat-author">Tomás Ferreira</span><time class="chat-time" datetime="2026-09-13T08:20">08:20</time></div>
                    <div class="chat-bubble">4213 has a changed address — hold that one until the customer confirms.</div>
                </div>
            </div>
        </div>
    </div>
</div>

While it loads

The history arriving is skeletons in the thread's own shapes: a .skeleton-avatar at the avatar's size, a .skeleton-text for the meta line and a .skeleton-block at a bubble's height, mine on the end. The card is aria-busy and the words are there for a screen reader.

Loading the conversation
<div class="card" style="max-width:560px" aria-busy="true">
    <div class="card-body">
        <div class="chat" aria-label="Loading the conversation">
            <div class="chat-message">
                <span class="skeleton skeleton-avatar" style="width:22px; height:22px"></span>
                <div class="chat-body">
                    <span class="skeleton skeleton-text" style="width:120px; margin:0"></span>
                    <span class="skeleton skeleton-block" style="width:280px; --skeleton-height:44px"></span>
                </div>
            </div>
            <div class="chat-message chat-message--mine">
                <div class="chat-body">
                    <span class="skeleton skeleton-block" style="width:200px; --skeleton-height:44px"></span>
                </div>
            </div>
            <div class="chat-message">
                <span class="skeleton skeleton-avatar" style="width:22px; height:22px"></span>
                <div class="chat-body">
                    <span class="skeleton skeleton-text" style="width:120px; margin:0"></span>
                    <span class="skeleton skeleton-block" style="width:240px; --skeleton-height:64px"></span>
                </div>
            </div>
            <span class="visually-hidden">Loading the conversation</span>
        </div>
    </div>
</div>

The conversations

The list of chats is a .list: an avatar, the name and the last line, the time and an unread badge — the row that navigates is the row that navigates everywhere else. A group is an .avatar-pair: two of its people stacked corner to corner inside one avatar's square, so a group's name starts where a person's does and the column of names stays straight. An .avatar-group there would be as wide as its count. Name the pair once, on the wrapper.

<div class="card" style="max-width:420px">
    <ul class="list">
        <li>
            <a class="list-row" href="#" aria-current="true">
                <span class="avatar">AF</span>
                <span class="list-main">
                    <span class="list-title">Alex Fischer</span>
                    <span class="list-sub">Perfect, thanks!</span>
                </span>
                <span class="list-meta"><span>09:45</span></span>
            </a>
        </li>
        <li>
            <a class="list-row" href="#">
                <span class="avatar-pair" role="img" aria-label="Priya Nair and Tomás Ferreira">
                    <span class="avatar avatar--viz-2" aria-hidden="true">PN</span>
                    <span class="avatar avatar--viz-4" aria-hidden="true">TF</span>
                </span>
                <span class="list-main">
                    <span class="list-title">Dispatch — EU-Central</span>
                    <span class="list-sub"><strong>Tomás:</strong> 4213 has a changed address — hold that one…</span>
                </span>
                <span class="list-meta"><span>08:20</span><span class="badge badge-go">3</span></span>
            </a>
        </li>
        <li>
            <a class="list-row" href="#">
                <span class="avatar avatar--muted"><i class="ri-robot-2-line"></i></span>
                <span class="list-main">
                    <span class="list-title">Workflow API</span>
                    <span class="list-sub">Reservation rsv-20419 was approved.</span>
                </span>
                <span class="list-meta"><span>Yesterday</span></span>
            </a>
        </li>
    </ul>
</div>