# Recommendation card

> An agent’s suggestion put as a question, with a three-bar confidence meter, Accept, and a drawer of alternatives.

A small card where an agent proposes one thing and says how sure it is. The sentence carries inline chips for who and how long, a three-bar meter reads the confidence, and Accept takes it. Alternatives unfolds the other options, each with its own meter; picking one swaps the sentence, the meter and the main button’s words, and the drawer stays open so they can be compared.

A React component by Bruno Delorence: https://brunodelorence.com/components/recommendation-card. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: AI.

## Anatomy

1. **Card**: The card, 380 px at most, on a soft grey stage (graphite at night). It grows from the middle as the drawer unfolds.
2. **Question**: What the agent asks, in one line ending in a question mark.
3. **Sentence**: The current option in words, with inline chips for who and how long. It fades in afresh, 180 ms, on every pick.
4. **Name chip**: An initial on a coloured disc and a name, set inline in the sentence.
5. **Option row**: One of the other options in the drawer: its meter, its summary and its confidence. Clicking it makes it the current one.
6. **Confidence meter**: Three bars and their words: green for high, orange for needs review, red for low, none lit for no signal.
7. **Alternatives**: Unfolds and folds the drawer, 300 ms on a long ease-out. Escape folds it and gives focus back.
8. **Main button**: Blue Accept for a sure option, an ink button with its own words for a doubtful one, green Accepted once taken.

## When to use

- An agent proposing one concrete next step that the person should approve before it runs.
- A suggestion with a few ranked fallbacks the person may prefer, each with its own confidence.
- When how sure the agent is should change what the person does: accept at once, or review first.
- Inline in a chat or a side panel, where a small card asks without taking over the screen.

## When not to use

- Several independent questions before a task. Use an approval card that asks them one by one.
- A destructive action that needs an explicit yes. Use a confirmation that names the action on its button.
- Options with no ranking or confidence behind them. Use a plain menu or a radio group.
- Reporting what the agent already did. That is a message or a toast, not a question.
- Long comparisons with many attributes per option. Use a table the person can sort.

## Installation

Install it with the shadcn CLI, into components/recommendation-card of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/recommendation-card.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import {
  RecommendationCard,
  RecommendationChip,
  RecommendationPill,
  type Recommendation,
} from './recommendation-card';

const options: Recommendation[] = [
  {
    id: 'retry',
    body: (
      <>
        Retry the deploy on <RecommendationChip name="Runner 4" color="#6c8cff" /> after{' '}
        <RecommendationPill tone="green">2 minutes</RecommendationPill>
      </>
    ),
    summary: 'Retry on Runner 4 in 2 minutes',
    confidence: 'high',
  },
  {
    id: 'rollback',
    body: <>Roll back to the last green build.</>,
    summary: 'Roll back to the last green build',
    confidence: 'medium',
    actionLabel: 'Review rollback',
  },
];

