Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Recommendation card

New

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

recommendation-card/recommendation-card.tsx
Live · interactive

Want me to place this restock order?

Reorder waffle cones from Cone King with lead time 7 days

Other options

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/recommendation-card of any React project, with no setup:

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

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

deploy-suggestion.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}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
titlestring'Want me to place this restock order?'The question the card asks.
recommendationsRecommendation[]three sample optionsThe 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.
defaultValuestring—The id of the option shown first; the first option by default.
defaultOpenboolean—Opens with the drawer of other options unfolded. Setting it turns the demo off.
defaultAcceptedboolean—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.
alternativesLabelstring'Alternatives'The button that opens and closes the drawer.
optionsLabelstring'Other options'The drawer’s heading.
acceptedLabelstring'Accepted'The main button’s words once the option is accepted.
autoplaybooleantrueWhile 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.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

recommendation-card.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),
recommendation-card.module.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);
use-autoplay.ts
'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;
  • 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.