Alternative page sectionsDesign system
Site

Alternative page sections

Comparison cards, tables, pricing comparisons, and quotes for alternatives pages.

npx context-ds add alternative-sections
On the site

Usage

Use it for

Compare pages ("The top X alternative"). Order - Hero with AlternativeQuickCompare in HeroMedia, LogoStrip, AlternativeReasons, AlternativeComparisonTable, AlternativePricingTable, AlternativeFitGuide, ProductFaq (product-landing), ClosingCta. A long comparison article uses BlogPost and BlogProse.

Avoid

  • Red crosses for a missing feature (a faint dash)
  • Green checks (our checks are blue
  • theirs gray)
  • Tinting the competitor's column
  • Claims without a source in the footnotes

Dependencies: section, lucide-react

Example

examples/alternative-sections.tsx
import { Button } from "@/components/ui/button";import { Hero, HeroMedia } from "@/components/site/hero";import {  AlternativeComparisonTable,  AlternativeFitGuide,  AlternativePricingTable,  AlternativeQuickCompare,  AlternativeReasons,} from "@/components/site/alternative-sections";const QUICK = [  { feature: "Scrape to Markdown or HTML", ours: true, theirs: true },  { feature: "Search with page Markdown", ours: "2 credits", theirs: "12 credits" },  { feature: "Zero data retention", ours: "Included", theirs: "+1 credit/page" },  { feature: "Brand data on the same key", ours: true, theirs: false },];export function Site() {  return (    <div className="w-full">      <Hero        title="The top Firecrawl alternative"        accent="Firecrawl alternative"        description="Scrape, crawl, map, and search the web with more concurrency and a lower price per page."        actions={          <>            <Button variant="pill" asChild><a href="https://context.dev/signup">Get API access free</a></Button>            <Button variant="pill-outline" asChild><a href="https://docs.context.dev">View documentation</a></Button>          </>        }        media={          <HeroMedia>            <AlternativeQuickCompare ours="Context.dev" competitor="Firecrawl" rows={QUICK} />          </HeroMedia>        }      />      <AlternativeReasons        description="Both scrape, crawl, map, and search the web. Context.dev gives you more concurrent requests and a lower price per page on every paid plan."        reasons={[          { tag: "Key difference", title: "The same scraping toolkit, with more headroom per dollar", body: "Scrape pages to Markdown, HTML, screenshots, images, or JSON against your schema, with more credits and concurrency on every paid plan." },          { tag: "Search", title: "Search that reads the results for 2 credits, not 12", body: "Ten results with Markdown cost 2 credits." },          { tag: "Billing", title: "Fewer paid add-ons", body: "Highlights add 1 credit instead of 4, and zero data retention adds nothing." },        ]}      />      <AlternativeComparisonTable        ours="Context.dev"        competitor="Firecrawl"        rows={[          { category: "Scraping and crawling", feature: "Scrape to Markdown or HTML", ours: true, theirs: true },          { category: "Scraping and crawling", feature: "Async batch scraping", ours: "Up to 100,000 URLs", theirs: true },          { category: "Deployment", feature: "Self-hosted open source", ours: false, theirs: "AGPL-3.0" },        ]}      />      <AlternativePricingTable        ours="Context.dev"        competitor="Firecrawl"        description="Public list prices on both sites, broken down to the cost of scraping 1,000 pages at each tier."        tiers={[          { tier: "Entry", ours: { plan: "Developer", price: "$19/mo", credits: "20,000 credits", concurrency: "10 concurrent", costPerThousand: "$0.95" }, theirs: { plan: "Hobby", price: "$19/mo", credits: "3,000 credits", concurrency: "5 concurrent", costPerThousand: "$6.33" } },          { tier: "Mid", ours: { plan: "Pro", price: "$99/mo", credits: "150,000 credits", concurrency: "50 concurrent", costPerThousand: "$0.66" }, theirs: { plan: "Standard", price: "$99/mo", credits: "100,000 credits", concurrency: "50 concurrent", costPerThousand: "$0.99" } },        ]}        footnotes={[{ content: "Cost per 1,000 pages assumes 1 credit per page, the base scrape price on both APIs.", strong: true }, { content: "Prices are from each provider's public pricing page." }]}      />      <AlternativeFitGuide        description="Both are capable web data APIs. These are the cases where the choice is clear."        columns={[          { title: "Choose Context.dev if", points: ["You scrape at volume and want a lower price per page.", "You also need brand or company data."] },          { title: "Choose Firecrawl if", points: ["You need to self-host.", "You need long, live browser sessions."] },        ]}        storiesTitle="Teams that switched"        stories={[{ quote: "Pricing was what triggered it, and responsive support sealed the deal.", person: "A founder", href: "https://context.dev/customers", linkLabel: "Read the story" }]}      />    </div>  );}

