Raft Design System

Components · Messages

Comment

Comments beside a document, Google Docs style — a margin of cards each level with the passage it is about, the card of one discussion, its comments, and the marker that replaces the margin on a narrow screen.

ExperimentalAlso called CommentMargin, CommentCard, CommentMarker, Margin comments, Document comments
import { Comment } from "@botiverse/raft-ui/wip";

The parts for comments on a document: CommentMargin lays out a column of CommentCards beside the document, each level with the passage it is about; CommentEntry is one comment in a card; CommentMarker replaces the margin where it doesn't fit. When and how a product shows comments is in Comments; this page covers the parts.

comment/compositions/margin

When to use

SituationUse
Comments on a document or on passages of it, in a viewer with room for a marginCommentMargin with a CommentCard per discussion
The same comments where the margin doesn't fitA CommentMarker beside each passage, opening a bottom sheet (Drawer)
A reply to a message in a conversationA thread (ThreadPanel), not a comment
The number of comments on a file, on its card in a messageMessageAttachmentCommentSummary (MessageAttachment)

Anatomy

  • CommentMargin: the column beside the document, position: relative, as tall as its lowest card. Required for the margin.
    • CommentMarginItem: one card's place. top is where its anchor is, in px from the margin's top; active pins it there. Required for each card.
  • CommentCard: one discussion, on layer-card with a line-hairline border and radius-container. active marks the open one.
    • CommentCardMeta: a line in text-caption and foreground-hint with the caller's words: "2 replies", "On the whole document", "Position not found". Optional.
    • CommentEntry: one comment. At least one.
      • CommentEntryHeader: the author's Avatar at size="xs", CommentEntryAuthor (text-subhead, bold) and CommentEntryTime (Space Mono, text-mono-meta, foreground-hint), a <time>.
      • CommentEntryBody: the text, in text-callout. clamp cuts it to 2 lines.
    • CommentCardQuote: the passage the discussion is about, in the caller's words, visually hidden (sr-only). Point the trigger's or the marker's aria-describedby at it. Optional, but give every anchored discussion one.
    • CommentCardTrigger: a button over the whole card that opens it. Optional; only on a collapsed card.
    • The reply box is a Composer (ComposerRoot, Composer, ComposerInput, ComposerSubmit), not a part of its own.
  • CommentMarker: the button beside a passage on a narrow screen, with an AvatarGroup of up to 3 size="2xs" avatars and the count.
  • useCommentMarginFits(viewer, { minWidth }): whether the viewer has room for the margin (Wide and narrow).
  • setCommentHighlights, COMMENT_HIGHLIGHT, COMMENT_HIGHLIGHT_ACTIVE: highlight the passages (Highlights).
  • resolveCommentMarginLayout: the layout CommentMargin uses, for a caller that places cards itself.

The body is text-callout, not text-body: a comment is an aside to the document, and the document's own text is text-body. On a desktop both are 14/22, so they read the same; on a phone a card is a step smaller (14/20) than the document (16/24), the step between a message and its preview (Typography). The header uses a message's styles (MessageItem): bold name, then the time in Space Mono.

States

comment/compositions/card-states
StateLook
CollapsedThe first comment, its text cut to 2 lines (clamp), and the number of replies in CommentCardMeta. CommentCardTrigger opens it
Activeactive: a 1.5px line-strong border and shadow-float, lifted off the margin. Every comment, and the reply box when the person can reply
On the whole documentCommentCardMeta first, saying so. Its CommentMarginItem has top={0}
Position not foundCommentCardMeta first, saying the passage can no longer be found. Its CommentMarginItem has top={0}, after the whole document's

Which parts a card holds is the caller's choice: raft-ui has no collapsed or expanded prop. Render clamp, the reply count and CommentCardTrigger for a collapsed card; every entry and the Composer for an open one.

