# Composites

> Things the product needs that are built from existing components instead of having a design of their own — task details, the lightbox, embeds, tables, date picking and more.

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

Some things in the product have no design of their own: they are assembled from existing components. Build them as described here, without new colors, shapes or lines.

## When it applies

Whenever you need one of the things below. If what you need isn't here and no component fits, follow the six questions in the [README](https://raft-design.botiverse.build/#before-you-build-something-new) before adding anything.

## Structure

### Task details

A wider [Dialog](https://raft-design.botiverse.build/components/dialog.md): the title, one row of properties (Status, Assignee, Created by) in a [DescriptionList](https://raft-design.botiverse.build/components/description-list.md), the History in a collapsed [Timeline](https://raft-design.botiverse.build/components/timeline.md), then the task's thread and a [Composer](https://raft-design.botiverse.build/components/composer.md). Its size is in [Tasks](https://raft-design.botiverse.build/patterns/tasks.md).

### Image viewer

A full-screen `layer-backdrop` holding the image, previous and next, and one icon button to close ([Lightbox](https://raft-design.botiverse.build/components/lightbox.md)).

```tsx
import { Download, X } from "lucide-react";
import {
  Button,
  Lightbox,
  LightboxClose,
  LightboxContent,
  LightboxInfoActions,
  LightboxInfoBar,
  LightboxInfoDock,
  LightboxInfoTitle,
  LightboxMedia,
  LightboxStage,
  LightboxTitle,
  LightboxTrigger,
} from "@botiverse/raft-ui";

const DEMO_IMAGE_SRC =
  "https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1440&q=80";

export function ImageLightboxShellComposition() {
  return (
    <Lightbox>
      <LightboxTrigger render={<Button type="button" variant="default" />}>
        Open image lightbox
      </LightboxTrigger>
      <LightboxContent data-testid="image-lightbox">
        <LightboxTitle className="sr-only">product-shot.png</LightboxTitle>
        <LightboxClose aria-label="Close">
          <X />
        </LightboxClose>
        <LightboxStage>
          <LightboxMedia render={<img src={DEMO_IMAGE_SRC} alt="Product attachment" />} />
          <LightboxInfoDock>
            <LightboxInfoBar>
              <LightboxInfoTitle>
                release-card-final-approved-for-directory-cover.png
              </LightboxInfoTitle>
              <LightboxInfoActions>
                <Button type="button" variant="ghost" size="icon-sm" aria-label="Download">
                  <Download />
                </Button>
              </LightboxInfoActions>
            </LightboxInfoBar>
          </LightboxInfoDock>
        </LightboxStage>
      </LightboxContent>
    </Lightbox>
  );
}
```

### Link cards, forwarded messages, quoted previews

The structure of a file card ([MessageAttachment](https://raft-design.botiverse.build/components/message-attachment.md)) with the header of a message ([MessageItem](https://raft-design.botiverse.build/components/message-item.md)): [MessageEmbed](https://raft-design.botiverse.build/components/message-embed.md), [MessageForwardedBundle](https://raft-design.botiverse.build/components/message-forwarded-bundle.md), [MessageQuotedPreview](https://raft-design.botiverse.build/components/message-quoted-preview.md).

```tsx
import { useCallback, useState } from "react";
import {
  MESSAGE_FIXTURE_AVATAR_SRC,
  MESSAGE_PLAYGROUND_URLS,
} from "#/modules/message-item/message-item.fixtures.ts";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
  MessageItem,
  MessageItemAvatarSlot,
  MessageItemBody,
  MessageItemContent,
  MessageItemEmbedded,
  MessageItemHeader,
  MessageItemMeta,
  MessageItemSender,
  MessageItemTime,
} from "@botiverse/raft-ui";
import { Markdown } from "#/lib/markdown/markdown.tsx";
import { ChevronDown, ChevronUp, FileText, Forward } from "lucide-react";

import {
  MessageForwardedBundle,
  MessageForwardedBundleAttachment,
  MessageForwardedBundleAttachmentMeta,
  MessageForwardedBundleAttachmentName,
  MessageForwardedBundleAttachments,
  MessageForwardedBundleFooter,
  MessageForwardedBundleGallery,
  MessageForwardedBundleGalleryTile,
  MessageForwardedBundleHeader,
  MessageForwardedBundleHeaderContent,
  MessageForwardedBundleHeaderCount,
  MessageForwardedBundleHeaderLabel,
  MessageForwardedBundleItem,
  MessageForwardedBundleItemAuthor,
  MessageForwardedBundleItemContent,
  MessageForwardedBundleItemMeta,
  MessageForwardedBundleItems,
  MessageForwardedBundleSource,
  MessageForwardedBundleFooterAction,
} from "@botiverse/raft-ui";

export type ForwardedBundleCompositionItem = {
  id: string;
  author: string;
  createdAt: string;
  createdAtDateTime?: string;
  content: string;
  attachment?: { name: string; meta: string };
  images?: readonly string[];
};

const FORWARDED_FIXTURE_IMAGES = [
  "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='96'%20height='96'%3E%3Crect%20width='96'%20height='96'%20fill='%23dbeafe'/%3E%3Ccircle%20cx='48'%20cy='46'%20r='20'%20fill='%2360a5fa'/%3E%3C/svg%3E",
  "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='96'%20height='96'%3E%3Crect%20width='96'%20height='96'%20fill='%23dcfce7'/%3E%3Crect%20x='26'%20y='26'%20width='44'%20height='44'%20fill='%234ade80'/%3E%3C/svg%3E",
  "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='96'%20height='96'%3E%3Crect%20width='96'%20height='96'%20fill='%23fef3c7'/%3E%3Cpath%20d='M48%2028l20%2036H28z'%20fill='%23f59e0b'/%3E%3C/svg%3E",
  "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='96'%20height='96'%3E%3Crect%20width='96'%20height='96'%20fill='%23fce7f3'/%3E%3Ccircle%20cx='36'%20cy='40'%20r='14'%20fill='%23ec4899'/%3E%3Ccircle%20cx='62'%20cy='58'%20r='14'%20fill='%23f9a8d4'/%3E%3C/svg%3E",
];

export type ForwardedBundleEmbeddedCompositionProps = {
  items?: readonly ForwardedBundleCompositionItem[];
  sourceLabel?: string;
  sourceHref?: string | null;
  sourceAriaLabel?: string;
  sourceTitle?: string;
  collapsible?: boolean;
};

const DEFAULT_FORWARDED_ITEMS: readonly ForwardedBundleCompositionItem[] = [
  {
    id: "rollout",
    author: "Ada",
    createdAt: "Jul 15, 2:05 PM",
    createdAtDateTime: "2026-07-15T06:05:00.000Z",
    content: "Rollout is clear for the message reference migration.",
  },
  {
    id: "attachment-context",
    author: "Will",
    createdAt: "Jul 15, 2:09 PM",
    createdAtDateTime: "2026-07-15T06:09:00.000Z",
    content: "Keep the source case with its attachment context.",
    attachment: { name: "release-notes.md", meta: "markdown" },
  },
  {
    id: "review-context",
    author: "Mia",
    createdAt: "Jul 15, 2:12 PM",
    createdAtDateTime: "2026-07-15T06:12:00.000Z",
    content: "Keep the original context available to reviewers.",
    images: FORWARDED_FIXTURE_IMAGES,
  },
  {
    id: "thread-contract",
    author: "Nia",
    createdAt: "Jul 15, 2:16 PM",
    createdAtDateTime: "2026-07-15T06:16:00.000Z",
    content: "Follow the same message state contract in the thread.",
  },
];

function ForwardedBundleItemsContent({
  items,
}: {
  items: readonly ForwardedBundleCompositionItem[];
}) {
  return (
    <>
      {items.map((item) => (
        <MessageForwardedBundleItem key={item.id} data-testid="forwarded-bundle-item">
          <MessageForwardedBundleItemMeta>
            <MessageForwardedBundleItemAuthor>@{item.author}</MessageForwardedBundleItemAuthor>
            <span aria-hidden>·</span>
            <time dateTime={item.createdAtDateTime}>{item.createdAt}</time>
          </MessageForwardedBundleItemMeta>
          <MessageForwardedBundleItemContent>
            <Markdown>{item.content}</Markdown>
          </MessageForwardedBundleItemContent>
          {item.images?.length || item.attachment ? (
            <MessageForwardedBundleAttachments>
              {item.images?.length ? (
                <MessageForwardedBundleGallery>
                  {item.images.map((src, index) => (
                    <MessageForwardedBundleGalleryTile key={`${item.id}-image-${index}`}>
                      <img src={src} alt="" />
                    </MessageForwardedBundleGalleryTile>
                  ))}
                </MessageForwardedBundleGallery>
              ) : null}
              {item.attachment ? (
                <MessageForwardedBundleAttachment>
                  <FileText size={12} />
                  <MessageForwardedBundleAttachmentName>
                    {item.attachment.name}
                  </MessageForwardedBundleAttachmentName>
                  <MessageForwardedBundleAttachmentMeta>
                    · {item.attachment.meta}
                  </MessageForwardedBundleAttachmentMeta>
                </MessageForwardedBundleAttachment>
              ) : null}
            </MessageForwardedBundleAttachments>
          ) : null}
        </MessageForwardedBundleItem>
      ))}
    </>
  );
}

export function ForwardedBundleEmbeddedComposition({
  items = DEFAULT_FORWARDED_ITEMS,
  sourceLabel = "release-notes",
  sourceHref = MESSAGE_PLAYGROUND_URLS.releaseNotesThread,
  sourceAriaLabel = "Open source thread in release-notes",
  sourceTitle = "Open source message",
  collapsible = true,
}: ForwardedBundleEmbeddedCompositionProps = {}) {
  const [expanded, setExpanded] = useState(false);

  const handleToggleExpanded = useCallback(() => {
    setExpanded((current) => !current);
  }, []);

  return (
    <MessageItemEmbedded>
      <MessageForwardedBundle data-testid="forwarded-bundle-card">
        <MessageForwardedBundleHeader>
          <MessageForwardedBundleHeaderContent>
            <MessageForwardedBundleHeaderLabel>
              <Forward size={12} />
              Forwarded
            </MessageForwardedBundleHeaderLabel>
            <MessageForwardedBundleHeaderCount>
              {items.length} messages
            </MessageForwardedBundleHeaderCount>
          </MessageForwardedBundleHeaderContent>
          <MessageForwardedBundleSource
            data-testid="forwarded-bundle-source-label"
            title={sourceHref ? sourceTitle : undefined}
            render={sourceHref ? <a href={sourceHref} aria-label={sourceAriaLabel} /> : undefined}
          >
            from #{sourceLabel}
          </MessageForwardedBundleSource>
        </MessageForwardedBundleHeader>
        <MessageForwardedBundleItems collapsed={collapsible && !expanded}>
          <ForwardedBundleItemsContent items={items} />
        </MessageForwardedBundleItems>
        {collapsible ? (
          <MessageForwardedBundleFooter>
            <MessageForwardedBundleFooterAction onClick={handleToggleExpanded}>
              {expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
              {expanded ? "Collapse" : `Show all ${items.length} messages`}
            </MessageForwardedBundleFooterAction>
          </MessageForwardedBundleFooter>
        ) : null}
      </MessageForwardedBundle>
    </MessageItemEmbedded>
  );
}
```

```tsx
import { QuotedPreviewDirectMessageComposition } from "./quoted-preview-direct-message.composition.tsx";
import { QuotedPreviewLoadingComposition } from "./quoted-preview-loading.composition.tsx";
import { QuotedPreviewMessageComposition } from "./quoted-preview-message.composition.tsx";
import { QuotedPreviewUnavailableComposition } from "./quoted-preview-unavailable.composition.tsx";

export function MessageQuotedPreviewShowcaseComposition() {
  return (
    <div className="grid w-full max-w-3xl gap-4">
      <QuotedPreviewMessageComposition />
      <QuotedPreviewDirectMessageComposition />
      <QuotedPreviewLoadingComposition />
      <QuotedPreviewUnavailableComposition />
    </div>
  );
}
```

### Diagrams and Markdown tables

The same container as a code block ([CodeBlock](https://raft-design.botiverse.build/components/code-block.md)): `layer-code`, a hairline border, `radius-container`.

### Translation, read and saved markers

One more small chip in the row under the message, or a short line of Space Mono text ([MessageTranslation](https://raft-design.botiverse.build/components/message-translation.md)). Don't add a color for them.

```tsx
import { MessageTranslationBilingualComposition } from "./bilingual.composition.tsx";
import { MessageTranslationStatesComposition } from "./translation-states.composition.tsx";
import { MessageTranslationTranslatedComposition } from "./translated.composition.tsx";

export function MessageTranslationShowcaseComposition() {
  return (
    <div className="flex w-full max-w-3xl flex-col">
      <MessageTranslationTranslatedComposition />
      <MessageTranslationBilingualComposition />
      <MessageTranslationStatesComposition />
    </div>
  );
}
```

### Toolbar for selected messages

`MessageMultiSelectToolbar` at the bottom of the conversation ([MessageItem](https://raft-design.botiverse.build/components/message-item.md)).

```tsx
import { useCallback, useState } from "react";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
  MessageItemAvatarSlot,
  MessageItemBody,
  MessageItemContent,
  MessageItemHeader,
  MessageItem,
  MessageItemSender,
  MessageItemTime,
  MessageMultiSelect,
  MessageMultiSelectCheckbox,
  Button,
} from "@botiverse/raft-ui";

import { MESSAGE_FIXTURE_AVATAR_SRC } from "../message-item.fixtures.ts";
import { MessageMultiSelectToolbarComposition } from "./selection.compositions.tsx";

export function MessageSelectionShowcaseComposition() {
  const [active, setActive] = useState(true);
  const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set(["selected-row"]));

  const handleReenter = useCallback(() => {
    setActive(true);
    setSelected(new Set(["selected-row"]));
  }, []);

  const handleCheckedChange = useCallback((checked: boolean) => {
    setSelected(checked ? new Set(["selected-row"]) : new Set());
  }, []);

  const handleCancel = useCallback(() => {
    setActive(false);
    setSelected(new Set());
  }, []);

  if (!active) {
    return (
      <Button variant="link" size="sm" onClick={handleReenter}>
        Re-enter multi-select mode
      </Button>
    );
  }

  return (
    <div className="grid w-full max-w-3xl gap-6">
      <MessageMultiSelect>
        <MessageItem>
          <MessageMultiSelectCheckbox
            aria-label="Select message"
            checked={selected.has("selected-row")}
            value="selected-row"
            onCheckedChange={handleCheckedChange}
          />
          <MessageItemAvatarSlot>
            <Avatar size="md" type="agent">
              <AvatarImage src={MESSAGE_FIXTURE_AVATAR_SRC} alt="" />
              <AvatarFallback>AT</AvatarFallback>
            </Avatar>
          </MessageItemAvatarSlot>
          <MessageItemContent>
            <MessageItemHeader>
              <MessageItemSender>Selected row</MessageItemSender>
              <MessageItemTime>3:18 PM</MessageItemTime>
            </MessageItemHeader>
            <MessageItemBody>Selection is represented by a real checkbox.</MessageItemBody>
          </MessageItemContent>
        </MessageItem>
      </MessageMultiSelect>

      <div className="grid gap-2">
        <MessageMultiSelectToolbarComposition onCancel={handleCancel}>
          {selected.size} selected
        </MessageMultiSelectToolbarComposition>
        <p className="text-xs text-foreground-muted">
          Copy, Forward, and Generate image are disabled in this isolated example because it has no
          selected-message payload.
        </p>
      </div>
    </div>
  );
}
```

### Resizable columns

The seam between two columns is the only line, and the drag handle sits on it ([Resizable](https://raft-design.botiverse.build/components/resizable.md)).

### Tables

Prefer a [DescriptionList](https://raft-design.botiverse.build/components/description-list.md) or [ListRow](https://raft-design.botiverse.build/components/list-row.md) rows. When it has to be a table, such as usage or billing ([Table](https://raft-design.botiverse.build/components/table.md)):

- Set the header in `text-footnote`, `foreground-hint`.
- Divide rows with `line-hairline`.
- Set numbers in Space Mono, right-aligned.

### Picking a date

A column of dates inside the popup of a [Select](https://raft-design.botiverse.build/components/select.md), with the dates in Space Mono.

### A row of toggle buttons

A row of filters ([ToggleGroup](https://raft-design.botiverse.build/components/toggle-group.md)), each one selectable on its own.

```tsx
import { Bold, Italic, Underline } from "lucide-react";
import { ToggleGroup, ToggleGroupItem, ToggleGroupLabel } from "@botiverse/raft-ui";

const DEFAULT_VALUE = ["bold"];

export function ToggleGroupFormattingComposition() {
  return (
    <ToggleGroup multiple defaultValue={DEFAULT_VALUE} aria-label="Text formatting">
      <ToggleGroupItem value="bold">
        <Bold aria-hidden />
        <ToggleGroupLabel>Bold</ToggleGroupLabel>
      </ToggleGroupItem>
      <ToggleGroupItem value="italic">
        <Italic aria-hidden />
        <ToggleGroupLabel>Italic</ToggleGroupLabel>
      </ToggleGroupItem>
      <ToggleGroupItem value="underline">
        <Underline aria-hidden />
        <ToggleGroupLabel>Underline</ToggleGroupLabel>
      </ToggleGroupItem>
    </ToggleGroup>
  );
}
```

### Authorization (OAuth)

The structure of a [Dialog](https://raft-design.botiverse.build/components/dialog.md), with the requested permissions listed in a [DescriptionList](https://raft-design.botiverse.build/components/description-list.md).

### Switching workspaces

- On a phone, the workspace pill opens a bottom sheet with the list ([Drawer](https://raft-design.botiverse.build/components/drawer.md)).
- On desktop, the tile at the top of the rail opens the same list as a menu next to the tile ([DropdownMenu](https://raft-design.botiverse.build/components/dropdown-menu.md)).
- Make each row the workspace tile, its name and its unread count, with a check on the current workspace.

## Related

- [Choosing a component](https://raft-design.botiverse.build/patterns/choosing-components.md).
- [Conversation](https://raft-design.botiverse.build/patterns/conversation.md): where most of these appear.