Source

components/site/alternative-sections.tsx
import * as React from "react";import { Check } from "lucide-react";import { SectionHeaderRule, SectionHeading, SiteSection } from "@/components/site/section";import { cn } from "@/lib/utils";// The alternatives pages ("The top Firecrawl alternative"): the site's Hero with a quick comparison card in its blue// frame, the logo strip, then why switch, the full comparison, the cost per 1,000 pages, which one fits, the FAQ and// the closing call to action. Every table is a white card in the soft blue ring, our column tinted blue; a yes is a// check (blue for us, gray for them), a no is a faint dash, and a string is shown as is.export type ComparisonSupport = boolean | string;/** One comparison cell: a check, a dash, or a short note. `ours` paints it blue. */function ComparisonSupportCell({ value, ours = false }: { value: ComparisonSupport; ours?: boolean }) {  if (typeof value === "string") return <span className={cn("text-body-sm", ours ? "font-medium text-fg-brand" : "text-fg-muted")}>{value}</span>;  if (value) {    return (      <span className="inline-flex justify-center">        <Check aria-hidden="true" className={cn("size-5", ours ? "text-fg-brand" : "text-fg-faint")} />        <span className="sr-only">Yes</span>      </span>    );  }  return (    <span className="text-body-sm text-neutral-30">      <span aria-hidden="true">–</span>      <span className="sr-only">No</span>    </span>  );}// The ringed white table card: the ring is the wrapper's tint, so it shares the card's radius and adds no size.function TableCard({ label, minWidth, children }: { label: string; minWidth: string; children: React.ReactNode }) {  return (    <div className="-m-2 rounded-card bg-ring-tint p-2">      <div tabIndex={0} role="region" aria-label={label} className="overflow-x-auto rounded-card bg-surface ring-1 ring-hairline outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2">        <table className={cn("w-full border-collapse text-left leading-snug", minWidth)}>{children}</table>      </div>    </div>  );}const OURS_TINT = "bg-brand/5";export type QuickCompareRow = { feature: string; ours: ComparisonSupport; theirs: ComparisonSupport };/** * The hero's quick comparison: a white card for the hero's blue frame (HeroMedia), three columns, the rows past the * third hidden on phones (every row is in the full comparison further down). */function AlternativeQuickCompare({ ours, competitor, rows, rowsOnPhones = 3, className }: { ours: string; competitor: string; rows: QuickCompareRow[]; rowsOnPhones?: number; className?: string }) {  return (    <div data-slot="alternative-quick-compare" className={cn("w-full overflow-hidden rounded-card bg-surface text-left", className)}>      <table className="w-full border-collapse leading-snug">        <thead>          <tr className="text-caption sm:text-body-sm">            <th scope="col" className="px-4 py-3 font-normal text-fg-muted sm:px-6">              Feature            </th>            <th scope="col" className="w-22 px-1 py-3 text-center font-medium text-fg-brand sm:w-36 sm:px-3">              {ours}            </th>            <th scope="col" className="w-22 px-1 py-3 text-center font-normal text-fg sm:w-36 sm:px-3">              {competitor}            </th>          </tr>        </thead>        <tbody>          {rows.map((row, index) => (            <tr key={row.feature} className={cn("border-t border-wash-pill", index >= rowsOnPhones && "max-sm:hidden")}>              <th scope="row" className="px-4 py-3 text-body-sm font-normal text-fg-nav sm:px-6 sm:py-3.5 sm:text-body">                {row.feature}              </th>              <td className="px-1 py-3 text-center sm:px-3">                <ComparisonSupportCell value={row.ours} ours />              </td>              <td className="px-1 py-3 text-center sm:px-3">                <ComparisonSupportCell value={row.theirs} />              </td>            </tr>          ))}        </tbody>      </table>    </div>  );}export type AlternativeReason = { title: React.ReactNode; body: React.ReactNode; tag: React.ReactNode };function ReasonCard({ reason, featured = false }: { reason: AlternativeReason; featured?: boolean }) {  return (    <div className="flex h-full min-w-0 flex-col gap-4 rounded-card bg-surface p-6 ring-1 ring-hairline md:p-8">      <span className="w-fit rounded-pill bg-ring-tint px-3 py-1 text-body-sm leading-snug text-fg-brand">{reason.tag}</span>      <h3 className={cn("font-normal tracking-normal text-fg", featured ? "max-w-3xl text-h3 leading-heading" : "text-h4 leading-snug")}>{reason.title}</h3>      <p className={cn("text-fg-secondary", featured ? "max-w-3xl text-body-lg leading-snug" : "text-body leading-snug")}>{reason.body}</p>    </div>  );}/** * Why switch: the first reason as a wide featured card, the rest two across (an odd last one spans both), each with a * tag pill, a title and a paragraph. */function AlternativeReasons({  title = "Why",  accent = "switch",  description,  reasons,  className,  ...props}: Omit<React.ComponentProps<"section">, "title"> & { title?: React.ReactNode; accent?: React.ReactNode; description?: React.ReactNode; reasons: AlternativeReason[] }) {  const [featured, ...rest] = reasons;  return (    <SiteSection lines className={className} {...props}>      <SectionHeading title={title} accent={accent} description={description} />      <SectionHeaderRule />      <ul className="grid grid-cols-1 gap-6 md:grid-cols-2">        {featured && (          <li className="-m-2 rounded-card bg-ring-tint p-2 md:col-span-2">            <ReasonCard reason={featured} featured />          </li>        )}        {rest.map((reason, index) => (          <li key={index} className={cn("-m-2 rounded-card bg-ring-tint p-2", rest.length % 2 === 1 && index === rest.length - 1 && "md:col-span-2")}>            <ReasonCard reason={reason} />          </li>        ))}      </ul>    </SiteSection>  );}export type AlternativeComparisonRow = { category: string; feature: React.ReactNode; ours: ComparisonSupport; theirs: ComparisonSupport };/** The full comparison: a ringed table grouped by category, our column tinted. */function AlternativeComparisonTable({  ours,  competitor,  rows,  title = "Full",  accent = "comparison",  className,  ...props}: Omit<React.ComponentProps<"section">, "title"> & { ours: string; competitor: string; rows: AlternativeComparisonRow[]; title?: React.ReactNode; accent?: React.ReactNode }) {  const categories = [...new Set(rows.map((row) => row.category))];  return (    <SiteSection lines className={className} {...props}>      <SectionHeading title={title} accent={accent} />      <SectionHeaderRule />      <TableCard label={`${ours} compared with ${competitor}`} minWidth="min-w-136">        <thead>          <tr className="text-body-sm">            <th scope="col" className="px-5 py-4 font-normal text-fg-muted md:px-8">              Feature            </th>            <th scope="col" className={cn("w-36 px-4 py-4 text-center font-medium text-fg-brand md:w-44", OURS_TINT)}>              {ours}            </th>            <th scope="col" className="w-36 px-4 py-4 text-center font-normal text-fg md:w-44">              {competitor}            </th>          </tr>        </thead>        {categories.map((category) => (          <tbody key={category}>            <tr className="border-t border-hairline">              <th scope="colgroup" colSpan={3} className="px-5 pt-6 pb-2 text-body-sm font-medium text-fg md:px-8">                {category}              </th>            </tr>            {rows              .filter((row) => row.category === category)              .map((row, index) => (                <tr key={index} className="border-t border-wash-pill">                  <th scope="row" className="px-5 py-3.5 text-body font-normal text-fg-nav md:px-8">                    {row.feature}                  </th>                  <td className={cn("px-4 py-3.5 text-center", OURS_TINT)}>                    <ComparisonSupportCell value={row.ours} ours />                  </td>                  <td className="px-4 py-3.5 text-center">                    <ComparisonSupportCell value={row.theirs} />                  </td>                </tr>              ))}          </tbody>        ))}      </TableCard>    </SiteSection>  );}export type ComparedPlan = { plan: React.ReactNode; price: React.ReactNode; credits: React.ReactNode; concurrency?: React.ReactNode; costPerThousand?: React.ReactNode };export type PricingTier = { tier: React.ReactNode; ours: ComparedPlan; theirs: ComparedPlan };function PlanCell({ plan, ours = false }: { plan: ComparedPlan; ours?: boolean }) {  return (    <div className="flex flex-col gap-1.5">      <p className="text-body text-fg">        {plan.plan} <span className="text-fg-muted">{plan.price}</span>      </p>      <p className="text-body-sm text-fg-muted">{plan.credits}</p>      {plan.concurrency && <p className="text-body-sm text-fg-muted">{plan.concurrency}</p>}      {plan.costPerThousand && (        <p className="pt-1 text-body-sm">          <span className={cn("font-medium", ours ? "text-fg-brand" : "text-fg-nav")}>{plan.costPerThousand}</span> <span className="text-fg-muted">per 1K pages</span>        </p>      )}    </div>  );}/** Plan by plan, side by side: a ringed table of tiers with each plan's price, credits, concurrency and unit cost, then numbered footnotes. */function AlternativePricingTable({  ours,  competitor,  tiers,  footnotes,  title = "Cost per",  accent = "1,000 pages",  description,  className,  ...props}: Omit<React.ComponentProps<"section">, "title"> & {  ours: string;  competitor: string;  tiers: PricingTier[];  /** Numbered notes under the table; `strong` sets one in the darker, medium weight. */  footnotes?: { content: React.ReactNode; strong?: boolean }[];  title?: React.ReactNode;  accent?: React.ReactNode;  description?: React.ReactNode;}) {  return (    <SiteSection lines className={className} {...props}>      <SectionHeading title={title} accent={accent} description={description} />      <SectionHeaderRule />      <TableCard label={`Cost per 1,000 pages on ${ours} and ${competitor}`} minWidth="min-w-160">        <thead>          <tr className="text-body-sm">            <th scope="col" className="w-40 px-5 py-4 font-normal text-fg-muted md:px-8">              Tier            </th>            <th scope="col" className={cn("px-5 py-4 font-medium text-fg-brand", OURS_TINT)}>              {ours}            </th>            <th scope="col" className="px-5 py-4 font-normal text-fg">              {competitor}            </th>          </tr>        </thead>        <tbody>          {tiers.map((row, index) => (            <tr key={index} className="border-t border-wash-pill align-top">              <th scope="row" className="px-5 py-5 text-body font-normal text-fg md:px-8">                {row.tier}              </th>              <td className={cn("px-5 py-5", OURS_TINT)}>                <PlanCell plan={row.ours} ours />              </td>              <td className="px-5 py-5">                <PlanCell plan={row.theirs} />              </td>            </tr>          ))}        </tbody>      </TableCard>      {footnotes && footnotes.length > 0 && (        <ol className="mt-8 flex max-w-3xl list-decimal flex-col gap-2 pl-5 text-body-sm leading-snug text-fg-muted [&_strong]:font-medium [&_strong]:text-fg-nav">          {footnotes.map((note, index) => (            <li key={index} className={note.strong ? "font-medium text-fg-nav" : undefined}>              {note.content}            </li>          ))}        </ol>      )}    </SiteSection>  );}export type FitColumn = { title: React.ReactNode; points: React.ReactNode[] };export type SwitchStory = { quote: React.ReactNode; person: React.ReactNode; href?: string; linkLabel?: React.ReactNode };// The fit guide's cards: white, the raised hairline shadow.const FIT_CARD = "rounded-shell bg-surface p-7 shadow-raised md:p-8";/** * Which one fits: a centered heading and line, two cards of bullet points ("Choose us if", "Choose them if"), then a * heading and two quotes from teams that switched. */function AlternativeFitGuide({  title = "Which one fits",  description,  columns,  storiesTitle,  stories = [],  className,  ...props}: Omit<React.ComponentProps<"section">, "title"> & {  title?: React.ReactNode;  description?: React.ReactNode;  columns: FitColumn[];  storiesTitle?: React.ReactNode;  stories?: SwitchStory[];}) {  return (    <SiteSection lines className={cn("pt-16 pb-16 md:pt-24 md:pb-24", className)} {...props}>      <div className="mb-12 flex flex-col items-center gap-5 text-center">        <h2 className="text-h2 leading-none font-normal tracking-tight text-fg md:text-h1">{title}</h2>        {description && <p className="max-w-xl text-body-lg leading-relaxed text-fg-subtle [&_a]:text-fg-brand [&_a]:underline [&_a]:underline-offset-4">{description}</p>}      </div>      <div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-5">        {columns.map((column, index) => (          <div key={index} className={FIT_CARD}>            <h3 className="text-body-lg leading-snug font-normal tracking-tight text-fg md:text-h5">{column.title}</h3>            <ul className="mt-4 list-disc space-y-3 pl-5 text-body leading-relaxed text-fg-subtle">              {column.points.map((point, pointIndex) => (                <li key={pointIndex}>{point}</li>              ))}            </ul>          </div>        ))}      </div>      {stories.length > 0 && (        <>          {storiesTitle && <h3 className="mt-16 mb-6 text-center text-h4 leading-snug font-normal tracking-tight text-fg md:text-h3">{storiesTitle}</h3>}          <div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-5">            {stories.map((story, index) => (              <figure key={index} className={cn(FIT_CARD, "flex flex-col justify-between gap-5")}>                <blockquote className="text-body leading-relaxed text-fg-nav">“{story.quote}”</blockquote>                <figcaption className="flex flex-col gap-2 text-body-sm leading-5">                  <span className="font-medium text-fg">{story.person}</span>                  {story.href && story.linkLabel && (                    <a href={story.href} className="w-fit rounded-sm font-medium text-fg-brand outline-none hover:underline focus-visible:ring-2 focus-visible:ring-focus-ring">                      {story.linkLabel}                    </a>                  )}                </figcaption>              </figure>            ))}          </div>        </>      )}    </SiteSection>  );}export { ComparisonSupportCell, AlternativeQuickCompare, AlternativeReasons, AlternativeComparisonTable, AlternativePricingTable, AlternativeFitGuide };

Reference: src/components/sections/alternatives/alternative-hero.tsx; alternative-reasons.tsx; alternative-comparison-table.tsx; alternative-article.tsx; firecrawl/FirecrawlPricingTable.tsx; firecrawl/FirecrawlFitGuide.tsx; src/app/(marketing)/compare/the-top-firecrawl-alternative/page.tsx.