# Recommendation Card

> Present one recommended action with its confidence stated, and keep the alternatives a press away.

Source: https://ai.honestui.com/docs/components/recommendation-card

```tsx
"use client"

import * as React from "react"

import {
  RecommendationCard,
  type RecommendationOption,
} from "@/components/ai/recommendation-card"

// Sample options.
const sampleOptions: RecommendationOption[] = [
  {
    id: "round-once",
    body: "Round the total once, after the rows are summed. All 14 existing tests pass with this change.",
    summary: "Round once, after the sum",
    confidence: 3,
    confidenceLabel: "High confidence",
    actionLabel: "Apply the fix",
  },
  {
    id: "decimal",
    body: "Store amounts as integer cents. This removes rounding entirely, but it changes the invoice schema.",
    summary: "Store amounts as integer cents",
    confidence: 2,
    confidenceLabel: "Needs review",
    actionLabel: "Draft the migration",
  },
  {
    id: "leave",
    body: "Leave the export as it is and note the one-cent drift in the changelog.",
    summary: "Leave it and document the drift",
    confidence: 0,
    confidenceLabel: "No evidence",
    actionLabel: "Write the note",
  },
]

/** This preview applies nothing. It shows the option your `onAccept` receives. */
export function RecommendationCardDemo() {
  const [accepted, setAccepted] = React.useState("")

  return (
    <div className="flex w-full max-w-md min-w-0 flex-col gap-[var(--hui-space-3)]">
      <RecommendationCard
        title="How should I fix the rounding drift?"
        options={sampleOptions}
        onAccept={(option) => setAccepted(option.id)}
      />
      <p
        role="status"
        className="m-0 text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-small)]"
      >
        {accepted ? `onAccept received “${accepted}”` : ""}
      </p>
    </div>
  )
}

```

## Overview [#overview]

Recommendation Card shows what an agent proposes to do, how sure it is, and what else it considered. The person accepts the recommendation or opens the alternatives and picks one of those.

Nothing happens until the person accepts. The card calls `onAccept` with the chosen option, and your application does the work.

Use [Approval Card](/docs/components/approval-card) when the agent has questions to ask. Use this card when it has an answer to propose.

## Anatomy [#anatomy]

<Anatomy title="RecommendationCard">
  <AnatomyItem name="Title" description="The question the agent is asking" />

  <AnatomyItem name="Body" description="The selected option in full" />

  <AnatomyItem name="Confidence" description="A three-bar meter with the confidence in words" />

  <AnatomyItem name="Alternatives" description="The other options, behind a disclosure" />

  <AnatomyItem name="Accept" description="A button named for the selected option" />
</Anatomy>

