Rich Reply Cards
Typed cards for AI chat replies: links, steps, plans, estimates, projects and FAQs, rendered from data.
npx shadcn@latest add @vibebox/reply-cardsAbout
Answers in a chat are easier to act on when the facts come as cards. Six cards share one quiet design: a mono label led by a short spectrum line, a compact title, small chips and one or two actions. ReplyCardView renders any of them from typed data, so a server can turn markers in a model's reply into cards resolved against real records (prices, projects, steps) and drop anything it cannot resolve. Ported from the VibeBox assistant's cards, which were tied to VibeBox's own data; these are generic.
Use it when
- An assistant's reply includes prices, steps, a project, links or common questions.
- The data comes from your records, not from the model's imagination.
Skip it when
- The content is a long article; link to it with a LinkCard instead.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/reply-cards - 3Use it:
import { ReplyCardView } from "@/components/vibebox/reply-cards"; {message.cards.map((card, index) => <ReplyCardView key={index} card={card} />)}
Code
4 filescomponents/vibebox/reply-cards/cards.tsx
/**
* VibeBox Motion · Rich Reply Cards
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { CardActions, CardAction, CardText, CardTitle, Chips, ReplyCard, formatMoney } from "./primitives";
import type { ReplyCardData } from "./types";
type Card<K extends ReplyCardData["kind"]> = Extract<ReplyCardData, { kind: K }>;
export function LinkCard({ card }: { card: Card<"link"> }) {
return (
<ReplyCard label={card.label ?? "Read more"} aria-label={card.title}>
<div className="px-3.5 pt-2.5 pb-1">
<CardTitle>{card.title}</CardTitle>
{card.description && <CardText>{card.description}</CardText>}
</div>
<CardActions actions={[{ label: "Open", href: card.href }]} />
</ReplyCard>
);
}
export function StepsCard({ card }: { card: Card<"steps"> }) {
return (
<ReplyCard label={card.label ?? "How it works"} aria-label={card.title ?? card.label ?? "Steps"}>
{card.title && (
<div className="px-3.5 pt-2.5">
<CardTitle>{card.title}</CardTitle>
</div>
)}
<ol className="m-0 grid list-none gap-0.5 px-3.5 pt-2.5 pb-3.5">
{card.steps.map((step, index) => (
<li key={step.title} className="grid grid-cols-[22px_1fr] gap-x-2.5 py-1.5">
<span className="grid size-[22px] place-items-center rounded-full border border-border-strong font-mono text-[0.68rem] text-muted-foreground">
{index + 1}
</span>
<span>
<span className="block text-[0.86rem] font-semibold text-foreground">{step.title}</span>
{step.body && <span className="mt-0.5 block text-[0.8rem] leading-normal text-muted-foreground">{step.body}</span>}
</span>
</li>
))}
</ol>
<CardActions actions={card.actions} />
</ReplyCard>
);
}
export function PlansCard({ card }: { card: Card<"plans"> }) {
return (
<ReplyCard label={card.label ?? "Plans"} aside={<span className="font-mono text-[0.72rem] text-subtle-foreground">{card.currency}</span>} aria-label={card.label ?? "Plans"}>
<ul className="m-0 grid list-none gap-2 px-3.5 pt-2.5 pb-3.5">
{card.plans.map((plan) => (
<li
key={plan.name}
className={
plan.recommended
? "rounded-md border border-transparent bg-muted p-3 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--spectrum-3)_45%,transparent)]"
: "rounded-md border border-border p-3"
}
>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[0.9rem] font-semibold text-foreground">
{plan.name}
{plan.recommended && <span className="ml-2 font-mono text-[0.66rem] font-normal text-subtle-foreground">recommended</span>}
</span>
<span className="font-semibold text-foreground tabular-nums">
{formatMoney(plan.price, card.currency)}
{plan.period && <span className="text-[0.74rem] font-normal text-muted-foreground"> / {plan.period}</span>}
</span>
</div>
<ul className="mt-1.5 mb-0 grid list-none gap-0.5 p-0 text-[0.78rem] text-muted-foreground">
{plan.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
</li>
))}
</ul>
{card.note && <CardText className="mt-0 px-3.5 pb-3">{card.note}</CardText>}
<CardActions actions={card.actions} />
</ReplyCard>
);
}
export function EstimateCard({ card }: { card: Card<"estimate"> }) {
const total = card.lines.reduce((sum, line) => sum + line.amount, 0);
const splitTotal = card.split?.reduce((sum, part) => sum + part.amount, 0) ?? 0;
return (
<ReplyCard label={card.label ?? "Estimate"} aside={<span className="font-mono text-[0.72rem] text-subtle-foreground">{card.currency}</span>} aria-label={card.label ?? "Estimate"}>
<dl className="m-0 grid gap-1 px-3.5 pt-2.5">
{card.lines.map((line) => (
<div key={line.label} className="flex items-baseline justify-between gap-3 text-[0.84rem]">
<dt className="text-muted-foreground">
{line.label}
{line.note && <span className="ml-1.5 font-mono text-[0.66rem] text-subtle-foreground">{line.note}</span>}
</dt>
<dd className="m-0 text-foreground tabular-nums">{formatMoney(line.amount, card.currency)}</dd>
</div>
))}
</dl>
<div className="mx-3.5 mt-2.5 flex items-baseline justify-between border-t border-border pt-2.5">
<span className="text-[0.84rem] text-muted-foreground">Total</span>
<strong className="text-lg font-semibold text-foreground tabular-nums">{formatMoney(total, card.currency)}</strong>
</div>
{card.split && splitTotal > 0 && (
<div className="mx-3.5 mt-3 flex overflow-hidden rounded-md border border-border" aria-label="Payment split">
{card.split.map((part, index) => (
<div
key={part.label}
className={index === 0 ? "bg-muted px-2.5 py-2" : "px-2.5 py-2"}
style={{ width: `${(part.amount / splitTotal) * 100}%` }}
>
<small className="block text-[0.68rem] text-subtle-foreground">{part.label}</small>
<b className="text-[0.84rem] font-semibold text-foreground tabular-nums">{formatMoney(part.amount, card.currency)}</b>
</div>
))}
</div>
)}
{card.note && <CardText className="px-3.5 pt-2.5">{card.note}</CardText>}
<div className="pt-3">
<CardActions actions={card.actions} />
</div>
</ReplyCard>
);
}
export function ProjectCard({ card }: { card: Card<"project"> }) {
return (
<ReplyCard label={card.label ?? "Project"} aria-label={card.title}>
{card.image && (
<div className="mx-3.5 mt-2.5 overflow-hidden rounded-md border border-border bg-muted" style={{ aspectRatio: `${card.image.width} / ${card.image.height}` }}>
<img src={card.image.src} alt={card.image.alt} width={card.image.width} height={card.image.height} loading="lazy" className="block h-full w-full object-cover" />
</div>
)}
<div className="grid gap-2 px-3.5 pt-2.5 pb-3">
<div>
<CardTitle>{card.title}</CardTitle>
<CardText>{card.summary}</CardText>
</div>
{card.tags?.length ? <Chips items={card.tags} /> : null}
</div>
<CardActions actions={card.actions} />
</ReplyCard>
);
}
export function FaqCard({ card }: { card: Card<"faq"> }) {
return (
<ReplyCard label={card.label ?? "Questions"} aria-label={card.label ?? "Questions"}>
<div className="grid px-3.5 pt-1.5 pb-2">
{card.items.map((item) => (
<details key={item.question} className="group border-b border-border py-2 last:border-b-0">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-[0.86rem] font-semibold text-foreground [&::-webkit-details-marker]:hidden">
{item.question}
<span aria-hidden="true" className="text-muted-foreground transition-transform duration-200 ease-expo-out group-open:rotate-45">
+
</span>
</summary>
<p className="mt-1.5 mb-0 text-[0.82rem] leading-normal text-muted-foreground">{item.answer}</p>
</details>
))}
</div>
<CardActions actions={card.actions} />
</ReplyCard>
);
}
/** Renders any card from its data, for cards an assistant streams in. */
export function ReplyCardView({ card }: { card: ReplyCardData }) {
switch (card.kind) {
case "link":
return <LinkCard card={card} />;
case "steps":
return <StepsCard card={card} />;
case "plans":
return <PlansCard card={card} />;
case "estimate":
return <EstimateCard card={card} />;
case "project":
return <ProjectCard card={card} />;
case "faq":
return <FaqCard card={card} />;
}
}
export { CardAction };components/vibebox/reply-cards/index.tsx
/**
* VibeBox Motion · Rich Reply Cards
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
export { EstimateCard, FaqCard, LinkCard, PlansCard, ProjectCard, ReplyCardView, StepsCard } from "./cards";
export { CardAction, CardActions, CardLabel, CardText, CardTitle, Chips, ReplyCard, formatMoney } from "./primitives";
export type { ReplyAction, ReplyCardData, ReplyCardKind } from "./types";components/vibebox/reply-cards/primitives.tsx
/**
* VibeBox Motion · Rich Reply Cards
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { ReplyAction } from "./types";
/* Shared pieces: a quiet surface, a mono label led by a spectrum line, compact actions and small
chips. Cards rise in with the token reveal (off with reduced motion). */
export function ReplyCard({ label, aside, children, className, ...rest }: { label?: string; aside?: ReactNode; children: ReactNode; className?: string; "aria-label"?: string }) {
return (
<section
data-vb="reply-card"
className={cn(
"relative overflow-hidden rounded-lg border border-border bg-card text-card-foreground motion-safe:animate-reveal",
className,
)}
{...rest}
>
{(label || aside) && (
<header className="flex items-center justify-between gap-2.5 px-3.5 pt-3">
{label ? <CardLabel>{label}</CardLabel> : <span />}
{aside}
</header>
)}
{children}
</section>
);
}
export function CardLabel({ children }: { children: ReactNode }) {
return (
<p className="m-0 inline-flex items-center gap-2 font-mono text-[0.7rem] leading-tight text-subtle-foreground before:h-0.5 before:w-4 before:shrink-0 before:rounded-full before:bg-spectrum before:content-['']">
{children}
</p>
);
}
export function CardTitle({ children, className }: { children: ReactNode; className?: string }) {
return <h4 className={cn("m-0 text-base leading-tight font-semibold tracking-[-0.015em] text-foreground", className)}>{children}</h4>;
}
export function CardText({ children, className }: { children: ReactNode; className?: string }) {
return <p className={cn("mt-1 mb-0 text-[0.84rem] leading-normal text-muted-foreground", className)}>{children}</p>;
}
export function Chips({ items }: { items: string[] }) {
return (
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
{items.map((item) => (
<li key={item} className="rounded-full border border-border bg-muted px-2 py-0.5 text-[0.74rem] leading-snug text-muted-foreground">
{item}
</li>
))}
</ul>
);
}
function ArrowIcon({ external }: { external: boolean }) {
return external ? (
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" className="shrink-0">
<path d="M6 3h7v7M13 3 4 12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" className="shrink-0 transition-transform duration-200 ease-expo-out group-hover:translate-x-0.5">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function CardAction({ action }: { action: ReplyAction }) {
const external = /^https?:\/\//.test(action.href);
return (
<a
href={action.href}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}
className={cn(
"group inline-flex min-h-[34px] items-center gap-1.5 rounded-md border px-3 text-[0.82rem] font-semibold whitespace-nowrap no-underline",
"transition-[background-color,border-color,transform] duration-(--vb-duration-micro) ease-expo-out active:scale-[0.98]",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card focus-visible:outline-none",
action.primary
? "border-transparent bg-primary text-primary-foreground hover:opacity-90"
: "border-border-strong bg-muted text-foreground hover:bg-accent",
)}
>
<span>{action.label}</span>
<ArrowIcon external={external} />
</a>
);
}
export function CardActions({ actions }: { actions?: ReplyAction[] }) {
if (!actions?.length) return null;
return (
<div className="flex flex-wrap items-center gap-2 px-3.5 pb-3.5">
{actions.map((action) => (
<CardAction key={action.href + action.label} action={action} />
))}
</div>
);
}
/** Formats an amount in a currency with the visitor-neutral locale for that currency. */
export function formatMoney(amount: number, currency: string) {
const locale = currency === "PHP" ? "en-PH" : "en-US";
return new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }).format(amount);
}components/vibebox/reply-cards/types.ts
/**
* VibeBox Motion · Rich Reply Cards
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
/** Card data an assistant can send; the server resolves it from real records, never invents it. */
export type ReplyAction = { label: string; href: string; primary?: boolean };
export type ReplyCardData =
| { kind: "link"; label?: string; title: string; description?: string; href: string }
| { kind: "steps"; label?: string; title?: string; steps: { title: string; body?: string }[]; actions?: ReplyAction[] }
| {
kind: "plans";
label?: string;
currency: string;
plans: { name: string; price: number; period?: string; features: string[]; recommended?: boolean }[];
note?: string;
actions?: ReplyAction[];
}
| {
kind: "estimate";
label?: string;
currency: string;
lines: { label: string; amount: number; note?: string }[];
split?: { label: string; amount: number }[];
note?: string;
actions?: ReplyAction[];
}
| {
kind: "project";
label?: string;
title: string;
summary: string;
image?: { src: string; alt: string; width: number; height: number };
tags?: string[];
actions?: ReplyAction[];
}
| { kind: "faq"; label?: string; items: { question: string; answer: string }[]; actions?: ReplyAction[] };
export type ReplyCardKind = ReplyCardData["kind"];Props
| Prop | Type | Default | Description |
|---|---|---|---|
| card * | ReplyCardData | — | The card to render (ReplyCardView), or each card's own data. |
| className | string | — | On ReplyCard: merged with cn(). |
Accessibility and motion
- Keyboard
- Actions are links; FAQ items are details elements (Enter or Space toggles).
- Screen readers
- Each card is a named section; estimates use a description list; steps use an ordered list.
- Touch
- Actions are at least 34 px tall with room between them.
- Reduced motion
- Cards appear in place without the rise.
Edge cases and limits
- Unknown currencies format with the
en-USlocale. - A split that does not add up to the total still draws to its own scale; keep them consistent in your data.
Related

The front of an AI chat: a magnetic launcher with a question teaser, a window or phone sheet, and a quick menu.
AI and chat · Motion

An honest working line for AI chats: a small orb, a status that advances in stages, and a spectrum comet.
AI and chat · Motion

Liquid Orb
ProA living liquid drop for an AI assistant: it listens, thinks and speaks, dents under touch and morphs between shapes.
3D · WebGL2