Product gridDesign system
Site

Product grid

Product cards with a title, description, action, tags, and demo window.

npx context-ds add product-grid
On the site

Usage

Use it for

Presenting the main products, two to four of them, with a row of smaller pills for the rest of the API (MoreApis, a marquee on phones). Put the agent setup window beside the section heading when the page offers agent setup.

Avoid

  • Filled chips
  • A black pill in every card (Explore is the outline pill)
  • Screenshots that ignore the media panel's ring

Dependencies: badge, button, lucide-react

Example

examples/product-grid.tsx
import { SiteSection } from "@/components/site/section";import { DotGlyph } from "@/components/ui/dot-glyph";import { ProductGrid } from "@/components/site/product-grid";// Stand-ins for the products' dot stories, which come later.const SEARCH = ["22222..", "2...2..", "2...2..", "22222..", "....22.", ".....22", "......2"];const PAGE = ["22222", "2...2", "2.2.2", "2...2", "22222"];const ANSWER = ["..2..", ".222.", "22222", ".222.", "..2.."];const WATCH = ["1.1.1", ".2.2.", "1.2.1", ".2.2.", "1.1.1"];export function Site() {  return (<SiteSection>    <ProductGrid      className="w-full"      products={[        { name: "Search", description: "Search the web or the news and get ranked results with clean page content, not just links.", tags: ["Fresh sources for RAG", "Company news", "Agent web search"], href: "https://context.dev/search", media: <DotGlyph rows={SEARCH} className="size-24 text-brand" /> },        { name: "Scrape", description: "Turn any page or PDF into Markdown, HTML, structured fields, images, or screenshots in one request.", tags: ["Scrape to Markdown", "Onboarding autofill", "PDF parsing"], href: "https://context.dev/web-scraping-api", media: <DotGlyph rows={PAGE} className="size-24 text-brand" /> },        { name: "Research", description: "Ask a research question and get a structured answer in your JSON shape, with the sources behind it.", tags: ["Company research", "Entity enrichment", "Cited answers"], href: "https://context.dev/research", media: <DotGlyph rows={ANSWER} className="size-24 text-brand" /> },        { name: "Monitor", description: "Watch pages on a schedule and get notified when pricing, content, or pages change.", tags: ["Pricing changes", "New pages", "Webhook updates"], href: "https://context.dev/monitors", media: <DotGlyph rows={WATCH} className="size-24 text-brand" /> },      ]}      more={[        { label: "Crawl", href: "https://context.dev/data/crawl-website-api" },        { label: "Map URLs", href: "https://context.dev/map" },        { label: "Batches", href: "https://context.dev/batches" },        { label: "HTML scraping", href: "https://context.dev/data/web-scrape-html-api" },        { label: "Screenshots", href: "https://context.dev/data/screenshot-api" },        { label: "Brand data", href: "https://context.dev/data/brand-data" },        { label: "Logos", href: "https://context.dev/data/logo-api" },        { label: "Fonts", href: "https://context.dev/data/font-api" },      ]}    />  </SiteSection>);}

Source

