UI Library

Product card

A linked card with a name, tagline, metadata pills, and an arrow that nudges on hover. Every pill tone is a full literal class string, so Tailwind can actually see it.

Cardscardpillshover
apps/site/blocks/components/product-card.tsx
import { ArrowRight } from 'lucide-react';

export interface ProductCardProps {
  name?: string;
  tagline?: string;
  href?: string;
  /** Rendered as the accented pill. */
  category?: string;
  /** Rendered as the status pill; `muted` tones it down. */
  status?: string;
  statusTone?: 'active' | 'muted';
  /** Extra neutral pills, e.g. star and fork counts. */
  meta?: string[];
}

export function ProductCard({
  name = 'Caspian Notes',
  tagline = 'Markdown notes in VS Code, synced to your workspace.',
  href = '#',
  category,
  status,
  statusTone = 'active',
  meta = [],
}: ProductCardProps) {
  return (
    <a
      href={href}
      className="group flex flex-col gap-3 rounded-xl border border-border bg-card p-5 transition-colors hover:border-foreground/30 hover:bg-accent/40"
    >
      <div className="flex items-start justify-between gap-3">
        <div className="space-y-1">
          <div className="text-base font-semibold tracking-tight">{name}</div>
          <div className="text-sm text-muted-foreground">{tagline}</div>
        </div>
        {/* shrink-0 keeps the arrow from being squeezed by a long tagline. */}
        <ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
      </div>

      <div className="flex flex-wrap items-center gap-1.5">
        {category ? <Pill tone="category">{category}</Pill> : null}
        {status ? <Pill tone={statusTone === 'muted' ? 'muted' : 'status'}>{status}</Pill> : null}
        {meta.map((m) => (
          <Pill key={m} tone="muted">
            {m}
          </Pill>
        ))}
      </div>
    </a>
  );
}

function Pill({
  tone,
  children,
}: {
  tone: 'category' | 'status' | 'muted';
  children: React.ReactNode;
}) {
  // Full literal class strings per branch — Tailwind scans source text, so an
  // interpolated `border-${tone}/30` would emit no utility at all.
  const cls =
    tone === 'category'
      ? 'border-primary/30 bg-primary/10 text-primary'
      : tone === 'status'
        ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-500'
        : 'border-border bg-muted text-muted-foreground';
  return (
    <span
      className={`inline-flex h-5 items-center rounded-md border px-1.5 text-[10px] font-medium ${cls}`}
    >
      {children}
    </span>
  );
}

Dependencies

  • lucide-react — npm package

Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.