export function DeploySuggestion({ onAccept }: { onAccept: (id: string) => void }) {
  return (
    <RecommendationCard
      title="Want me to retry the deploy?"
      recommendations={options}
      onAccept={(option) => onAccept(option.id)}
      autoplay={false}
    />
  );
}
```

## States

- **Suggested**: High confidence: three green bars and the blue Accept.
- **Needs review**: Medium confidence: two orange bars, and an ink button (dark by day, light at night) that says Configure.
- **Low confidence**: One red bar. The main button keeps the ink tone and the option’s own words.
- **No signal**: No bar lit: a fallback the agent has no evidence for, named plainly on its button.
- **Alternatives open**: The drawer lists every other option with its own meter; Alternatives stays pressed in.
- **Accepted**: The main button turns green and reads Accepted until another option is picked.

## How it works

- The options are one list: the card shows the current one, and the drawer lists all the others in their own order. Picking one makes it the current and the one it replaces moves into the drawer, so nothing is lost and the reader can go back.
- The drawer unfolds by its grid row, from 0fr to 1fr, while it fades in: both 300 ms on a long ease-out (0.16, 1, 0.3, 1). The card grows from the middle of its stage with no height to measure, and a closed drawer is inert, so focus never reaches it.
- Each pick remounts the sentence (a new key), so it fades in afresh over 180 ms. The drawer’s rows are keyed by position rather than by option, so their bars change colour (300 ms) instead of popping in new.
- Confidence sets everything the footer shows: three green bars, two orange, one red or none, with their words (High confidence, Needs review, No signal). A sure suggestion gets the blue Accept; a doubtful one a dark button with its own words, such as Configure. Accepting turns it green until another option is picked.
- The sentence is any React node; three small helpers draw its pieces: a chip with an initial on a coloured disc, a pill (green for a good figure, neutral otherwise) and a stressed word.
- Buttons press to 96 % on a 150 ms ease-out. When the card is too narrow for the meter and both buttons, the buttons wrap under the meter rather than squeeze its words.
- While on screen and untouched, a demo plays the card: it opens the drawer, tries each alternative in turn, comes back to the first, closes the drawer and accepts, then starts over. It lights and presses the controls itself, never calls back nor announces, and any key or press hands the card to the reader.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme or the colorScheme prop. The card’s shadow is one list with every layer, those a scheme does not use being transparent: a long soft lift by day, a short dark one at night.

## API reference

- **title** (`string`, default `'Want me to place this restock order?'`): The question the card asks.
- **recommendations** (`Recommendation[]`, default `three sample options`): The options, the first being the agent’s suggestion: { id, body, summary, confidence, confidenceLabel?, actionLabel? }. body is the sentence the card shows (any React node), summary its line in the drawer, confidence one of high, medium, low or none.
- **defaultValue** (`string`): The id of the option shown first; the first option by default.
- **defaultOpen** (`boolean`): Opens with the drawer of other options unfolded. Setting it turns the demo off.
- **defaultAccepted** (`boolean`): Opens with the current option already accepted. Setting it turns the demo off.
- **onSelect** (`(recommendation: Recommendation) => void`): Called when the reader picks another option from the drawer.
- **onAccept** (`(recommendation: Recommendation) => void`): Called when the reader accepts the current option.
- **alternativesLabel** (`string`, default `'Alternatives'`): The button that opens and closes the drawer.
- **optionsLabel** (`string`, default `'Other options'`): The drawer’s heading.
- **acceptedLabel** (`string`, default `'Accepted'`): The main button’s words once the option is accepted.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo opens the drawer, tries the options, comes back and accepts, then starts over.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The card is a section named by its question, a heading. Alternatives is a button with aria-expanded and aria-controls; the drawer is a group named by its “Other options” heading, and inert while closed.
- Each option in the drawer is a button whose name reads its summary and its confidence (“Switch to Vanilla Madagascar, Needs review”). Escape closes the drawer and puts focus back on Alternatives.
- The confidence is a meter (0 to 3) named Confidence, whose value text is its words; the bars are hidden from screen readers.
- A polite live region says what a pick changed to, and that the option was accepted. Once accepted, the main button is aria-disabled until another option is picked. Focus rings are visible on every control.
- Under reduced motion the drawer, the sentence and the bars change at once, buttons do not shrink, and the demo never plays.

## Source

### recommendation-card/recommendation-card.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './recommendation-card.module.css';

/*
 * Recommendation card: an agent's suggestion put as a question, with how sure it is and two
 * answers. The sentence carries small inline chips (who, how long); a three-bar meter under it
 * says the confidence; Accept takes it, Alternatives unfolds the other options.
 *
 * - The options are one list. The card shows the current one; the drawer under it lists all the
 *   others, in their own order, each with its own meter and confidence. Picking one makes it the
 *   current: the sentence fades in afresh (180 ms), every meter's bars change colour (300 ms),
 *   and the drawer stays open so the reader can compare.
 * - The drawer unfolds by its grid row, 0fr to 1fr, as it fades in: 300 ms on a long ease-out,
 *   so the card grows from the middle of its stage with no height to measure.
 * - Confidence sets the meter (three green bars, two orange, one red, none) and the main
 *   button: a sure suggestion gets the blue Accept, a doubtful one a dark button with its own
 *   words (Configure, Accept full restock). Accepting turns it green ("Accepted") until another
 *   option is picked.
 * - While on screen and untouched, a demo opens the drawer, tries the other options, comes back
 *   to the first, closes the drawer and accepts, then starts over. Any real input hands the card
 *   to the reader. A card opened in a given state (defaultOpen, defaultAccepted) has no demo.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type Confidence = 'high' | 'medium' | 'low' | 'none';

export type Recommendation = {
  /** Names the option in callbacks. */
  id: string;
  /** The sentence the card shows while this option is the current one. */
  body: ReactNode;
  /** The option's one line in the drawer. */
  summary: string;
  confidence: Confidence;
  /** The confidence's words; by default High confidence, Needs review, Low confidence, No signal. */
  confidenceLabel?: string;
  /** The main button's words; by default Accept when confidence is high, Configure otherwise. */
  actionLabel?: string;
};

const LEVELS: Record<Confidence, { bars: number; label: string }> = {
  high: { bars: 3, label: 'High confidence' },
  medium: { bars: 2, label: 'Needs review' },
  low: { bars: 1, label: 'Low confidence' },
  none: { bars: 0, label: 'No signal' },
};

/** A small round chip inside the sentence: an initial on a coloured disc, and a name. */
export function RecommendationChip({
  name,
  initial = name.charAt(0),
  color = '#e08a3c',
}: {
  name: string;
  initial?: string;
  /** The disc's colour. */
  color?: string;
}) {
  return (
    <span className={styles.chip} data-part="chip">
      <span className={styles.avatar} style={{ background: color }} aria-hidden="true">
        {initial}
      </span>
      <span className={styles.chipName}>{name}</span>
    </span>
  );
}

/** A pill inside the sentence: green for a good figure, neutral for a plain one. */
export function RecommendationPill({
  children,
  tone = 'neutral',
}: {
  children: ReactNode;
  tone?: 'green' | 'neutral';
}) {
  return (
    <span className={styles.pill} data-part="pill" data-tone={tone}>
      {children}
    </span>
  );
}

/** A stressed word or two inside the sentence. */
export function RecommendationEmphasis({ children }: { children: ReactNode }) {
  return (
    <span className={styles.emphasis} data-part="emphasis">
      {children}
    </span>
  );
}

const RECOMMENDATIONS: Recommendation[] = [
  {
    id: 'reorder',
    body: (
      <>
        Reorder waffle cones from <RecommendationChip name="Cone King" /> with lead time{' '}
        <RecommendationPill tone="green">7 days</RecommendationPill>
      </>
    ),
    summary: 'Reorder from Cone King · 7-day lead',
    confidence: 'high',
    actionLabel: 'Accept',
  },
  {
    id: 'vanilla',
    body: (
      <>
        Switch vanilla to <RecommendationPill>Vanilla Madagascar</RecommendationPill> for peak
        season.
      </>
    ),
    summary: 'Switch to Vanilla Madagascar',
    confidence: 'medium',
    actionLabel: 'Configure',
  },
  {
    id: 'full-restock',
    body: (
      <>
        Fall back to a <RecommendationEmphasis>full restock</RecommendationEmphasis> across every
        SKU.
      </>
    ),
    summary: 'Full restock across every SKU',
    confidence: 'none',
    actionLabel: 'Accept full restock',
  },
];

/** The meter's bars: `count` of three lit in the level's colour. */
function Bars({ confidence }: { confidence: Confidence }) {
  const { bars } = LEVELS[confidence];
  return (
    <span className={styles.bars} data-part="bars" data-level={confidence} aria-hidden="true">
      {[0, 1, 2].map((bar) => (
        <span key={bar} className={styles.bar} data-on={bar < bars || undefined} />
      ))}
    </span>
  );
}

type DemoTarget = 'toggle' | 'accept' | number | null;
type Step = { wait: number; run: () => void };

export function RecommendationCard({
  title = 'Want me to place this restock order?',
  recommendations = RECOMMENDATIONS,
  defaultValue,
  defaultOpen,
  defaultAccepted,
  onSelect,
  onAccept,
  alternativesLabel = 'Alternatives',
  optionsLabel = 'Other options',
  acceptedLabel = 'Accepted',
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The question the card asks. */
  title?: string;
  /** The options; the first is the agent's suggestion. */
  recommendations?: Recommendation[];
  /** The id of the option shown first; the first option by default. */
  defaultValue?: string;
  /** Opens with the drawer of other options unfolded. Setting it turns the demo off. */
  defaultOpen?: boolean;
  /** Opens with the current option already accepted. Setting it turns the demo off. */
  defaultAccepted?: boolean;
  /** Called when the reader picks another option from the drawer. */
  onSelect?: (recommendation: Recommendation) => void;
  /** Called when the reader accepts the current option. */
  onAccept?: (recommendation: Recommendation) => void;
  /** The button that opens the drawer. */
  alternativesLabel?: string;
  /** The drawer's heading. */
  optionsLabel?: string;
  /** The main button's words once accepted. */
  acceptedLabel?: string;
  /** While on screen and untouched, a demo tries the options and accepts by itself. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const first = () => {
    const found = recommendations.findIndex((option) => option.id === defaultValue);
    return found < 0 ? 0 : found;
  };
  const [current, setCurrent] = useState(first);
  const [open, setOpen] = useState(defaultOpen ?? false);
  const [accepted, setAccepted] = useState(defaultAccepted ?? false);
  /** Bumped on every pick, so the sentence mounts afresh and fades in. */
  const [turn, setTurn] = useState(0);
  /** What the demo points at (it lights as under a pointer) and presses (it shrinks). */
  const [hover, setHover] = useState<DemoTarget>(null);
  const [pressed, setPressed] = useState<DemoTarget>(null);
  const [message, setMessage] = useState('');
  const toggle = useRef<HTMLButtonElement>(null);
  const uid = useId();

  // Other options (by their ids): start over with them.
  const signature = recommendations.map((option) => option.id).join('\n');
  const [seen, setSeen] = useState(signature);
  if (seen !== signature) {
    setSeen(signature);
    setCurrent(first());
    setAccepted(false);
  }

  const option = recommendations[current] ?? recommendations[0];
  const others = recommendations
    .map((other, index) => ({ other, index }))
    .filter(({ index }) => index !== current);
  // Opening in a given state holds it there: no demo.
  const locked = defaultOpen !== undefined || defaultAccepted !== undefined;
  const demo = autoplay && !locked && playing;

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));
  const labelOf = (other: Recommendation) =>
    other.confidenceLabel ?? LEVELS[other.confidence].label;
  const actionOf = (other: Recommendation) =>
    other.actionLabel ?? (other.confidence === 'high' ? 'Accept' : 'Configure');

  /** Makes an option the current one: a fresh sentence, and not accepted yet. */
  const show = (index: number) => {
    setCurrent(index);
    setAccepted(false);
    setTurn((value) => value + 1);
  };

  const pick = (index: number) => {
    const next = recommendations[index];
    if (!next || index === current) return;
    show(index);
    onSelect?.(next);
    announce(`${next.summary}. ${labelOf(next)}.`);
  };

  const accept = () => {
    if (accepted || !option) return;
    setAccepted(true);
    onAccept?.(option);
    announce(`${acceptedLabel}: ${option.summary}.`);
  };

  // The demo, one step after another, while it may play. It starts from the first option with
  // the drawer closed, and never calls back or announces.
  const runDemo = useEffectEvent((signal: { stop: boolean }) => {
    const count = recommendations.length;
    const press = (target: DemoTarget, then: () => void) => {
      setPressed(target);
      window.setTimeout(() => {
        if (signal.stop) return;
        setPressed(null);
        then();
      }, 150);
    };
    // The drawer lists the others in order; the demo tries each, then goes back to the first.
    const tries: Step[] = [];
    let at = 0;
    for (let next = 1; next <= count; next++) {
      const target = next % count;
      if (count < 2) break;
      const from = at;
      // Its row in the drawer while `from` is the current option.
      const row = target < from ? target : target - 1;
      tries.push(
        { wait: 1100, run: () => setHover(row) },
        {
          wait: 650,
          run: () =>
            press(row, () => {
              show(target);
              setHover(null);
            }),
        },
      );
      at = target;
    }
    const steps: Step[] = [
      {
        wait: 1200,
        run: () => {
          show(0);
          setOpen(false);
          setHover(null);
        },
      },
      { wait: 600, run: () => setHover('toggle') },
      {
        wait: 350,
        run: () =>
          press('toggle', () => {
            setOpen(true);
            setHover(null);
          }),
      },
      ...tries,
      { wait: 1300, run: () => setHover('toggle') },
      {
        wait: 350,
        run: () =>
          press('toggle', () => {
            setOpen(false);
            setHover(null);
          }),
      },
      { wait: 800, run: () => setHover('accept') },
      {
        wait: 350,
        run: () =>
          press('accept', () => {
            setAccepted(true);
            setHover(null);
          }),
      },
      { wait: 2600, run: () => {} },
    ];
    let index = 0;
    let timer = 0;
    const tick = () => {
      if (signal.stop) return;
      const step = steps[index % steps.length];
      timer = window.setTimeout(() => {
        if (signal.stop) return;
        step.run();
        index++;
        tick();
      }, step.wait);
    };
    tick();
    return () => window.clearTimeout(timer);
  });

  useEffect(() => {
    if (!demo) return;
    const signal = { stop: false };
    const clear = runDemo(signal);
    return () => {
      signal.stop = true;
      clear();
      setHover(null);
      setPressed(null);
    };
  }, [demo]);

  const onKeyDown = (event: ReactKeyboardEvent) => {
    if (event.key === 'Escape' && open) {
      setOpen(false);
      toggle.current?.focus();
    }
  };

  const panelId = `${uid}-options`;
  const headingId = `${uid}-heading`;
  const titleId = `${uid}-title`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDown={take}
      onKeyDown={(event) => {
        take();
        onKeyDown(event);
      }}
    >
      <div className={styles.stage}>
        <section className={styles.card} data-part="card" aria-labelledby={titleId}>
          <div className={styles.pad}>
            <h3 id={titleId} className={styles.title} data-part="title">
              {title}
            </h3>
            <p
              key={turn}
              className={styles.body}
              data-part="body"
              data-fade={turn > 0 || undefined}
            >
              {option?.body}
            </p>
          </div>

          <div className={styles.drawer} data-open={open || undefined}>
            <div className={styles.drawerClip}>
              <div
                id={panelId}
                className={styles.options}
                data-part="options"
                role="group"
                aria-labelledby={headingId}
                inert={!open}
              >
                <p id={headingId} className={styles.optionsHeading}>
                  {optionsLabel}
                </p>
                {others.map(({ other, index }, row) => (
                  <button
                    // By row, so a row's bars change colour rather than remount.
                    key={row}
                    type="button"
                    className={styles.row}
                    data-part="option"
                    data-hover={hover === row || undefined}
                    data-pressed={pressed === row || undefined}
                    onClick={() => pick(index)}
                  >
                    <Bars confidence={other.confidence} />
                    <span className={styles.rowLabel}>{other.summary}</span>
                    <span className={styles.srOnly}>, </span>
                    <span className={styles.rowConfidence}>{labelOf(other)}</span>
                  </button>
                ))}
              </div>
            </div>
          </div>

          <div className={styles.footer}>
            <span
              className={styles.meter}
              data-part="meter"
              role="meter"
              aria-valuemin={0}
              aria-valuemax={3}
              aria-valuenow={option ? LEVELS[option.confidence].bars : 0}
              aria-valuetext={option ? labelOf(option) : undefined}
              aria-label="Confidence"
            >
              {option && <Bars confidence={option.confidence} />}
              <span className={styles.meterLabel} aria-hidden="true">
                {option && labelOf(option)}
              </span>
            </span>
            <span className={styles.actions}>
              <button
                ref={toggle}
                type="button"
                className={`${styles.button} ${styles.secondary}`}
                data-part="alternatives"
                aria-expanded={open}
                aria-controls={panelId}
                data-hover={hover === 'toggle' || undefined}
                data-pressed={pressed === 'toggle' || undefined}
                onClick={() => setOpen((value) => !value)}
              >
                {alternativesLabel}
              </button>
              <button
                type="button"
                className={styles.button}
                data-part="accept"
                data-tone={accepted ? 'accepted' : option?.confidence === 'high' ? 'accent' : 'ink'}
                aria-disabled={accepted || undefined}
                data-hover={hover === 'accept' || undefined}
                data-pressed={pressed === 'accept' || undefined}
                onClick={accept}
              >
                {accepted ? acceptedLabel : option && actionOf(option)}
              </button>
            </span>
          </div>
        </section>
      </div>
      <p className={styles.srOnly} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### recommendation-card/recommendation-card.module.css

```css
/* Recommendation card (recommendation-card.tsx): a soft grey stage with the card in its middle,
   380 px at most, or at night a graphite stage with the card a step lighter.

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). The shadows are one list for both schemes, each layer coloured for
   one of them and clear in the other.

   Motion: the drawer's grid row and opacity, the bars' colours, the sentence's fade, and the
   buttons' press and background. */

