Recommendation Card
Agent suggestion with a confidence meter and actions.
"use client";
/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import "./styles/foundation.css";
import { useState } from "react";
import { Button } from "@/registry/ward/ui/button";
import { EntityChip } from "./EntityChip";
import { ValuePill } from "./ValuePill";
/* ─────────────────────────────────────────────────────────
* RECOMMENDATION CARD
* The card holds its shape. Pressing "Alternatives" opens a
* new drawer listing the other options; picking one promotes
* it to the recommendation. The primary action confirms.
* ───────────────────────────────────────────────────────── */
export type RecommendationOption = {
key: string;
body: React.ReactNode;
short: string;
signal: number;
tone: string;
label: string;
cta: string;
ctaVariant: "default" | "secondary";
};
export type RecommendationLabels = {
title: string;
alternatives: string;
otherOptions: string;
accepted: string;
};
const DEFAULT_LABELS: RecommendationLabels = {
title: "Want me to place this restock order?",
alternatives: "Alternatives",
otherOptions: "Other options",
accepted: "Accepted",
};
const OPTIONS: RecommendationOption[] = [
{
key: "high",
body: (
<>
Reorder design assets from{" "}
<EntityChip name="Asset King" />{" "}
with lead time <ValuePill tone="green">7 days</ValuePill>
</>
),
short: "Reorder from Asset King · 7-day lead",
signal: 3,
tone: "var(--green)",
label: "High confidence",
cta: "Accept",
ctaVariant: "default",
},
{
key: "review",
body: (
<>
Switch vanilla to <ValuePill>Vanilla Madagascar</ValuePill> for peak season.
</>
),
short: "Switch to Vanilla Madagascar",
signal: 2,
tone: "var(--orange)",
label: "Needs review",
cta: "Configure",
ctaVariant: "default",
},
{
key: "none",
body: (
<>
Fall back to a <span className="font-medium text-ink">full restock</span> across every SKU.
</>
),
short: "Full restock across every SKU",
signal: 0,
tone: "var(--ink-3)",
label: "No signal",
cta: "Accept full restock",
ctaVariant: "default",
},
];
function Meter({ signal, tone }: { signal: number; tone: string }) {
return (
<span className="flex items-end gap-0.5">
{[0, 1, 2].map((bar) => (
<span
key={bar}
className="w-1 rounded-full transition-colors duration-300"
style={{ height: 10, background: bar < signal ? tone : "var(--line-strong)" }}
/>
))}
</span>
);
}
export default function RecommendationCard({
options = OPTIONS,
labels,
}: {
options?: RecommendationOption[];
labels?: Partial<RecommendationLabels>;
variant?: string;
} = {}) {
const t = { ...DEFAULT_LABELS, ...labels };
const [selected, setSelected] = useState(0);
const [open, setOpen] = useState(false);
const [accepted, setAccepted] = useState(false);
const active = options[selected];
const others = options.map((o, i) => ({ o, i })).filter(({ i }) => i !== selected);
return (
<div className="w-full max-w-95 overflow-hidden rounded-card bg-card shadow-card">
<div className="primitive-card-pad">
<span className="text-[14px] font-medium text-ink">
{t.title}
</span>
<p
key={active.key}
className="mt-1.5 min-h-12 text-[13px] leading-relaxed text-ink-2"
style={{ animation: "fade-in 180ms ease-out both" }}
>
{active.body}
</p>
</div>
{/* alternatives drawer — a distinctly new section of the card */}
<div
className="grid transition-[grid-template-rows,opacity] duration-300"
style={{
gridTemplateRows: open ? "1fr" : "0fr",
opacity: open ? 1 : 0,
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
<div className="overflow-hidden">
<div className="border-t border-line bg-card px-2 py-2">
<p className="px-1.5 pb-1 text-[11px] font-medium text-ink-3">
{t.otherOptions}
</p>
{others.map(({ o, i }) => (
<button
key={o.key}
type="button"
onClick={() => {
setSelected(i);
setAccepted(false);
}}
className="flex w-full items-center gap-2.5 rounded-control px-1.5 py-1.5
text-left transition-colors duration-100 hover:bg-hover"
>
<Meter signal={o.signal} tone={o.tone} />
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{o.short}</span>
<span className="shrink-0 text-[11px] text-ink-3">{o.label}</span>
</button>
))}
</div>
</div>
</div>
<div className="primitive-card-footer flex items-center justify-between gap-3 bg-card">
<span className="flex items-center gap-2">
<Meter signal={active.signal} tone={active.tone} />
<span className="text-[12.5px] font-medium text-ink-2">{active.label}</span>
</span>
<span className="-mr-0.5 flex items-center gap-2">
<Button
variant="secondary"
size="sm"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
className="h-7 px-2.5 text-[12.5px]"
>
{t.alternatives}
</Button>
<Button
variant={accepted ? "secondary" : active.ctaVariant}
size="sm"
onClick={() => setAccepted(true)}
className={accepted ? "h-7 bg-green text-[12.5px] text-white hover:bg-green" : "h-7 text-[12.5px]"}
>
{accepted ? t.accepted : active.cta}
</Button>
</span>
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ward/recommendation-cardnpx shadcn@latest add @ward/recommendation-cardyarn dlx shadcn@latest add @ward/recommendation-cardbunx --bun shadcn@latest add @ward/recommendation-cardAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/recommendation-card.json
Add the Ward items it builds on.
npx shadcn@latest add @ward/foundation @ward/button @ward/entity-chip @ward/value-pillCopy each file from the Code tab into:
components/ward/RecommendationCard.tsx
Update the import paths to match your project.
Usage
import RecommendationCard from "@/components/ward/RecommendationCard";<RecommendationCard
labels={{ title: "Apply this fix?" }}
options={[
{ key: "patch", body: "Patch the null check in the session handler.", short: "Patch the null check", signal: 3, tone: "var(--green)", label: "High confidence", cta: "Apply", ctaVariant: "default" },
{ key: "revert", body: "Revert the last deploy.", short: "Revert the last deploy", signal: 1, tone: "var(--orange)", label: "Needs review", cta: "Revert", ctaVariant: "default" },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
options | RecommendationOption[] | OPTIONS | Options to choose between, with the first shown as the recommendation and the rest listed under Alternatives. |
labels | Partial<RecommendationLabels> | - | Overrides for the card title, the Alternatives button, the drawer heading and the accepted state. |
variant | string | "Default" | Accepted for registry parity; RecommendationCard has no visual variants. |
Ward · Version 1.1.0 · Registry manifest