Installing `recommendation-card` brings [Button](https://www.honestui.com/docs/components/button) and [Collapsible](https://www.honestui.com/docs/components/collapsible) along as registry dependencies.

## Behavior [#behavior]

### Options [#options]

The first option is the recommendation. Choosing an alternative makes it the selected option: the body, the confidence, and the accept button's text all change to match.

### Confidence [#confidence]

`confidence` runs from 0 to 3 and fills the meter. `confidenceLabel` is always shown beside it. Take both from your agent. Do not present a guess as high confidence.

### Accepting [#accepting]

If `onAccept` returns a promise, the button shows progress and the card locks until it settles. When it resolves, the card confirms and stays locked. When it rejects, the card shows an error and the person can accept again or pick another option.

## Accessibility [#accessibility]

The Alternatives button reports whether the list is expanded. Each alternative is a button. The body is a polite live region, so choosing an alternative announces the new recommendation.

The confirmation is a status and the error is an alert. The meter is decorative; the confidence is carried by its label.

| Key                                  | Action                                                            |
| ------------------------------------ | ----------------------------------------------------------------- |
| <kbd>Tab</kbd>                       | Move across the alternatives, Alternatives, and the accept button |
| <kbd>Enter</kbd> or <kbd>Space</kbd> | Activate the focused button                                       |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/recommendation-card&#x22;]" />
  

  
    
      
        Add the components it is built from.
      

      <CliBlock commands="[&#x22;button&#x22;, &#x22;collapsible&#x22;]" />

      
        Copy and paste this file into your project.
      

      ### components/ai/recommendation-card.tsx

```tsx
"use client"

import * as React from "react"
import {
  CircleAlert as CircleAlertIcon,
  CircleCheck as CircleCheckIcon,
  LoaderCircle as LoaderCircleIcon,
} from "honestui/icons"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsiblePanel,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"

export type RecommendationOption = {
  id: string
  /** The recommendation in full, shown while this option is selected. */
  body: React.ReactNode
  /** One line for the list of alternatives. */
  summary: string
  /** How strong the evidence is, from 0 (none) to 3 (high). */
  confidence: 0 | 1 | 2 | 3
  /** The confidence in words, such as "High confidence". Always shown beside the meter. */
  confidenceLabel: string
  /** The accept button's text for this option. */
  actionLabel: string
}

export type RecommendationCardLabels = {
  alternatives: string
  otherOptions: string
  accepting: string
  accepted: string
  error: string
}

export const defaultRecommendationCardLabels: RecommendationCardLabels = {
  alternatives: "Alternatives",
  otherOptions: "Other options",
  accepting: "Working…",
  accepted: "Accepted.",
  error: "This was not accepted. Try again.",
}

const smallText =
  "[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"

function ConfidenceMeter({ level }: { level: number }) {
  return (
    <span aria-hidden="true" className="flex shrink-0 items-end gap-0.5">
      {[1, 2, 3].map((bar) => (
        <span
          key={bar}
          className={cn(
            "h-2.5 w-1 rounded-full",
            bar <= level
              ? "bg-[var(--hui-color-foreground-base-primary)]"
              : "bg-[var(--hui-color-border-base-secondary)]"
          )}
        />
      ))}
    </span>
  )
}

export type RecommendationCardProps = Omit<React.ComponentProps<"div">, "children" | "title"> & {
  /** The question the agent is asking, such as "Place this restock order?". */
  title: string
  /** The first option is the recommendation. The rest are alternatives. */
  options: RecommendationOption[]
  /**
   * Receives the chosen option. Return a promise to show progress; if it
   * rejects, the card shows an error and the choice can be made again.
   */
  onAccept: (option: RecommendationOption) => void | Promise<void>
  labels?: Partial<RecommendationCardLabels>
}

/**
 * One recommended action with its confidence stated, and the alternatives a
 * press away. Nothing happens until the person accepts an option.
 */
function RecommendationCard({
  title,
  options,
  onAccept,
  labels: labelsProp,
  className,
  ...props
}: RecommendationCardProps) {
  const labels = React.useMemo(
    () => ({ ...defaultRecommendationCardLabels, ...labelsProp }),
    [labelsProp]
  )
  const [selectedId, setSelectedId] = React.useState(options[0]?.id)
  const [phase, setPhase] = React.useState<"idle" | "accepting" | "accepted" | "error">("idle")
  const selected = options.find((option) => option.id === selectedId) ?? options[0]
  const others = options.filter((option) => option !== selected)
  const isLocked = phase === "accepting" || phase === "accepted"

  if (!selected) return null

  const accept = async () => {
    setPhase("accepting")
    try {
      await onAccept(selected)
      setPhase("accepted")
    } catch {
      setPhase("error")
    }
  }

  return (
    <div
      data-slot="recommendation-card"
      data-phase={phase}
      className={cn(
        "w-full min-w-0 overflow-hidden rounded-[var(--hui-radius-4)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] text-[var(--hui-color-foreground-base-primary)] shadow-[var(--hui-shadow-soft)]",
        smallText,
        className
      )}
      {...props}
    >
      <Collapsible>
        <div className="flex flex-col gap-[var(--hui-space-3)] p-[var(--hui-space-5)]">
          <p className="m-0 break-words [font-size:var(--hui-font-size-regular)] [font-weight:var(--hui-font-weight-medium)] [letter-spacing:var(--hui-letter-spacing-regular)] [line-height:var(--hui-line-height-regular)]">
            {title}
          </p>
          <div
            aria-live="polite"
            className="break-words text-[var(--hui-color-foreground-base-secondary)] [line-height:var(--hui-line-height-regular)]"
          >
            {selected.body}
          </div>
          <p className="m-0 flex items-center gap-[var(--hui-space-3)] text-[var(--hui-color-foreground-base-secondary)]">
            <ConfidenceMeter level={selected.confidence} />
            {selected.confidenceLabel}
          </p>
        </div>

        {others.length > 0 && (
          <CollapsiblePanel>
            <div className="border-t-[0.5px] border-[var(--hui-color-border-base-primary)] p-[var(--hui-space-3)]">
              <p className="m-0 px-[var(--hui-space-2)] pb-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)]">
                {labels.otherOptions}
              </p>
              <ul role="list" className="m-0 flex list-none flex-col p-0">
                {others.map((option) => (
                  <li key={option.id}>
                    <button
                      type="button"
                      disabled={isLocked}
                      onClick={() => {
                        setSelectedId(option.id)
                        setPhase("idle")
                      }}
                      className="flex w-full cursor-pointer items-center gap-[var(--hui-space-3)] rounded-[var(--hui-radius-2)] border-0 bg-transparent px-[var(--hui-space-2)] py-[var(--hui-space-3)] text-left outline-none motion-safe:[transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50"
                    >
                      <ConfidenceMeter level={option.confidence} />
                      <span className="min-w-0 flex-1 break-words">{option.summary}</span>
                      <span className="shrink-0 text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)]">
                        {option.confidenceLabel}
                      </span>
                    </button>
                  </li>
                ))}
              </ul>
            </div>
          </CollapsiblePanel>
        )}

        <div className="flex flex-wrap items-center justify-end gap-[var(--hui-space-3)] border-t-[0.5px] border-[var(--hui-color-border-base-primary)] px-[var(--hui-space-5)] py-[var(--hui-space-3)]">
          <p
            role={phase === "error" ? "alert" : "status"}
            className="m-0 me-auto flex min-w-0 items-center gap-[var(--hui-space-2)]"
          >
            {phase === "accepted" && (
              <>
                <CircleCheckIcon
                  aria-hidden="true"
                  className="size-3.5 shrink-0 text-[var(--hui-color-foreground-success-primary)]"
                />
                {labels.accepted}
              </>
            )}
            {phase === "error" && (
              <>
                <CircleAlertIcon strokeWidth={1.5} strokeLinecap="round"
                  aria-hidden="true"
                  className="size-3.5 shrink-0 text-[var(--hui-color-foreground-danger-primary)]"
                />
                {labels.error}
              </>
            )}
          </p>
          {others.length > 0 && (
            <CollapsibleTrigger render={<Button variant="secondary" size="sm" />}>
              {labels.alternatives}
            </CollapsibleTrigger>
          )}
          <Button size="sm" disabled={isLocked} onClick={() => void accept()}>
            {phase === "accepting" && (
              <LoaderCircleIcon aria-hidden="true" className="size-3.5 motion-safe:animate-spin" />
            )}
            {phase === "accepting" ? labels.accepting : selected.actionLabel}
          </Button>
        </div>
      </Collapsible>
    </div>
  )
}

export { RecommendationCard }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { RecommendationCard } from "@/components/ai/recommendation-card"

<RecommendationCard
  title="How should I fix the rounding drift?"
  options={proposal.options}
  onAccept={(option) => agent.accept(proposal.id, option.id)}
/>
```

## Examples [#examples]

### An accept that fails [#an-accept-that-fails]

This preview rejects the first accept and resolves the second.

```tsx
"use client"

import * as React from "react"

import { RecommendationCard } from "@/components/ai/recommendation-card"

function wait(milliseconds: number) {
  return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
}

/**
 * There is no server behind this preview. The first accept is rejected on
 * purpose to show the failure state; the next one resolves.
 */
export function RecommendationCardFailureExample() {
  const attempts = React.useRef(0)

  return (
    <div className="w-full max-w-md min-w-0">
      <RecommendationCard
        title="Run the migration on staging?"
        labels={{ accepted: "Started on staging." }}
        options={[
          {
            id: "run",
            body: "The migration adds a region column and backfills 1,204 rows. It can be rolled back.",
            summary: "Run the migration",
            confidence: 3,
            confidenceLabel: "High confidence",
            actionLabel: "Run it",
          },
        ]}
        onAccept={async () => {
          attempts.current += 1
          await wait(900)
          if (attempts.current === 1) throw new Error("This preview rejects the first accept.")
        }}
      />
    </div>
  )
}

```

## API reference [#api-reference]

### RecommendationCard [#recommendationcard]

Accepts native div props beyond those listed.

| Prop       | Type                                | Default  | Description                                      |
| ---------- | ----------------------------------- | -------- | ------------------------------------------------ |
| `title`    | string                              | Required | The question the agent is asking.                |
| `options`  | `RecommendationOption[]`            | Required | The recommendation first, then the alternatives. |
| `onAccept` | `(option) => void \| Promise<void>` | Required | Receives the chosen option.                      |
| `labels`   | `Partial<RecommendationCardLabels>` | English  | Button names and outcome messages.               |

### Types [#types]

| Export                 | Shape                                                                            |
| ---------------------- | -------------------------------------------------------------------------------- |
| `RecommendationOption` | `id`, `body`, `summary`, `confidence` (0 to 3), `confidenceLabel`, `actionLabel` |