.root {
  /* The stage, the card, the chips' fill, and the hover fills. */
  --canvas: oklch(96.1% 0.002 247.84);
  --surface: oklch(100% 0 0);
  --field: oklch(96.1% 0.001 286.375);
  --hover: oklch(97% 0.002 247.839);
  --inset: oklch(97.9% 0.002 247.839);
  /* Words: strong, the sentence, and quiet. */
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  /* Hairlines, and the meter's unlit bars. */
  --line: oklch(94.6% 0.003 264.542);
  --line-strong: oklch(91.2% 0.005 258.326);
  /* Accept, and Accept under the pointer. */
  --accent: oklch(62.6% 0.205 254.947);
  --accent-hover: oklch(55.6% 0.187 255.617);
  /* The meter's levels, and the green pill's fill. */
  --green: oklch(60.3% 0.155 150.883);
  --green-tint: oklch(95.8% 0.017 159.118);
  --orange: oklch(68.9% 0.179 49.902);
  --red: oklch(57.7% 0.215 27.325);
  /* The card's ring and lift: the day's long soft shadows, the night's short dark ones. */
  --card-ring: oklch(94.6% 0.003 264.542);
  --day-8: rgb(0 0 0 / 0.03);
  --day-5: rgb(0 0 0 / 0.02);
  --day-3: rgb(0 0 0 / 0.012);
  --night: transparent;
  --night-btn: transparent;
  --btn-ring: oklch(91.2% 0.005 258.326);
  --btn-day: rgb(0 0 0 / 0.04);
  /* The same on both schemes: the focus ring. */
  --focus: oklch(62.6% 0.205 254.947);
  position: relative;
  container-type: inline-size;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 272px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  font-feature-settings: normal;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --inset: light-dark(oklch(97.9% 0.002 247.839), oklch(24.3% 0.004 264.492));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --line-strong: light-dark(oklch(91.2% 0.005 258.326), oklch(35.6% 0.007 264.474));
    --accent: light-dark(oklch(62.6% 0.205 254.947), oklch(68% 0.173 253.301));
    --accent-hover: light-dark(oklch(55.6% 0.187 255.617), oklch(78.8% 0.113 248.33));
    --green: light-dark(oklch(60.3% 0.155 150.883), oklch(70.5% 0.154 153.814));
    --green-tint: light-dark(oklch(95.8% 0.017 159.118), oklch(70.5% 0.154 153.814 / 0.14));
    --orange: light-dark(oklch(68.9% 0.179 49.902), oklch(74.6% 0.156 55.642));
    --red: light-dark(oklch(57.7% 0.215 27.325), oklch(66.6% 0.18 21.433));
    --card-ring: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.11));
    --day-8: light-dark(rgb(0 0 0 / 0.03), transparent);
    --day-5: light-dark(rgb(0 0 0 / 0.02), transparent);
    --day-3: light-dark(rgb(0 0 0 / 0.012), transparent);
    --night: light-dark(transparent, oklch(0% 0 0 / 0.2));
    --night-btn: light-dark(transparent, oklch(0% 0 0 / 0.3));
    --btn-ring: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --btn-day: light-dark(rgb(0 0 0 / 0.04), transparent);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

