# AppLinkCard

> The card of an app link drawn in the message itself, where the app's answer lists one, with Raft's header naming the app, the item in the app's words, and buttons that work by keyboard and touch.

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

An app link's card drawn in the message, after the link's paragraph, for an answer whose `items` list a `card` with `placement: "block"` (protocol version 3). It shows the item as the hover card does ([PreviewCard](https://raft-design.botiverse.build/components/preview-card.md#item-card)), under a header in which Raft names the app; its title opens the item, and its buttons can be pressed right there, by mouse, keyboard or touch. The card never replaces the link: the link stays as the answer lists it, a chip with `inline` and the author's plain link without. What an answer lists, where the card goes in a message, and when Raft draws none are in [Navigation](https://raft-design.botiverse.build/patterns/navigation.md#block-cards).

```tsx
import { ArrowUpRight, Ellipsis } from "lucide-react";
import { useCallback, useId, useState } from "react";
import {
  Badge,
  Button,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItemLabel,
  DropdownMenuTrigger,
} from "@botiverse/raft-ui";
import {
  AppLinkCard,
  AppLinkCardActions,
  AppLinkCardBody,
  AppLinkCardEyebrow,
  AppLinkCardHeader,
  AppLinkCardHost,
  AppLinkCardIcon,
  AppLinkCardMeta,
  AppLinkCardName,
  AppLinkCardTitle,
  DropdownMenuLinkItem,
} from "@botiverse/raft-ui/wip";

// Two buttons, the app's first two in its order (View changes, a link, and
// Approve), and ⋯ for the rest: the title opens the item, so Open in Stamp
// goes in the menu.
export function AppLinkCardWithActionsComposition() {
  const titleId = useId();
  const [approved, setApproved] = useState(false);
  const handleApprove = useCallback(() => setApproved(true), []);

  return (
    <div className="flex w-full justify-center p-6">
      <AppLinkCard aria-labelledby={titleId}>
        <AppLinkCardHeader>
          <AppLinkCardIcon>
            <img src="/app-icons/stamp.svg" alt="" />
          </AppLinkCardIcon>
          <AppLinkCardName>Stamp</AppLinkCardName>
          <AppLinkCardHost>stamp.build</AppLinkCardHost>
        </AppLinkCardHeader>
        <AppLinkCardBody>
          <AppLinkCardEyebrow>botiverse/slock</AppLinkCardEyebrow>
          <AppLinkCardTitle id={titleId}>
            <a href="#stamp-pull-10451" target="_blank" rel="noopener noreferrer">
              #10451 test(i18n): cover the zh-CN fallback when the locale cookie is missing
            </a>
          </AppLinkCardTitle>
          <AppLinkCardMeta>
            <Badge appearance="soft">{approved ? "Approved" : "Review requested"}</Badge>
            <span>Jasper · +112 −8</span>
          </AppLinkCardMeta>
        </AppLinkCardBody>
        <AppLinkCardActions>
          <DropdownMenu>
            <DropdownMenuTrigger
              render={<Button variant="ghost" size="icon-md" aria-label="More" />}
            >
              <Ellipsis />
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuLinkItem
                href="#stamp-pull-10451"
                target="_blank"
                rel="noopener noreferrer"
              >
                <DropdownMenuItemLabel>Open in Stamp</DropdownMenuItemLabel>
                <ArrowUpRight role="img" aria-label="(opens in a new tab)" />
              </DropdownMenuLinkItem>
            </DropdownMenuContent>
          </DropdownMenu>
          <Button
            variant="ghost"
            render={<a href="#stamp-pull-10451-files" target="_blank" rel="noopener noreferrer" />}
          >
            View changes
            <ArrowUpRight data-icon="inline-end" role="img" aria-label="(opens in a new tab)" />
          </Button>
          <Button variant="outline" disabled={approved} onClick={handleApprove}>
            Approve
          </Button>
        </AppLinkCardActions>
      </AppLinkCard>
    </div>
  );
}
```

## When to use

| Situation                                                          | Use                                                                     |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| An app link whose answer lists a block card, in the message itself | AppLinkCard                                                             |
| The same answer, while Raft fetches it again from its cache        | `AppLinkCardPlaceholder`                                                |
| An answer that lists `inline`, wherever the link is drawn          | A chip ([MessageItem](https://raft-design.botiverse.build/components/message-item.md#app-links))       |
| An answer that lists a hover card                                  | An item card ([PreviewCard](https://raft-design.botiverse.build/components/preview-card.md#item-card)) |
| A link to a page no app previews                                   | A link card ([MessageEmbed](https://raft-design.botiverse.build/components/message-embed.md))         |

## Anatomy

All parts are experimental and exported from `@botiverse/raft-ui/wip`.

- `AppLinkCard`: the card, on the hairline frame of a link card. Required.
- `AppLinkCardHeader`: Raft's row at the top, under a hairline. Required: it says whose card this is.
  - `AppLinkCardIcon`: the app's registered icon, 16px.
  - `AppLinkCardName`: the app's name.
  - `AppLinkCardHost`: the link's host, after a "·".
- `AppLinkCardBody`: the item, 12px from the card's edges, as in every card in a message ([Cards in messages](https://raft-design.botiverse.build/patterns/cards-in-messages.md#one-frame-one-inset)).
  - `AppLinkCardEyebrow`: the app's id for the item, in muted Space Mono, one line.
  - `AppLinkCardTitle`: the app's title, at `text-subhead`, up to 2 lines, then an ellipsis. Put a link to the item in it: the title opens the item.
  - `AppLinkCardMeta`: one state in a neutral Badge, then a muted line of details. It wraps when it runs out of room.
- `AppLinkCardActions`: the app's buttons, at the card's bottom end.
- `AppLinkCardPlaceholder`: in place of `AppLinkCard` while the answer loads. It holds the header and draws the item's lines and a button as gray bars.

The body is the hover card's: the same parts in the same order, with the same type and colors, drawn from the same recipe (`messageCard`), so a reader who has seen one reads the other and a change to one is a change to both. Three things differ, because this card is in the page: its title is a link to the item; a link to the item itself goes in ⋯ instead of taking a button's place; and its row takes the app's first two buttons whatever they are, where the hover card's takes only the app's actions (with none, its first link) and puts Refresh and further links in ⋯ ([PreviewCard](https://raft-design.botiverse.build/components/preview-card.md#item-card)). The hover card opens only under a pointer, for a glance at what someone can decide there; this card is read and used in place, by keyboard and touch too, so its row shows the app's buttons in the app's own order, and only the link the title already opens gives up its place.

```tsx
import { ArrowUpRight, Ellipsis } from "lucide-react";
import { useId } from "react";
import {
  Badge,
  Button,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItemLabel,
  DropdownMenuTrigger,
} from "@botiverse/raft-ui";
import {
  AppLinkCard,
  AppLinkCardActions,
  AppLinkCardBody,
  AppLinkCardEyebrow,
  AppLinkCardHeader,
  AppLinkCardHost,
  AppLinkCardIcon,
  AppLinkCardMeta,
  AppLinkCardName,
  AppLinkCardTitle,
  DropdownMenuLinkItem,
} from "@botiverse/raft-ui/wip";

// A card with no actions: the title opens the item, and Open in Stamp waits
// in ⋯.
export function AppLinkCardDefaultComposition() {
  const titleId = useId();
  return (
    <div className="flex w-full justify-center p-6">
      <AppLinkCard aria-labelledby={titleId}>
        <AppLinkCardHeader>
          <AppLinkCardIcon>
            <img src="/app-icons/stamp.svg" alt="" />
          </AppLinkCardIcon>
          <AppLinkCardName>Stamp</AppLinkCardName>
          <AppLinkCardHost>stamp.build</AppLinkCardHost>
        </AppLinkCardHeader>
        <AppLinkCardBody>
          <AppLinkCardEyebrow>botiverse/slock</AppLinkCardEyebrow>
          <AppLinkCardTitle id={titleId}>
            <a href="#stamp-pull-10449" target="_blank" rel="noopener noreferrer">
              #10449 refactor(i18n): one display-locale source for web and desktop
            </a>
          </AppLinkCardTitle>
          <AppLinkCardMeta>
            <Badge appearance="soft">Merged</Badge>
            <span>Martin · +803 −498</span>
          </AppLinkCardMeta>
        </AppLinkCardBody>
        <AppLinkCardActions>
          <DropdownMenu>
            <DropdownMenuTrigger
              render={<Button variant="ghost" size="icon-md" aria-label="More" />}
            >
              <Ellipsis />
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuLinkItem
                href="#stamp-pull-10449"
                target="_blank"
                rel="noopener noreferrer"
              >
                <DropdownMenuItemLabel>Open in Stamp</DropdownMenuItemLabel>
                <ArrowUpRight role="img" aria-label="(opens in a new tab)" />
              </DropdownMenuLinkItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </AppLinkCardActions>
      </AppLinkCard>
    </div>
  );
}
```

## States

While Raft fetches an answer it already holds from its cache, and that answer listed a block card, draw `AppLinkCardPlaceholder` after the link's paragraph at once, with the app's header in it. Its bars take the line heights of the parts they stand for, so it is the size of a card with a two-line title and a row of buttons, and the card that takes its place moves the message as little as it can. Without a cached answer, Raft doesn't know a link has a block card until its answer arrives: the link is drawn as any app link is until then, and the card is added after the paragraph when the answer lists one. The link itself never moves and is never replaced by the card ([Navigation](https://raft-design.botiverse.build/patterns/navigation.md#block-cards)).

```tsx
import {
  AppLinkCardHeader,
  AppLinkCardHost,
  AppLinkCardIcon,
  AppLinkCardName,
  AppLinkCardPlaceholder,
} from "@botiverse/raft-ui/wip";

export function AppLinkCardLoadingPlaceholderComposition() {
  return (
    <div className="flex w-full justify-center p-6">
      <AppLinkCardPlaceholder>
        <AppLinkCardHeader>
          <AppLinkCardIcon>
            <img src="/app-icons/stamp.svg" alt="" />
          </AppLinkCardIcon>
          <AppLinkCardName>Stamp</AppLinkCardName>
          <AppLinkCardHost>stamp.build</AppLinkCardHost>
        </AppLinkCardHeader>
      </AppLinkCardPlaceholder>
    </div>
  );
}
```

A button the app marks disabled stays visible and disabled, as on the hover card.

## Behavior

- The card is an item card (`MessageEmbed size="item"`): as wide as the hover card, 400px, and never wider than the message. In the phone layout (below 768px) it takes the message's full width, so its buttons have the room a finger needs.
- It keeps the gap a table or an image keeps in the text, and none at the top or bottom of the message.
- The title wraps to 2 lines and then ends in an ellipsis; when it is cut off, the app wraps it in a [Tooltip](https://raft-design.botiverse.build/components/tooltip.md) that shows it whole, as on the hover card.
- The host, when it doesn't fit, loses its start, never its end: the end (`…review.attacker.example`) says where the link goes.
- The title's link stays in the title's ink, not link blue, and is underlined on hover, as a sender's name is: the whole title is the link, so it needs no color to say so.

```tsx
import { ArrowUpRight, Ellipsis } from "lucide-react";
import { useCallback, useId, useState } from "react";
import {
  Avatar,
  AvatarFallback,
  Badge,
  Button,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItemLabel,
  DropdownMenuTrigger,
  MarkdownLink,
  MarkdownParagraph,
  MessageItem,
  MessageItemAvatarSlot,
  MessageItemBody,
  MessageItemContent,
  MessageItemHeader,
  MessageItemSender,
  MessageItemSenderGroup,
  MessageItemTime,
} from "@botiverse/raft-ui";
import {
  AppLinkCard,
  AppLinkCardActions,
  AppLinkCardBody,
  AppLinkCardEyebrow,
  AppLinkCardHeader,
  AppLinkCardHost,
  AppLinkCardIcon,
  AppLinkCardMeta,
  AppLinkCardName,
  AppLinkCardTitle,
  DropdownMenuLinkItem,
} from "@botiverse/raft-ui/wip";

// A phone's message column. The answer lists only the block card, so the
// link stays the author's plain link and the card follows its paragraph: it
// takes the message's width, and its buttons are tapped right there.
export function AppLinkCardPhoneComposition() {
  const titleId = useId();
  const [approved, setApproved] = useState(false);
  const handleApprove = useCallback(() => setApproved(true), []);

  return (
    <div className="mx-auto flex w-full max-w-[375px] flex-col">
      <MessageItem>
        <MessageItemAvatarSlot>
          <Avatar size="md" type="human">
            <AvatarFallback>J</AvatarFallback>
          </Avatar>
        </MessageItemAvatarSlot>
        <MessageItemContent>
          <MessageItemHeader>
            <MessageItemSenderGroup>
              <MessageItemSender>Jasper</MessageItemSender>
            </MessageItemSenderGroup>
            <MessageItemTime dateTime="2026-11-05T09:12:00Z">9:12 AM</MessageItemTime>
          </MessageItemHeader>
          <MessageItemBody>
            <MarkdownParagraph>
              Could someone approve this before the release?{" "}
              <MarkdownLink href="#stamp-pull-10451" target="_blank" rel="noopener noreferrer">
                https://stamp.build/botiverse/slock/pull/10451
              </MarkdownLink>
            </MarkdownParagraph>
            <AppLinkCard aria-labelledby={titleId}>
              <AppLinkCardHeader>
                <AppLinkCardIcon>
                  <img src="/app-icons/stamp.svg" alt="" />
                </AppLinkCardIcon>
                <AppLinkCardName>Stamp</AppLinkCardName>
                <AppLinkCardHost>stamp.build</AppLinkCardHost>
              </AppLinkCardHeader>
              <AppLinkCardBody>
                <AppLinkCardEyebrow>botiverse/slock</AppLinkCardEyebrow>
                <AppLinkCardTitle id={titleId}>
                  <a href="#stamp-pull-10451" target="_blank" rel="noopener noreferrer">
                    #10451 test(i18n): cover the zh-CN fallback when the locale cookie is missing
                  </a>
                </AppLinkCardTitle>
                <AppLinkCardMeta>
                  <Badge appearance="soft">{approved ? "Approved" : "Review requested"}</Badge>
                  <span>Jasper · +112 −8</span>
                </AppLinkCardMeta>
              </AppLinkCardBody>
              <AppLinkCardActions>
                <DropdownMenu>
                  <DropdownMenuTrigger
                    render={<Button variant="ghost" size="icon-md" aria-label="More" />}
                  >
                    <Ellipsis />
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="end">
                    <DropdownMenuLinkItem
                      href="#stamp-pull-10451"
                      target="_blank"
                      rel="noopener noreferrer"
                    >
                      <DropdownMenuItemLabel>Open in Stamp</DropdownMenuItemLabel>
                      <ArrowUpRight role="img" aria-label="(opens in a new tab)" />
                    </DropdownMenuLinkItem>
                  </DropdownMenuContent>
                </DropdownMenu>
                <Button
                  variant="ghost"
                  render={
                    <a href="#stamp-pull-10451-files" target="_blank" rel="noopener noreferrer" />
                  }
                >
                  View changes
                  <ArrowUpRight
                    data-icon="inline-end"
                    role="img"
                    aria-label="(opens in a new tab)"
                  />
                </Button>
                <Button variant="outline" disabled={approved} onClick={handleApprove}>
                  Approve
                </Button>
              </AppLinkCardActions>
            </AppLinkCard>
          </MessageItemBody>
        </MessageItemContent>
      </MessageItem>
    </div>
  );
}
```

## Content

- Fill the header from what Raft stores for the app: its registered icon and its name, then the link's host. Never from the answer.
- Write the app's words in the body as it sends them, and leave out a part it doesn't send rather than filling it with Raft's own.
- Put at most two buttons on the card: the app's first two buttons in its order, whatever they are (an action, Refresh, a link such as View changes), except a link to the item itself (below); frameless ones first and the bordered one last, at the end; everything else in a More menu (⋯) to their left, in the app's order, never wrapped onto a second line and never dropped. A link in the row ends in the leaving mark (↗) in the button's trailing icon place, and a link in the menu is a link item with ↗ where the shortcut goes ([Links that leave Raft](https://raft-design.botiverse.build/patterns/navigation.md#links-that-leave-raft)).
- Make the title a link to the item, opening in a new tab, so the item opens from the card the way it opens from the chip. An Open link such as Stamp's Open in Stamp then goes in ⋯, never in the row: the title already opens the item, and the row keeps its two places for the app's other buttons. A link to the item itself is a link button whose address is the link's own, the one the title opens: the same address once both are read as URLs (as `new URL(…).href` gives them). A link to any other address, such as View changes, is one of the other buttons. A card with no other buttons has only ⋯.

## Accessibility

- `AppLinkCard` is a `group`. Label it with `aria-labelledby` pointing at the id of its `AppLinkCardTitle`: someone who tabs to Approve then hears which item it approves, not a bare button among the message's words.
- Unlike the hover card, this card is in the page: its title's link, ⋯ and its buttons are real links and buttons, in the message's tab order in that order, the order they sit in from left to right (⋯ is to the buttons' left), and work by keyboard and touch.
- The header comes first in the DOM, so a screen reader reads the app's name before the app's words.
- `AppLinkCardPlaceholder` is `aria-busy` and its bars are hidden from assistive technology; the header in it is read.

## Rules

- Always show the header, drawn by Raft from what it stores for the app, never from the card's content. The card sits in the message beside people's own words, and its buttons work there: without Raft's frame naming the app, an app's card could pass for a system message or for someone's words.
- Lead the header with the app's registered icon, never an image from an address the app sends: an image fetched from the app on every view tells it who read the message and when, and an app could draw an icon that passes for one of Raft's own.
- Put no colors of the app's on the card: no brand color, no colored header. Show the item's state once, in the app's words, in a neutral Badge (`appearance="soft"`), as on the hover card ([PreviewCard](https://raft-design.botiverse.build/components/preview-card.md#rules)).
- Keep the card's width at the hover card's 400px on desktop. A card the width of the message would stretch a two-line title into one long line on a wide window, and set it apart from every other card in the message.

### The confirmation for an app Raft hasn't reviewed

When someone presses an acting button on the card of an app that Raft hasn't reviewed (one made in the server, not an official app), Raft confirms the press in its own dialog ([AlertDialog](https://raft-design.botiverse.build/components/alert-dialog.md)) before the app is called. This includes a button that has an undo window.

- Make the first line of the dialog's body, under the title, Raft's "Run by {App}" line: the app's registered icon (`AppLinkCardIcon`, in the card's frame), then "Run by {App}" in small semibold type. The line truncates rather than wraps. Keep 12px between it and the rest of the body.
- Show the line for every acting button of an app Raft hasn't reviewed, including buttons with an undo window. Never show it for an official app.
- End the body with Raft's line saying the app was made in this server, isn't reviewed by Raft, and receives the person's name ("{App} was made in this server and isn't reviewed by Raft. It gets your name and acts with its own access.").
- Draw both lines from what Raft stores for the app, never from the app's answer. The app writes the title and the body between them, so an app could word those to pass for Raft. These two lines are the only place where the person learns that Raft hasn't reviewed the app and that it will get their name (Tenny, 2026-11-10, #proj-apps:#60). In the web app this is slock #13956, `LinkCardActions.tsx` (`LinkCardConfirmDialog`).

## API

### AppLinkCard

An app link's card drawn in the message, after the link's paragraph, where the app's answer lists one (a `card` item with `placement: "block"`): Raft's header with the app's identity, the item, and buttons that work by keyboard and touch. It never replaces the link. A `group`: label it with `aria-labelledby` pointing at its `AppLinkCardTitle`, so someone who tabs to one of its buttons hears which item the button acts on.

Also takes the attributes of its element.

### AppLinkCardActions

The app's buttons at the card's bottom end: at most two, the bordered one last, with a More menu (⋯) to their left for the rest.

Also takes the attributes of its element.

### AppLinkCardBody

The item: `AppLinkCardEyebrow`, `AppLinkCardTitle`, `AppLinkCardMeta`, in that order.

Also takes the attributes of its element.

### AppLinkCardEyebrow

The app's id for the item, above the title. One line, truncated.

Also takes the attributes of its element.

### AppLinkCardHeader

Raft's row at the top: `AppLinkCardIcon`, `AppLinkCardName`, then `AppLinkCardHost`.

Also takes the attributes of its element.

### AppLinkCardHost

The link's host, after a "·". When it doesn't fit it loses its start, so the end, which says where the link goes, stays.

Also takes the attributes of its element.

### AppLinkCardIcon

The app's registered icon, the image Raft stores and serves: one child at 16px.

Also takes the attributes of its element.

### AppLinkCardMeta

One state in a neutral Badge, then muted details. Wraps when it runs out of room.

Also takes the attributes of its element.

### AppLinkCardName

The app's name as Raft stores it. One line, truncated.

Also takes the attributes of its element.

### AppLinkCardPlaceholder

The card's size while Raft fetches an answer it already knows lists a block card. Put the `AppLinkCardHeader` in it when the app is known; the item's lines and a button follow, as gray bars.

Also takes the attributes of its element.

### AppLinkCardTitle

The app's title for the item, up to 2 lines, then an ellipsis. Put a link to the item in it: the title opens the item, in the title's ink.

Also takes the attributes of its element.

## Changes

- 0.64.0 (Added): **AppLinkCard** and its parts (from `@botiverse/raft-ui/wip`): an app link's card drawn in the message itself, after the link's paragraph, where the app's answer lists one (protocol version 3: a `card` item with `placement: "block"` in `items`). The link is never replaced: it stays a chip when the answer lists `inline`, and the author's plain link when it doesn't. `AppLinkCardHeader` (with `AppLinkCardIcon`, `AppLinkCardName` and `AppLinkCardHost`) is Raft's row naming the app; `AppLinkCardBody`, `AppLinkCardEyebrow`, `AppLinkCardTitle` and `AppLinkCardMeta` are the item, as on the hover card, and the title holds a link that opens the item; `AppLinkCardActions` holds at most two of the app's buttons and ⋯ for the rest, where an Open link goes; `AppLinkCardPlaceholder` is the card's size while a cached answer loads. 400px wide, the message's width in the phone layout. Navigation's App links now say what each item of an answer draws, in the list's order, with nothing inferred.