components/site/product-grid.tsx
"use client";import * as React from "react";import { ArrowUpRight, Globe } from "lucide-react";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";// The home page's product cards: a white card in the soft blue ring, the product's name at h3 beside an outline// "Explore" pill, one line of description, outlined chips for what people build with it, and a demo window (the// product's dot story) filling the rest of the card. Two columns from tablet up; a row of smaller pills for the rest// of the API underneath, which runs as a marquee on phones.export type ProductGridItem = { name: string; description: string; tags?: string[]; href: string; linkLabel?: string; media?: React.ReactNode };/** The demo window at the bottom of a card: a white panel in the ring, its radius repeated so the corners match. */function ProductMedia({ children, className }: { children?: React.ReactNode; className?: string }) {  return (    <div data-slot="product-media" className={cn("relative mt-auto flex flex-1 flex-col rounded-card bg-ring-tint p-2", className)}>      <div className="relative flex min-h-88 flex-1 flex-col items-center justify-center overflow-hidden rounded-card bg-surface px-4 py-8 select-none sm:px-9">{children}</div>    </div>  );}function ProductCard({ name, description, tags = [], href, linkLabel = "Explore", media, className }: ProductGridItem & { className?: string }) {  return (    <div data-slot="product-card" className={cn("flex rounded-card bg-ring-tint p-2", className)}>      <article className="relative flex flex-1 flex-col gap-10 overflow-hidden rounded-card bg-surface p-6 ring-1 ring-hairline md:p-8">        <div className="flex flex-col gap-3">          <div className="mb-2 flex items-center justify-between gap-4">            <h3 className="text-h3 leading-heading font-normal tracking-normal text-fg">{name}</h3>            <Button asChild variant="pill-outline" className="group/explore">              <a href={href}>                {linkLabel}                <span className="sr-only"> {name}</span>                <ArrowUpRight aria-hidden="true" />              </a>            </Button>          </div>          <p className="text-body-lg leading-snug text-fg-secondary">{description}</p>          {tags.length > 0 && (            <ul aria-label={`What you can build with ${name}`} className="flex flex-wrap gap-1.5 leading-snug">              {tags.map((tag) => (                <li key={tag}>                  <Badge variant="chip">{tag}</Badge>                </li>              ))}            </ul>          )}        </div>        <ProductMedia>{media}</ProductMedia>      </article>    </div>  );}const MARQUEE_MS = 24000;function MoreApiList({ items, duplicate = false, className }: { items: { label: string; href: string }[]; duplicate?: boolean; className?: string }) {  return (    <ul aria-hidden={duplicate || undefined} className={cn("flex shrink-0 gap-2 pr-2 md:pr-0", className)}>      {items.map((api) => (        <li key={api.href}>          <a            href={api.href}            tabIndex={duplicate ? -1 : undefined}            className="inline-flex w-fit items-center justify-center rounded-pill border border-black/15 px-3 py-1.5 text-body-sm leading-snug whitespace-nowrap text-fg-nav outline-none transition-colors duration-200 ease-out hover:bg-black/3 hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring motion-reduce:transition-none"          >            {api.label}          </a>        </li>      ))}    </ul>  );}/** * The rest of the API as small outline pills beside a label. From tablet up it is one row that scrolls sideways * without a scrollbar; on phones it runs as a marquee fading in from just past the label, pausing while it is hovered, * pressed or focused. Reduced motion keeps the swipeable row instead. */function MoreApis({ items, label = "Also in the API", icon, className }: { items: { label: string; href: string }[]; label?: React.ReactNode; icon?: React.ReactNode; className?: string }) {  const track = React.useRef<HTMLDivElement>(null);  const [marquee, setMarquee] = React.useState(false);  React.useEffect(() => {    const phone = window.matchMedia("(max-width: 47.99rem)");    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");    const update = () => setMarquee(phone.matches && !reduce.matches);    update();    phone.addEventListener("change", update);    reduce.addEventListener("change", update);    return () => {      phone.removeEventListener("change", update);      reduce.removeEventListener("change", update);    };  }, []);  // A constant slide by half the track (one copy of the list), so the loop closes on the duplicate without a jump.  React.useEffect(() => {    const node = track.current;    if (!marquee || !node) return;    const animation = node.animate([{ transform: "translateX(0)" }, { transform: "translateX(-50%)" }], { duration: MARQUEE_MS, iterations: Infinity, easing: "linear" });    const pause = () => animation.pause();    const play = () => {      if (!node.matches(":hover, :focus-within")) animation.play();    };    const events: [string, () => void][] = [["pointerenter", pause], ["pointerdown", pause], ["focusin", pause], ["pointerleave", play], ["pointerup", play], ["focusout", play]];    const controller = new AbortController();    events.forEach(([name, handler]) => node.addEventListener(name, handler, { signal: controller.signal }));    return () => {      animation.cancel();      controller.abort();    };  }, [marquee]);  return (    <div data-slot="more-apis" className={cn("flex items-center gap-4 md:overflow-x-auto md:[scrollbar-width:none] md:[&::-webkit-scrollbar]:hidden", className)}>      <div className="inline-flex shrink-0 items-center gap-2 text-body-sm leading-snug whitespace-nowrap text-fg-secondary">        {icon ?? <Globe aria-hidden="true" className="size-4" />}        {label}      </div>      <div        className={cn(          "relative min-w-0 flex-1 md:overflow-visible md:[mask-image:none]",          marquee ? "overflow-hidden [mask-image:linear-gradient(to_right,transparent,var(--ds-color-black)_var(--ds-space-10))]" : "overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",        )}      >        <div ref={track} className="flex w-max">          <MoreApiList items={items} />          {/* The second copy only closes the loop, so it is hidden from assistive tech and the tab order. */}          {marquee && <MoreApiList items={items} duplicate />}        </div>      </div>    </div>  );}function ProductGrid({  products,  more,  moreLabel = "Also in the API",  className,}: {  products: ProductGridItem[];  /** The rest of the API, as small pills under the grid. */  more?: { label: string; href: string }[];  moreLabel?: string;  className?: string;}) {  return (    <div data-slot="product-grid" className={cn("flex flex-col gap-8", className)}>      {/* The ring sits outside each card's footprint: the grid's gap is measured between the cards, not their rings. */}      <div className="grid grid-cols-1 gap-6 md:grid-cols-2">        {products.map((product) => (          <ProductCard key={product.name} className="-m-2" {...product} />        ))}      </div>      {more && more.length > 0 && <MoreApis items={more} label={moreLabel} />}    </div>  );}export { ProductGrid, ProductCard, ProductMedia, MoreApis };

Reference: src/components/sections/home/product-grid.tsx; src/components/sections/home/product-demo-panel.tsx; src/components/sections/home/story-stage.tsx.