Skip to content
Motion
FreeIn reviewv1.0.0

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-cards

About

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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/reply-cards
  3. 3Use it:
    import { ReplyCardView } from "@/components/vibebox/reply-cards";
    
    {message.cards.map((card, index) => <ReplyCardView key={index} card={card} />)}

Code

4 files
components/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

PropTypeDefaultDescription
card *ReplyCardData—The card to render (ReplyCardView), or each card's own data.
classNamestring—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-US locale.
  • A split that does not add up to the total still draws to its own scale; keep them consistent in your data.
  • A chat launcher with a teaser of suggested questions

    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

  • A small spectrum orb beside a shimmering status line in a chat

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

    AI and chat · Motion

  • A glossy liquid orb in spectrum colours, rippling and changing mood

    A living liquid drop for an AI assistant: it listens, thinks and speaks, dents under touch and morphs between shapes.

    3D · WebGL2

↑ ↓ to move↵ to openesc to close