blocksection
Pricing comparison
The full feature matrix as a real table: row and column headers, colgroup scopes, and no meaning carried by a tick alone.
Preview
Installation
pnpm dlx shadcn@latest add https://facadeui.dev/r/pricing-comparison.jsonPulls in lucide-react@^1.47.0, badge, button, container, section, section-header, types, utils. The CLI installs them for you.
Usage
The exact source of the preview above.
import { PricingComparison } from "@registry/sections/pricing-comparison"
import { COMPARISON_GROUPS, COMPARISON_PLANS } from "./content"
export function Demo() {
return (
<PricingComparison
eyebrow="Compare"
title="Every feature, plan by plan"
plans={COMPARISON_PLANS}
groups={COMPARISON_GROUPS}
note="A tick is never the only thing in a cell — each carries hidden 'Included' text."
/>
)
}Source
What the CLI copies into your project, byte for byte.
/**
* PricingComparison — the full feature matrix, plan by plan.
*
* a11y: this is the section where a `<div>` grid would do the most damage, so it
* is a real `<table>` and the details matter.
*
* - Plan names are `<th scope="col">`, feature names are `<th scope="row">`.
* That pairing is what lets a screen reader announce "Team, Shared theme
* tokens, included" when the user lands on a cell — without it, the reader
* gets "included" with no idea of what or whose.
* - Group rows use `<th scope="colgroup">` spanning the table, so a feature
* inherits its group heading.
* - A tick or a cross is never the only content of a cell: each carries
* visually hidden "Included" / "Not included" text, and the icon is
* `aria-hidden`. Colour and shape alone fail 1.4.1.
* - The table has a `<caption>`. It can be visually hidden, but it is always
* there, because a table without one is an unnamed grid.
* - The scroll container is focusable, so a wide matrix can be scrolled without
* a mouse.
*
* Dependencies: lucide-react, react, @/lib/types, @/lib/utils,
* @/components/ui/badge, @/components/ui/button, @/components/ui/container,
* @/components/ui/section, @/components/ui/section-header.
*/
import { CheckIcon, MinusIcon } from "lucide-react"
import type { ElementType, ReactNode } from "react"
import type { CtaItem, LinkComponent, SectionBaseProps } from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Container } from "@/components/ui/container"
import { Section } from "@/components/ui/section"
import { SectionHeader } from "@/components/ui/section-header"
export interface ComparisonPlan {
name: string
price?: string
srPrice?: string
period?: string
cta?: CtaItem
featured?: boolean
featuredLabel?: string
}
/** `true`/`false` render a tick or a dash; a string renders as text. */
export type ComparisonValue = boolean | string
export interface ComparisonFeature {
label: string
/** One entry per plan, in the same order as `plans`. */
values: ComparisonValue[]
note?: string
}
export interface ComparisonGroup {
title: string
features: ComparisonFeature[]
}
export interface PricingComparisonProps extends SectionBaseProps {
plans: ComparisonPlan[]
groups: ComparisonGroup[]
title?: string
eyebrow?: string
description?: string
link?: LinkComponent
/** Table caption. Always present; hidden visually by default. */
caption?: string
showCaption?: boolean
note?: ReactNode
align?: "start" | "center"
}
function Cell({ value }: { value: ComparisonValue | undefined }) {
if (typeof value === "string") {
return <span className="text-foreground text-sm">{value}</span>
}
if (value) {
return (
<>
<CheckIcon
aria-hidden
focusable="false"
className="text-primary mx-auto size-5"
/>
{/* Never rely on the glyph alone. */}
<span className="sr-only">Included</span>
</>
)
}
return (
<>
<MinusIcon
aria-hidden
focusable="false"
className="text-muted-foreground mx-auto size-5"
/>
<span className="sr-only">Not included</span>
</>
)
}
export function PricingComparison({
plans,
groups,
title,
eyebrow,
description,
link,
caption,
showCaption = false,
note,
align = "center",
headingLevel = 2,
as,
spacing = "md",
className,
id,
}: PricingComparisonProps) {
const Link = (link ?? "a") as ElementType
const titleId = title ? (id ? `${id}-title` : slugId(title)) : undefined
const tableCaption =
caption ?? (title ? `${title}: plan comparison` : "Plan comparison")
return (
<Section
as={as ?? (title ? "section" : "div")}
spacing={spacing}
labelledBy={titleId}
id={id}
className={className}
>
<Container className="flex flex-col gap-12">
{title ? (
<SectionHeader
align={align}
eyebrow={eyebrow}
title={title}
description={description}
headingLevel={headingLevel}
titleId={titleId}
/>
) : null}
<div
tabIndex={0}
role="region"
aria-label={tableCaption}
className="focus-visible:ring-ring overflow-x-auto rounded-xl border focus-visible:outline-none focus-visible:ring-2"
>
<table className="w-full border-collapse text-left">
<caption
className={cn(
"px-4 py-3 text-sm",
showCaption ? "text-muted-foreground" : "sr-only",
)}
>
{tableCaption}
</caption>
<thead>
<tr className="border-b">
<th
scope="col"
className="bg-background sticky left-0 px-4 py-4 align-bottom"
>
<span className="sr-only">Feature</span>
</th>
{plans.map((plan) => (
<th
key={plan.name}
scope="col"
className={cn(
"min-w-40 px-4 py-4 text-center align-bottom",
plan.featured && "bg-accent/40",
)}
>
<span className="flex flex-col items-center gap-2">
{plan.featured ? (
<Badge variant="primary" size="sm">
{plan.featuredLabel ?? "Most popular"}
</Badge>
) : null}
<span className="text-foreground text-base font-semibold">
{plan.name}
</span>
{plan.price ? (
<span className="text-muted-foreground text-sm">
<span aria-hidden>
{plan.price}
{plan.period}
</span>
<span className="sr-only">
{plan.srPrice ?? plan.price}
{plan.period ? ` ${plan.period.replace(/^\//, "per ")}` : ""}
</span>
</span>
) : null}
{plan.cta ? (
<Link
href={plan.cta.href}
aria-label={
plan.cta["aria-label"] ?? `${plan.cta.label} — ${plan.name}`
}
className={buttonVariants({
variant:
plan.cta.variant ?? (plan.featured ? "primary" : "outline"),
size: "sm",
})}
>
{plan.cta.label}
</Link>
) : null}
</span>
</th>
))}
</tr>
</thead>
{groups.map((group) => (
<tbody key={group.title} className="border-b last:border-b-0">
<tr className="bg-muted/50">
{/* colgroup scope: every feature below inherits this heading. */}
<th
scope="colgroup"
colSpan={plans.length + 1}
className="text-foreground px-4 py-2.5 text-sm font-semibold"
>
{group.title}
</th>
</tr>
{group.features.map((feature) => (
<tr key={feature.label} className="border-t">
<th
scope="row"
className="bg-background sticky left-0 px-4 py-3 text-sm font-normal"
>
<span className="text-foreground">{feature.label}</span>
{feature.note ? (
<span className="text-muted-foreground mt-0.5 block text-pretty text-xs">
{feature.note}
</span>
) : null}
</th>
{plans.map((plan, index) => (
<td
key={plan.name}
className={cn(
"px-4 py-3 text-center",
plan.featured && "bg-accent/40",
)}
>
<Cell value={feature.values[index]} />
</td>
))}
</tr>
))}
</tbody>
))}
</table>
</div>
{note ? (
<p
className={cn(
"text-muted-foreground text-pretty text-sm",
align === "center" && "text-center",
)}
>
{note}
</p>
) : null}
</Container>
</Section>
)
}Props
PricingComparisonProps
Extends SectionBaseProps.
| Prop | Type | Default |
|---|---|---|
plans*Required | ComparisonPlan[] | — |
groups*Required | ComparisonGroup[] | — |
title | string | — |
eyebrow | string | — |
description | string | — |
link | LinkComponent | — |
captionTable caption. Always present; hidden visually by default. | string | — |
showCaption | boolean | false |
note | ReactNode | — |
align | "start" | "center" | "center" |