Theme
On this page (12)
Component Skill — components/message/component-skill.md

Native basis

A .message row of a .message-avatar slot and a .message-content column (header, surface, footer); <time datetime> for times; a .message-list with role="log" is the thread.

Web Platform APIs

<time>role="log":has()@media (hover)

Classes

.message.message-avatar.message-content.message-header.message-name.message-time.message-footer.message-status.message-attachment.message-group.message-list

Data attributes

data-align="end", data-size (sm, lg), data-avatar="none" on the row; data-reveal="hover" on the footer; data-status (sent, delivered, read, failed) on a .message-status.

§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