:where(.root) :where(button, h3, p) {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
}
:where(.root) :where(button) {
  cursor: pointer;
}
.root button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The card, centred in a 480 px column. */
.stage {
  display: flex;
  min-width: 0;
  flex-direction: column;
  align-items: center;
  width: 100%;
  max-width: 480px;
}

.card {
  width: 100%;
  min-width: 0;
  max-width: 380px;
  overflow: hidden;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--card-ring),
    0 1px 2px var(--night),
    0 2px 6px var(--night),
    0 18px 47px var(--day-8),
    0 7.5px 19px var(--day-5),
    0 4px 10.5px var(--day-5),
    0 2.3px 5.8px var(--day-3),
    0 1.2px 3.1px var(--day-3),
    0 0.5px 1.3px var(--day-3);
}

/* The question and the sentence. */
.pad {
  padding: 12px;
}
.title {
  color: var(--ink);
  font-size: 14px;
  font-weight: 500;
  line-height: 21px;
}
.body {
  min-height: 48px;
  margin-top: 6px;
  color: var(--ink-2);
  font-size: 13px;
  line-height: 1.625;
  text-wrap: pretty;
}
.body[data-fade] {
  animation: fade-in 180ms ease-out both;
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}

/* The sentence's chips: a name with an initial on a disc, a pill, a stressed word. */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  margin: 0 2px;
  padding: 1px 6px 1px 3px;
  border-radius: 999px;
  background: var(--field);
  box-shadow: 0 0 0 1px var(--line);
  vertical-align: middle;
}
.avatar {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  color: #fff;
  font-size: 9px;
  font-weight: 600;
  line-height: 1;
}
.chipName {
  color: var(--ink);
  font-size: 12px;
  font-weight: 500;
}
.pill {
  display: inline-flex;
  align-items: center;
  margin: 0 2px;
  padding: 0 6px;
  border-radius: 999px;
  background: var(--field);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink-2);
  font-size: 12px;
  font-weight: 500;
  vertical-align: middle;
}
.pill[data-tone='green'] {
  background: var(--green-tint);
  box-shadow: 0 0 0 1px color-mix(in oklch, var(--green) 28%, transparent);
  color: var(--green);
}
.emphasis {
  color: var(--ink);
  font-weight: 500;
}