Behavior

  • Each card sits at its top, level with the first line of its passage. When cards would overlap, they keep the document's order (by top; items with the same top keep the order they were first rendered in) and each moves down to 8px (gap) under the card before it.
  • The active card stays at its top, and the cards above it move up to make room, never above the margin's top; the cards below it move down. When the cards above don't fit, they stack from the top and the active card follows them. This is Google Docs' behavior: the discussion people are reading stays beside its passage.
  • The margin measures its cards with one ResizeObserver, so a card that opens or gets a reply moves the ones below it. The resolved top is the item's data-top, applied as transform: translateY(), so a move doesn't lay the margin out again; it takes 150ms after the first placement, and none under reduced motion. The transform makes the item the containing block of anything position: fixed inside it: portal popups out of the card.
  • top is the caller's measurement: the top of the passage's first line box (range.getClientRects()[0].top) minus the margin's top. Measure again when the document or the margin changes size, since the text rewraps.
  • Show a discussion whose passage can't be found in the document any more at the top, with the comments on the whole document, and say so in CommentCardMeta; never drop it (Comments).

Wide and narrow

Cards in a margin and markers with a bottom sheet are different trees, so the switch between them is made in JavaScript, not with a container query. useCommentMarginFits(viewer) returns true only on a desktop, from 768px with a mouse or a trackpad ((width >= 48rem) and (pointer: fine), the query of every desktop metric), and while the viewer element, the one that holds the document and the margin, is at least 784px wide (minWidth). It watches the viewer with a ResizeObserver, so the layout changes when a sidebar or a thread opens beside it.

comment/compositions/viewer
  • Where it returns false, put a CommentMarker beside each passage instead, in a 72px CommentMargin (room for three avatars and the count) so markers don't overlap either, and open the discussion in a bottom sheet (Drawer). The marker at the top holds the comments on the whole document and those whose position is lost.
comment/compositions/marker-sheet

Highlights

The passages are highlighted with the CSS Custom Highlight API: setCommentHighlights(name, ranges) registers Ranges under a highlight name, and styles.css paints them. It never wraps the document's text in an element, so the document's DOM, its selection and its find in page stay as they were.

  • Register every passage with comments under COMMENT_HIGHLIGHT (raft-comment): brand-soft with a 2px brand underline.
  • Register the active one under COMMENT_HIGHLIGHT_ACTIVE (raft-comment-active) instead: brand with ink type, in both modes, like a search hit.
  • Call the function it returns to clear the highlight. In a browser without CSS.highlights it does nothing, and the cards still sit beside their passages.
  • The names are global to the page: registering a name replaces what was registered under it. Let one viewer at a time own them, the one on screen, and clear them when it closes.

Content

  • Write CommentCardMeta in sentence case, as a fact: "2 replies", "On the whole document", "Position not found".
  • Write the time as in a message header, in the person's 12-hour or 24-hour choice: 11:02 today, Yesterday 17:20 before that (Writing).

Accessibility

The parts are plain elements built with Base UI useRender; CommentMarker wraps Base UI Button.

  • Render the margin as a landmark with a name, after the document in the DOM: render={<aside aria-label="Comments" />}. Render its items in reading order: the whole document's, the lost ones, then by passage. The passages themselves can't be focused, so Tab leaves the document and goes through the cards (or the markers) top to bottom, and a screen reader's landmark list reaches them directly.
  • The highlights carry no semantics. Say which passage a card is about with CommentCardQuote ("On “Re-run the guard suites on staging”"), pointed at by the aria-describedby of CommentCardTrigger or CommentMarker; for the whole document or a lost position, point it at the CommentCardMeta that says so as well.
  • Give CommentCardTrigger an aria-label that says whose comment it opens ("Comment from Martin"); the card's text stays readable around it.
  • Opening a card removes its trigger, so move focus into the open card: into its reply box when it was opened from the keyboard (a click with detail === 0), and to the card itself after a click (tabIndex={-1}, focus({ preventScroll: true })), so Esc still reaches it. Esc closes the card and returns focus to its trigger.
  • active sets aria-current="true" on the card.
  • Give CommentMarker an aria-label that starts with the count it shows ("2 comments"). The bottom sheet it opens needs a DrawerTitle.
  • Give CommentEntryTime the moment in dateTime (ISO 8601).

