# 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.

Source: https://raft-design.botiverse.build/components/comment. Generated from the current release; where it differs from what you remember, this page is right.

The parts for comments on a document: `CommentMargin` lays out a column of `CommentCard`s 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](https://raft-design.botiverse.build/patterns/comments.md); this page covers the parts.

```tsx
import { ArrowUp, MessageSquare } from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { ChangeEvent, FormEvent, KeyboardEvent, MouseEvent } from "react";
import {
  Avatar,
  AvatarFallback,
  Card,
  CardContent,
  Composer,
  ComposerInput,
  ComposerMeta,
  ComposerRoot,
  ComposerSubmit,
  ComposerToolbar,
} from "@botiverse/raft-ui";
import {
  CommentCard,
  CommentCardMeta,
  CommentCardQuote,
  CommentCardTrigger,
  CommentEntry,
  CommentEntryAuthor,
  CommentEntryBody,
  CommentEntryHeader,
  CommentEntryTime,
  CommentMargin,
  CommentMarginItem,
} from "@botiverse/raft-ui/wip";

import { useCommentAnchors, useCommentHighlights } from "../comment-anchors.ts";
import { ReleaseChecklist } from "../release-checklist.tsx";
import { ANCHORED, DISCUSSIONS, QUOTES } from "../release-checklist-comments.ts";
import type { Comment, DiscussionId } from "../release-checklist-comments.ts";

export function DiscussionEntry({ comment, clamp }: { comment: Comment; clamp?: boolean }) {
  return (
    <CommentEntry>
      <CommentEntryHeader>
        <Avatar size="xs" type={comment.type}>
          <AvatarFallback>{comment.author[0]}</AvatarFallback>
        </Avatar>
        <CommentEntryAuthor>{comment.author}</CommentEntryAuthor>
        <CommentEntryTime dateTime={comment.dateTime}>{comment.time}</CommentEntryTime>
      </CommentEntryHeader>
      <CommentEntryBody clamp={clamp}>{comment.text}</CommentEntryBody>
    </CommentEntry>
  );
}

/**
 * One discussion. Collapsed, it shows its first comment cut to 2 lines and
 * the number of replies, and the whole card opens it. Open, it shows every
 * comment and the reply box, and takes focus: the reply box when it was
 * opened from the keyboard, the card itself after a click. Esc closes it and
 * puts focus back on the collapsed card.
 */
export function Discussion({
  id,
  note,
  quote,
  active,
  onActiveChange,
}: {
  id: DiscussionId;
  /** A line about where it belongs: the whole document, or a lost position. */
  note?: string;
  /** The passage it is about, for screen readers. */
  quote?: string;
  active: boolean;
  onActiveChange: (id: DiscussionId | null) => void;
}) {
  const [comments, setComments] = useState(DISCUSSIONS[id]);
  const [draft, setDraft] = useState("");
  const card = useRef<HTMLDivElement>(null);
  const trigger = useRef<HTMLButtonElement>(null);
  const input = useRef<HTMLTextAreaElement>(null);
  const focusNext = useRef<"input" | "card" | "trigger" | null>(null);
  const noteId = useId();
  const quoteId = useId();
  const [first, ...replies] = comments;

  useEffect(() => {
    const next = focusNext.current;
    focusNext.current = null;
    if (next === "input") input.current?.focus({ preventScroll: true });
    if (next === "card") card.current?.focus({ preventScroll: true });
    if (next === "trigger") trigger.current?.focus({ preventScroll: true });
  }, [active]);

  const open = useCallback(
    (event: MouseEvent<HTMLButtonElement>) => {
      // A click from Enter or Space has no pointer detail: go to the reply box.
      focusNext.current = event.detail === 0 ? "input" : "card";
      onActiveChange(id);
    },
    [id, onActiveChange],
  );

  const close = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (event.key !== "Escape") return;
      focusNext.current = "trigger";
      onActiveChange(null);
    },
    [onActiveChange],
  );

  const changeDraft = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
    setDraft(event.currentTarget.value);
  }, []);

  const reply = useCallback(
    (event: FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      const text = draft.trim();
      if (!text) return;
      setComments((current) => [
        ...current,
        {
          author: "Martin",
          type: "human",
          time: "11:12",
          dateTime: "2026-10-20T11:12:00+08:00",
          text,
        },
      ]);
      setDraft("");
    },
    [draft],
  );

  const where = (
    <>
      {note ? <CommentCardMeta id={noteId}>{note}</CommentCardMeta> : null}
      {quote ? <CommentCardQuote id={quoteId}>{`On “${quote}”`}</CommentCardQuote> : null}
    </>
  );

  if (!active || !first) {
    return (
      <CommentCard>
        {where}
        {first ? <DiscussionEntry comment={first} clamp /> : null}
        {replies.length > 0 ? (
          <CommentCardMeta>
            <MessageSquare aria-hidden />
            {replies.length === 1 ? "1 reply" : `${replies.length} replies`}
          </CommentCardMeta>
        ) : null}
        <CommentCardTrigger
          ref={trigger}
          aria-label={`Comment from ${first?.author}`}
          aria-describedby={
            [note ? noteId : "", quote ? quoteId : ""].join(" ").trim() || undefined
          }
          onClick={open}
        />
      </CommentCard>
    );
  }

  return (
    <CommentCard active ref={card} tabIndex={-1} onKeyDown={close}>
      {where}
      {comments.map((comment) => (
        <DiscussionEntry key={comment.dateTime} comment={comment} />
      ))}
      <ComposerRoot onSubmit={reply}>
        <Composer>
          <ComposerInput
            ref={input}
            aria-label="Reply"
            placeholder="Reply"
            value={draft}
            onChange={changeDraft}
          />
          <ComposerToolbar>
            <ComposerMeta className="ms-auto">
              <ComposerSubmit size="icon-sm" aria-label="Send" disabled={!draft.trim()}>
                <ArrowUp />
              </ComposerSubmit>
            </ComposerMeta>
          </ComposerToolbar>
        </Composer>
      </ComposerRoot>
    </CommentCard>
  );
}

export function CommentMarginContent() {
  const [documentElement, setDocumentElement] = useState<HTMLElement | null>(null);
  const [marginElement, setMarginElement] = useState<HTMLElement | null>(null);
  const [activeId, setActiveId] = useState<DiscussionId | null>("rollout");
  const { ranges, tops, ready } = useCommentAnchors(documentElement, marginElement, QUOTES);
  useCommentHighlights(ranges, activeId);
  // A discussion whose passage isn't in the document any more goes to the
  // top, after the comments on the whole document; the rest follow the text.
  const anchored = ready ? [...ANCHORED].sort((a, b) => (tops[a] ?? 0) - (tops[b] ?? 0)) : [];

  return (
    <div className="flex w-full max-w-[920px] items-start gap-6">
      <Card className="min-w-0 flex-1">
        <CardContent ref={setDocumentElement}>
          <ReleaseChecklist />
        </CardContent>
      </Card>
      <CommentMargin
        ref={setMarginElement}
        render={<aside aria-label="Comments" />}
        className="w-[280px] shrink-0"
      >
        <CommentMarginItem top={0} active={activeId === "document"}>
          <Discussion
            id="document"
            note="On the whole document"
            active={activeId === "document"}
            onActiveChange={setActiveId}
          />
        </CommentMarginItem>
        {anchored.map((id) => (
          <CommentMarginItem key={id} top={tops[id] ?? 0} active={activeId === id}>
            <Discussion
              id={id}
              note={ranges[id] ? undefined : "Position not found"}
              quote={QUOTES[id]}
              active={activeId === id}
              onActiveChange={setActiveId}
            />
          </CommentMarginItem>
        ))}
      </CommentMargin>
    </div>
  );
}
```

## When to use

| Situation                                                                       | Use                                                                                                  |
| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Comments on a document or on passages of it, in a viewer with room for a margin | `CommentMargin` with a `CommentCard` per discussion                                                  |
| The same comments where the margin doesn't fit                                  | A `CommentMarker` beside each passage, opening a bottom sheet ([Drawer](https://raft-design.botiverse.build/components/drawer.md))        |
| A reply to a message in a conversation                                          | A thread ([ThreadPanel](https://raft-design.botiverse.build/components/thread-panel.md)), not a comment                             |
| The number of comments on a file, on its card in a message                      | `MessageAttachmentCommentSummary` ([MessageAttachment](https://raft-design.botiverse.build/components/message-attachment.md)) |

## 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](https://raft-design.botiverse.build/components/avatar.md) 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](https://raft-design.botiverse.build/components/composer.md) (`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](#wide-and-narrow)).
- `setCommentHighlights`, `COMMENT_HIGHLIGHT`, `COMMENT_HIGHLIGHT_ACTIVE`: highlight the passages ([Highlights](#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](https://raft-design.botiverse.build/foundations/typography.md#type-scale)). The header uses a message's styles ([MessageItem](https://raft-design.botiverse.build/components/message-item.md)): bold name, then the time in Space Mono.

## States

```tsx
import { MessageSquare, TextSearch } from "lucide-react";
import { Avatar, AvatarFallback } from "@botiverse/raft-ui";
import {
  CommentCard,
  CommentCardMeta,
  CommentEntry,
  CommentEntryAuthor,
  CommentEntryBody,
  CommentEntryHeader,
  CommentEntryTime,
} from "@botiverse/raft-ui/wip";

export function CommentCardStatesContent() {
  return (
    <div className="flex w-full flex-wrap items-start justify-center gap-6">
      <CommentCard className="w-[280px]">
        <CommentEntry>
          <CommentEntryHeader>
            <Avatar size="xs" type="human">
              <AvatarFallback>M</AvatarFallback>
            </Avatar>
            <CommentEntryAuthor>Martin</CommentEntryAuthor>
            <CommentEntryTime dateTime="2026-10-20T10:53:00+08:00">10:53</CommentEntryTime>
          </CommentEntryHeader>
          <CommentEntryBody clamp>
            Can we link the rollback runbook from this step? Whoever runs it will want it open, and
            the old wiki page is out of date.
          </CommentEntryBody>
        </CommentEntry>
        <CommentCardMeta>
          <MessageSquare aria-hidden />2 replies
        </CommentCardMeta>
      </CommentCard>

      <CommentCard active className="w-[280px]">
        <CommentEntry>
          <CommentEntryHeader>
            <Avatar size="xs" type="agent">
              <AvatarFallback>W</AvatarFallback>
            </Avatar>
            <CommentEntryAuthor>Wug</CommentEntryAuthor>
            <CommentEntryTime dateTime="2026-10-20T11:02:00+08:00">11:02</CommentEntryTime>
          </CommentEntryHeader>
          <CommentEntryBody>
            An hour feels short for the error rate. Can we watch it until the next morning?
          </CommentEntryBody>
        </CommentEntry>
        <CommentEntry>
          <CommentEntryHeader>
            <Avatar size="xs" type="human">
              <AvatarFallback>M</AvatarFallback>
            </Avatar>
            <CommentEntryAuthor>Martin</CommentEntryAuthor>
            <CommentEntryTime dateTime="2026-10-20T11:05:00+08:00">11:05</CommentEntryTime>
          </CommentEntryHeader>
          <CommentEntryBody>
            Agreed, overnight with the dashboard pinned in #launch-week.
          </CommentEntryBody>
        </CommentEntry>
      </CommentCard>

      <CommentCard className="w-[280px]">
        <CommentCardMeta>
          <TextSearch aria-hidden />
          Position not found
        </CommentCardMeta>
        <CommentEntry>
          <CommentEntryHeader>
            <Avatar size="xs" type="agent">
              <AvatarFallback>B</AvatarFallback>
            </Avatar>
            <CommentEntryAuthor>Bugen</CommentEntryAuthor>
            <CommentEntryTime dateTime="2026-10-19T17:20:00+08:00">
              Yesterday 17:20
            </CommentEntryTime>
          </CommentEntryHeader>
          <CommentEntryBody clamp>
            The staging smoke test needs the new fixtures before this step can run.
          </CommentEntryBody>
        </CommentEntry>
      </CommentCard>
    </div>
  );
}
```

| State                 | Look                                                                                                                                         |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Collapsed             | The first comment, its text cut to 2 lines (`clamp`), and the number of replies in `CommentCardMeta`. `CommentCardTrigger` opens it          |
| Active                | `active`: 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 document | `CommentCardMeta` first, saying so. Its `CommentMarginItem` has `top={0}`                                                                    |
| Position not found    | `CommentCardMeta` 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](https://raft-design.botiverse.build/patterns/comments.md#rules)).

### 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.

```tsx
import { useState } from "react";
import { useCommentMarginFits } from "@botiverse/raft-ui/wip";

import { CommentMarginContent } from "./margin.composition.tsx";
import { CommentMarkerSheetContent } from "./marker-sheet.composition.tsx";

/**
 * The document viewer decides which comments it shows: the margin of cards on
 * a desktop with room for it (784px), markers and a bottom sheet everywhere
 * else. The two are different trees, so the viewer measures itself.
 */
export function CommentViewerContent() {
  const [viewer, setViewer] = useState<HTMLDivElement | null>(null);
  const fits = useCommentMarginFits(viewer, { minWidth: 784 });

  return (
    <div ref={setViewer} className="flex w-full justify-center">
      {fits ? <CommentMarginContent /> : <CommentMarkerSheetContent />}
    </div>
  );
}
```

- 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](https://raft-design.botiverse.build/components/drawer.md)). The marker at the top holds the comments on the whole document and those whose position is lost.

```tsx
import { ArrowUp } from "lucide-react";
import { useCallback, useId, useMemo, useState } from "react";
import type { ChangeEvent, FormEvent } from "react";
import {
  Avatar,
  AvatarFallback,
  AvatarGroup,
  Button,
  Card,
  CardContent,
  Composer,
  ComposerInput,
  ComposerMeta,
  ComposerRoot,
  ComposerSubmit,
  ComposerToolbar,
  Drawer,
  DrawerActions,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
} from "@botiverse/raft-ui";
import {
  CommentCardMeta,
  CommentCardQuote,
  CommentMargin,
  CommentMarginItem,
  CommentMarker,
} from "@botiverse/raft-ui/wip";

import { useCommentAnchors, useCommentHighlights } from "../comment-anchors.ts";
import { ReleaseChecklist } from "../release-checklist.tsx";
import { ANCHORED, DISCUSSIONS, QUOTES } from "../release-checklist-comments.ts";
import type { Comment, DiscussionId } from "../release-checklist-comments.ts";
import { DiscussionEntry } from "./margin.composition.tsx";

const count = (comments: Comment[]) =>
  comments.length === 1 ? "1 comment" : `${comments.length} comments`;

/** A marker beside a passage, or at the top for the whole document and lost positions. */
function Marker({
  id,
  comments,
  description,
  open,
  onOpen,
}: {
  id: DiscussionId;
  comments: Comment[];
  description: string;
  open: boolean;
  onOpen: (id: DiscussionId) => void;
}) {
  const descriptionId = useId();
  const openThis = useCallback(() => onOpen(id), [id, onOpen]);
  const authors = [...new Map(comments.map((comment) => [comment.author, comment])).values()];
  return (
    <>
      <CommentMarker
        active={open}
        aria-label={count(comments)}
        aria-describedby={descriptionId}
        onClick={openThis}
      >
        <AvatarGroup>
          {authors.slice(0, 3).map((author) => (
            <Avatar key={author.author} size="2xs" type={author.type}>
              <AvatarFallback>{author.author[0]}</AvatarFallback>
            </Avatar>
          ))}
        </AvatarGroup>
        {comments.length}
      </CommentMarker>
      <CommentCardQuote id={descriptionId}>{description}</CommentCardQuote>
    </>
  );
}

export function CommentMarkerSheetContent() {
  const [documentElement, setDocumentElement] = useState<HTMLElement | null>(null);
  const [marginElement, setMarginElement] = useState<HTMLElement | null>(null);
  const [openId, setOpenId] = useState<DiscussionId | null>(null);
  const [draft, setDraft] = useState("");
  const [sent, setSent] = useState<Partial<Record<DiscussionId, Comment[]>>>({});
  const { ranges, tops, ready } = useCommentAnchors(documentElement, marginElement, QUOTES);
  useCommentHighlights(ranges, openId);

  const thread = useCallback(
    (id: DiscussionId) => [...DISCUSSIONS[id], ...(sent[id] ?? [])],
    [sent],
  );
  // Discussions whose passage isn't in the document any more go in the top
  // marker's sheet, after the comments on the whole document.
  const lost = useMemo(() => (ready ? ANCHORED.filter((id) => !ranges[id]) : []), [ready, ranges]);
  const found = ANCHORED.filter((id) => ranges[id] && tops[id] !== undefined);
  const top = useMemo(() => [...thread("document"), ...lost.flatMap(thread)], [thread, lost]);
  const comments = openId === "document" ? top : openId ? thread(openId) : [];

  const changeOpen = useCallback((open: boolean) => {
    if (!open) setOpenId(null);
  }, []);

  const changeDraft = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
    setDraft(event.currentTarget.value);
  }, []);

  const reply = useCallback(
    (event: FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      const text = draft.trim();
      if (!openId || !text) return;
      const comment: Comment = {
        author: "Martin",
        type: "human",
        time: "11:12",
        dateTime: "2026-10-20T11:12:00+08:00",
        text,
      };
      setSent((current) => ({ ...current, [openId]: [...(current[openId] ?? []), comment] }));
      setDraft("");
    },
    [draft, openId],
  );

  return (
    <div className="flex w-full max-w-[390px] items-start gap-2">
      <Card className="min-w-0 flex-1">
        <CardContent ref={setDocumentElement}>
          <ReleaseChecklist />
        </CardContent>
      </Card>
      <CommentMargin
        ref={setMarginElement}
        render={<aside aria-label="Comments" />}
        className="w-18 shrink-0"
        gap={4}
      >
        <CommentMarginItem top={0}>
          <Marker
            id="document"
            comments={top}
            description={
              lost.length > 0
                ? "On the whole document, and comments whose position was not found"
                : "On the whole document"
            }
            open={openId === "document"}
            onOpen={setOpenId}
          />
        </CommentMarginItem>
        {found.map((id) => (
          <CommentMarginItem key={id} top={tops[id] ?? 0}>
            <Marker
              id={id}
              comments={thread(id)}
              description={`On “${QUOTES[id]}”`}
              open={openId === id}
              onOpen={setOpenId}
            />
          </CommentMarginItem>
        ))}
      </CommentMargin>

      <Drawer open={openId !== null} onOpenChange={changeOpen}>
        <DrawerContent>
          <DrawerHeader>
            <DrawerTitle>{count(comments)}</DrawerTitle>
            <DrawerDescription>
              {openId === "document"
                ? "On the whole document"
                : openId
                  ? `“${QUOTES[openId as keyof typeof QUOTES]}”`
                  : null}
            </DrawerDescription>
          </DrawerHeader>
          <DrawerBody className="gap-4 overflow-y-auto">
            {(openId === "document" ? thread("document") : comments).map((comment) => (
              <DiscussionEntry key={comment.dateTime} comment={comment} />
            ))}
            <ComposerRoot onSubmit={reply}>
              <Composer>
                <ComposerInput
                  aria-label="Reply"
                  placeholder="Reply"
                  value={draft}
                  onChange={changeDraft}
                />
                <ComposerToolbar>
                  <ComposerMeta className="ms-auto">
                    <ComposerSubmit size="icon-sm" aria-label="Send" disabled={!draft.trim()}>
                      <ArrowUp />
                    </ComposerSubmit>
                  </ComposerMeta>
                </ComposerToolbar>
              </Composer>
            </ComposerRoot>
            {openId === "document"
              ? lost.map((id) => (
                  <section key={id} className="flex flex-col gap-2" aria-label="Position not found">
                    <CommentCardMeta>Position not found</CommentCardMeta>
                    <CommentCardQuote>{`Was on “${QUOTES[id]}”`}</CommentCardQuote>
                    {thread(id).map((comment) => (
                      <DiscussionEntry key={comment.dateTime} comment={comment} />
                    ))}
                  </section>
                ))
              : null}
          </DrawerBody>
          <DrawerFooter>
            <DrawerActions>
              <DrawerClose render={<Button variant="outline" size="lg" />}>Close</DrawerClose>
            </DrawerActions>
          </DrawerFooter>
        </DrawerContent>
      </Drawer>
    </div>
  );
}
```

### Highlights

The passages are highlighted with the CSS Custom Highlight API: `setCommentHighlights(name, ranges)` registers `Range`s 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](https://raft-design.botiverse.build/content/writing.md#times)).

## 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](https://raft-design.botiverse.build/components/button.md#which-color)).
- Use the Composer for the reply box ([Composer](https://raft-design.botiverse.build/components/composer.md)); don't build another input.

## API

### CommentCard

The 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`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `active` | `boolean` | `false` | The discussion people are reading or replying to: an ink border, lifted. |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentCardMeta

A 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentCardQuote

The passage the discussion is about, in the caller's words (usually the quoted text). Hidden on screen, where the card's place beside the highlighted passage says it; give it an `id` and point the `aria-describedby` of `CommentCardTrigger` or `CommentMarker` at it, so a screen reader says which passage a card is about.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentCardTrigger

A button laid over the whole card, for a collapsed card that opens when it is clicked or chosen with the keyboard. It has no text of its own: give it an `aria-label` ("Comment from Martin").

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentEntry

One comment in a discussion: its header, then its text.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentEntryAuthor

Who wrote the comment, in bold. It truncates; the time after it doesn't.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentEntryBody

What the comment says.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `clamp` | `boolean` | `false` | Cuts the text to 2 lines, for a collapsed card. |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentEntryHeader

One line: the author's `Avatar` (`size="xs"`), `CommentEntryAuthor`, then `CommentEntryTime`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentEntryTime

When the comment was written, right after the author. A `<time>`: pass the moment in `dateTime` (ISO 8601).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentMargin

The 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `gap` | `number` | `8` | Space between two cards that would otherwise touch, in px. Default 8. |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentMarginItem

One 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`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `active` | `boolean` | `false` | Pins this card to its anchor and moves the others around it. |
| `top` (required) | `number` |  | Where the card's anchor is: its y, in px, from the top of the margin. |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### CommentMarker

Where 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").

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `active` | `boolean` | `false` | The marker of the discussion that is open. |
| `focusableWhenDisabled` | `boolean` | `false` | Whether the button should be focusable when disabled. |
| `nativeButton` | `boolean` | `true` | Whether 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>`). |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

## Changes

- 0.10.6 (Added): Comments 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.