/* The drawer of other options: its grid row opens from 0fr to 1fr as it fades in. */
.drawer {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition:
    grid-template-rows 300ms cubic-bezier(0.16, 1, 0.3, 1),
    opacity 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.drawer[data-open] {
  grid-template-rows: 1fr;
  opacity: 1;
}
.drawerClip {
  min-height: 0;
  overflow: hidden;
}
.options {
  padding: 8px;
  border-top: 1px solid var(--line);
  background: var(--surface);
}
.optionsHeading {
  padding: 0 6px 4px;
  color: var(--ink-3);
  font-size: 11px;
  font-weight: 500;
  line-height: 1.5;
}
.row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 6px;
  border-radius: 8px;
  text-align: left;
  font-size: 14px;
  line-height: 1.5;
  transition: background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
.row[data-hover] {
  background: var(--hover);
}
@media (hover: hover) {
  .row:hover {
    background: var(--hover);
  }
}
.rowLabel {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  color: var(--ink);
  font-size: 12.5px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.rowConfidence {
  flex-shrink: 0;
  color: var(--ink-3);
  font-size: 11px;
}

/* A meter: three bars, 4 by 10 px, lit in the level's colour. */
.bars {
  display: flex;
  flex-shrink: 0;
  align-items: flex-end;
  gap: 2px;
}
.bar {
  width: 4px;
  height: 10px;
  border-radius: 999px;
  background: var(--line-strong);
  transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.bars[data-level='high'] .bar[data-on] {
  background: var(--green);
}
.bars[data-level='medium'] .bar[data-on] {
  background: var(--orange);
}
.bars[data-level='low'] .bar[data-on] {
  background: var(--red);
}

/* The footer: the meter and its words, then the two buttons, which wrap under the meter when
   the card is too narrow for both. */
.footer {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 8px 12px;
  padding: 10px;
  background: var(--surface);
}
.meter {
  display: flex;
  min-width: 0;
  flex-shrink: 0;
  align-items: center;
  gap: 8px;
}
.meterLabel {
  overflow: hidden;
  color: var(--ink-2);
  font-size: 12.5px;
  font-weight: 500;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.actions {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  gap: 8px;
  margin-right: -2px;
  margin-left: auto;
}
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 27px;
  padding: 0 12px;
  border-radius: 999px;
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.5;
  white-space: nowrap;
  user-select: none;
  transition:
    transform 150ms cubic-bezier(0, 0, 0.2, 1),
    background-color 150ms cubic-bezier(0, 0, 0.2, 1),
    opacity 150ms cubic-bezier(0, 0, 0.2, 1),
    filter 150ms cubic-bezier(0, 0, 0.2, 1);
}
.button:active,
.button[data-pressed] {
  transform: scale(0.96);
}
.secondary {
  padding: 0 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--btn-ring),
    0 0 4px var(--btn-day),
    0 1px 2px var(--night-btn);
  color: var(--ink);
}
.secondary[data-hover] {
  background: var(--inset);
}
@media (hover: hover) {
  .secondary:hover {
    background: var(--inset);
  }
}
.secondary[aria-expanded='true'] {
  background: var(--hover);
}
.button[data-tone] {
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.14);
}
.button[data-tone='accent'] {
  background: var(--accent);
  color: #fff;
}
.button[data-tone='accent'][data-hover] {
  background: var(--accent-hover);
}
.button[data-tone='ink'] {
  background: var(--ink);
  color: var(--canvas);
}
.button[data-tone='ink'][data-hover] {
  opacity: 0.9;
}
.button[data-tone='accepted'] {
  background: var(--green);
  color: #fff;
  cursor: default;
}
@media (hover: hover) {
  .button[data-tone='accent']:hover {
    background: var(--accent-hover);
  }
  .button[data-tone='ink']:hover {
    opacity: 0.9;
  }
  .button[data-tone='accepted']:hover {
    filter: brightness(0.95);
  }
}
.button[data-tone='accepted']:active {
  transform: none;
}

.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .body[data-fade] {
    animation: none;
  }
  .drawer,
  .bar,
  .row,
  .button {
    transition: none;
  }
  .button:active {
    transform: none;
  }
}
```

### recommendation-card/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```