defuss-shadcn / Chat / message
MessageMOL
One row of a conversation, where the pieces come together: an avatar, the sender's name and time, the bubble (or a file), delivery status and actions. Rows sit on the received or sent side, group so one sender shows one avatar and one name, and compose with markers into a whole thread. The avatar stays level with the bubble's bottom edge, above any footer. CSS only, after shadcn/ui's Message.
On this page (12)
§Conversation
Rows of avatar + content: received at the start (a secondary bubble), your own at the end (data-align='end', the primary bubble). The avatar sits level with the bubble's bottom edge; a .message-list with role='log' is the thread.
§Grouped messages
A .message-group stacks one sender's consecutive rows: only the last row shows the avatar and only the first its header - every row keeps an (empty) .message-avatar, so the bubbles line up - and the joined bubble corners tighten.
The header names the sender and the time; the footer follows the row's side with the time and a .message-status - sent, delivered, read (blue) or failed. The avatar lifts above the footer, staying level with the bubble.
§Actions
An assistant's reply: a ghost bubble (full width) and a footer of icon buttons, each with an aria-label. data-reveal='hover' shows them on hover or focus of the row - always on touch screens.
§Attachments
A media bubble (data-media) for an image, a .message-attachment card for a file - a real <a download> with an icon, the name and its meta.
§Typing
While the other side writes: a typing bubble in their row (role='status' with a label), or a marker with a spinner.
§Reactions
Bubble reactions hang on the surface inside a row as they do anywhere.
§A thread with markers
Messages, markers and separators compose a whole thread - the date, what the assistant did, the reply.
§Sizes
data-size sm · (default) · lg on the row scales the avatar column (1.5 / 2 / 2.5rem) and the gap; pair it with the bubble's own data-size.
§Without avatars
data-avatar='none' drops the avatar column - a one-to-one chat where the side already says who wrote.
§Right to left
Logical throughout - in dir='rtl' received rows sit on the right with their avatar, sent ones on the left.
§CSS view file
/* -- Message component ----------------------------------------------- *//* One row of a conversation: an avatar beside a column of header (name, time), the message surface (a .bubble, an attachment, anything) and a footer (status, actions). Start = the other side, end = you. CSS only. */@layer components { .message { --_av: 2rem; --_gap: 0.5rem; --_foot: 1.75rem; display: flex; align-items: flex-end; gap: var(--_gap); width: 100%; min-width: 0; &[data-align="end"] { flex-direction: row-reverse; } /* -- Sizes: avatar column and the text scale ------------------- */ &[data-size="sm"] { --_av: 1.5rem; --_gap: 0.375rem; } &[data-size="lg"] { --_av: 2.5rem; --_gap: 0.75rem; } } /* The avatar slot keeps its column even when empty (a placeholder keeps consecutive messages aligned) and sits level with the surface's bottom edge - lifted above the footer when there is one. */ .message-avatar { display: flex; flex: none; justify-content: center; width: var(--_av); & > .avatar { width: var(--_av); height: var(--_av); } } .message:has(> .message-content > .message-footer) > .message-avatar { margin-block-end: calc(var(--_foot) + 0.125rem); } /* no avatar column at all: data-avatar="none" on the message */ .message[data-avatar="none"] > .message-avatar { display: none; } .message-content { display: flex; flex-direction: column; align-items: flex-start; gap: 0.125rem; min-width: 0; max-width: min(80%, 40rem); /* the surface fills up to the column; the column carries the 80% cap */ & > .bubble { max-width: 100%; } & > .bubble[data-variant="ghost"] { width: auto; } } .message[data-align="end"] > .message-content { align-items: flex-end; } /* an assistant's ghost reply runs the full width */ .message-content:has(> .bubble[data-variant="ghost"]) { max-width: 100%; flex: 1; } /* its header lines up with the unframed text */ .message-content:has(> .bubble[data-variant="ghost"]) > :is(.message-header, .message-footer) { padding-inline: 0; } .message-header { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.375rem; padding-inline: 0.25rem; font-size: 0.75rem; line-height: 1.4; color: var(--muted-foreground); } .message-name { font-weight: 600; color: var(--foreground); } .message-time { font-variant-numeric: tabular-nums; color: var(--muted-foreground); } .message-footer { display: flex; align-items: center; gap: 0.25rem; height: var(--_foot); padding-inline: 0.25rem; font-size: 0.75rem; color: var(--muted-foreground); white-space: nowrap; & svg { width: 0.875rem; height: 0.875rem; flex-shrink: 0; } & .btn svg { width: 1rem; height: 1rem; } & .btn { color: var(--muted-foreground); } & .btn:hover { color: var(--foreground); } } /* data-reveal="hover": the footer's actions appear on hover / focus of the message; always shown where there is no hover (touch) */ @media (hover: hover) { .message-footer[data-reveal="hover"] { opacity: 0; transition: opacity 150ms ease; } .message:is(:hover, :focus-within) > .message-content > .message-footer[data-reveal="hover"] { opacity: 1; } } /* -- Status: delivery ticks in the footer --------------------------- */ .message-status { display: inline-flex; align-items: center; gap: 0.25rem; &[data-status="read"] { color: oklch(0.6 0.16 250); } &[data-status="failed"] { color: var(--destructive); } } /* -- Attachment: a file card as the message surface ------------------ */ .message-attachment { display: flex; align-items: center; gap: 0.75rem; box-sizing: border-box; width: 16rem; max-width: 100%; padding: 0.625rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--card); color: var(--card-foreground); font-size: 0.8125rem; text-decoration: none; &:is(a) { cursor: pointer; transition: background-color 150ms ease; } &:is(a):hover { background: var(--accent); } &:is(a):focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } } .message-attachment-icon { display: grid; flex: none; place-items: center; width: 2.25rem; height: 2.25rem; border-radius: var(--radius-md); background: var(--muted); color: var(--muted-foreground); & svg { width: 1.125rem; height: 1.125rem; } } .message-attachment-body { display: flex; flex: 1; flex-direction: column; min-width: 0; line-height: 1.3; } .message-attachment-name { overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } .message-attachment-meta { font-size: 0.75rem; color: var(--muted-foreground); } /* -- Group: consecutive messages of one sender ---------------------- */ .message-group { display: flex; flex-direction: column; gap: 0.1875rem; /* one avatar per group: only the last message shows it (the others keep the empty column, so everything lines up) */ & > .message:not(:last-child) > .message-avatar > * { visibility: hidden; } /* one header per group: the first message's */ & > .message:not(:first-child) > .message-content > .message-header { display: none; } /* the joined bubble corners tighten, as in a .bubble-group */ & > .message:not([data-align="end"]) { &:not(:last-child) > .message-content > .bubble > .bubble-content { border-end-start-radius: var(--_rs); } &:not(:first-child) > .message-content > .bubble > .bubble-content { border-start-start-radius: var(--_rs); } } & > .message[data-align="end"] { &:not(:last-child) > .message-content > .bubble > .bubble-content { border-end-end-radius: var(--_rs); } &:not(:first-child) > .message-content > .bubble > .bubble-content { border-start-end-radius: var(--_rs); } } } /* -- A conversation: rows of messages, groups and markers ------------- */ .message-list { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }}@media (prefers-reduced-motion: reduce) { @layer components { .message-footer[data-reveal="hover"], .message-attachment { transition: none; } }}@media (prefers-contrast: more) { @layer components { .message-header, .message-footer, .message-time, .message-attachment-meta { color: var(--foreground); } }}@media (forced-colors: active) { @layer components { .message-attachment { border-color: CanvasText; } .message-status[data-status] { color: CanvasText; } }}Comments, ideas or improvements? Edit this page's source on GitHub