Rules

  • Show nothing where there are no comments: no empty margin, no marker.
  • Put each card level with its passage; never list anchored comments in a column of their own order.
  • Open one discussion at a time; only the active card shows the reply box, so its send button is the one pink button (Button).
  • Use the Composer for the reply box (Composer); don't build another input.

API

CommentCard

ExperimentalThe card of one discussion: its comments, a line such as "2 replies", and the reply box when it is open. A `div`; which parts it holds (collapsed or open) is the caller's choice. To open it with a click, add `CommentCardTrigger`.
PropTypeDefault
activeThe discussion people are reading or replying to: an ink border, lifted.booleanfalse
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.ReactElement | ((props, state) => ReactElement)

Also takes the attributes of its element.

CommentCardMeta, CommentCardQuote, CommentCardTrigger, CommentEntry, CommentEntryAuthor

ExperimentalA short line about the discussion, in the caller's words: how many replies it has, that it is about the whole document, or that the passage it was on can no longer be found.
PropTypeDefault
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.ReactElement | ((props, state) => ReactElement)

Also takes the attributes of its element.

CommentEntryBody

ExperimentalWhat the comment says.
PropTypeDefault
clampCuts the text to 2 lines, for a collapsed card.booleanfalse
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.ReactElement | ((props, state) => ReactElement)

Also takes the attributes of its element.

CommentEntryHeader, CommentEntryTime

ExperimentalOne line: the author's `Avatar` (`size="xs"`), `CommentEntryAuthor`, then `CommentEntryTime`.
PropTypeDefault
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.ReactElement | ((props, state) => ReactElement)

Also takes the attributes of its element.

CommentMargin

ExperimentalThe column beside a document that holds its comment cards, each level with the passage it is about. It measures its `CommentMarginItem`s with one `ResizeObserver`, resolves their collisions with `resolveCommentMarginLayout`, and takes the height the lowest card needs. Place it beside the document so that its top is the top the items' `top` is measured from.
PropTypeDefault
gapSpace between two cards that would otherwise touch, in px. Default 8.number8
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.ReactElement | ((props, state) => ReactElement)

Also takes the attributes of its element.

CommentMarginItem

ExperimentalOne card's place in a `CommentMargin`. It sits at `top`, or lower when the card above it is in the way; the active one stays at `top` and the others make room. Items with the same `top` keep the order they were first rendered in. The resolved top is `data-top`, applied as a `translateY`.
PropTypeDefault
activePins this card to its anchor and moves the others around it.booleanfalse
top (required)Where the card's anchor is: its y, in px, from the top of the margin.number
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.ReactElement | ((props, state) => ReactElement)

Also takes the attributes of its element.

CommentMarker

ExperimentalWhere there is no room for a margin: a small button beside a passage that has comments, with the authors' avatars (an `AvatarGroup` of up to 3 `size="2xs"` avatars) and the count. It opens the discussion, in a bottom sheet. Give it an `aria-label` that starts with the count ("2 comments").
PropTypeDefault
activeThe marker of the discussion that is open.booleanfalse
focusableWhenDisabledWhether the button should be focusable when disabled.booleanfalse
nativeButtonWhether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`).booleantrue
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.ReactElement | ((props, state) => ReactElement)

Also takes the attributes of its element.

Changes

  • 0.10.6 · AddedComments beside a document, as in Google Docs, through @botiverse/raft-ui/wip: CommentMargin, CommentCard (with CommentCardTrigger, CommentCardMeta, CommentCardQuote), CommentEntry and CommentMarker. Cards sit level with their passages and keep the document's order; on a narrow viewer a CommentMarker beside each passage opens the discussion in a bottom sheet instead. useCommentMarginFits(viewer, { minWidth }) picks between the two, setCommentHighlights with COMMENT_HIGHLIGHT / COMMENT_HIGHLIGHT_ACTIVE highlights the passages, and resolveCommentMarginLayout is the layout for callers that place cards themselves. New pattern: Comments.