Website Sections
ComparisonSection
A real comparison table with one lit column. Rows pour in and ticks draw themselves, a switch folds away the rows that match, and narrow screens pick one rival with chips.
Download
- React
- Tailwind CSS v4
- motion
components/ui/comparison-section.tsx
"use client";
import { useId, useRef, useState, type CSSProperties, type ElementType, type ReactNode } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
export type ComparisonPlan = {
name: string;
/** Small line under the name. */
note?: string;
/** Link for the button at the foot of the column. */
href?: string;
/** Word on that button. */
cta?: string;
};
export type ComparisonRow = {
label: string;
/** Small line under the label. */
note?: string;
/** One value per plan: true, false or a short text. */
values: (boolean | string)[];
};
type ComparisonLabels = {
yes: string;
no: string;
feature: string;
onlyDifferences: string;
compareWith: string;
rowsShown: (count: number) => string;
};
type ComparisonSectionProps = {
plans: ComparisonPlan[];
rows: ComparisonRow[];
title: string;
/** Which plan gets the lit column, counted from 0. */
featured?: number;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Goes under the table: a link or a button. */
children?: ReactNode;
/** Every word in the section. Give only the ones to change. */
labels?: Partial<ComparisonLabels>;
/** Colour of the lit column. */
color?: string;
/** Text colour on that colour. */
ink?: string;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
const EASE = [0.16, 1, 0.3, 1] as const;
const WORDS: ComparisonLabels = {
yes: "Included",
no: "Not included",
feature: "Feature",
onlyDifferences: "Differences only",
compareWith: "Compare with",
rowsShown: (count) => `${count} rows shown`,
};
const FOLD = {
open: { height: "auto", opacity: 1 },
shut: { height: 0, opacity: 0 },
};
type ValueProps = {
value: boolean | string | undefined;
lit: boolean;
words: ComparisonLabels;
color: string;
ink: string;
draw: boolean;
delay: number;
instant: boolean;
};
function Value({ value, lit, words, color, ink, draw, delay, instant }: ValueProps) {
if (typeof value === "string") return <span className={lit ? "font-medium" : "opacity-80"}>{value}</span>;
if (!value)
return (
<>
<span aria-hidden className="inline-block h-px w-3 bg-current align-middle opacity-40" />
<span className="sr-only">{words.no}</span>
</>
);
return (
<>
<span aria-hidden className="inline-grid size-6 place-items-center rounded-full align-middle" style={lit ? { backgroundColor: color, color: ink } : undefined}>
<svg viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<motion.path
d="M3 8.5 6.5 12 13 4.5"
initial={{ pathLength: instant ? 1 : 0 }}
animate={{ pathLength: draw || instant ? 1 : 0 }}
transition={{ duration: instant ? 0 : 0.45, delay: instant ? 0 : delay + 0.15, ease: "easeOut" }}
/>
</svg>
</span>
<span className="sr-only">{words.yes}</span>
</>
);
}
/* ComparisonSection: a real table that compares plans, with one lit column. Rows pour in from the top and ticks draw themselves; a switch folds away the rows that match; on narrow screens chips pick which rival sits beside the lit plan. */
export function ComparisonSection({
plans,
rows,
title,
featured = 0,
eyebrow,
subtitle,
children,
labels,
color = "#a78bfa",
ink = "#0b0a14",
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: ComparisonSectionProps) {
const words = { ...WORDS, ...labels };
const instant = Boolean(useReducedMotion());
const id = useId();
const section = useRef<HTMLElement>(null);
const inView = useInView(section, { once: true, amount: 0.1 });
const rivals = plans.map((_, i) => i).filter((i) => i !== featured);
const [rival, setRival] = useState(rivals[0]);
// null until the switch is first used, so only the first reveal is staggered.
const [onlyDifferences, setOnlyDifferences] = useState<boolean | null>(null);
const matches = (row: ComparisonRow) => row.values.every((value) => value === row.values[0]);
const shown = onlyDifferences ? rows.filter((row) => !matches(row)).length : rows.length;
const column = (i: number) => (i === featured || i === rival ? "" : "hidden @2xl:table-cell");
const edge = `color-mix(in srgb, ${color} 45%, transparent)`;
const lane = (i: number, cap = ""): CSSProperties | undefined =>
i === featured
? { backgroundColor: `color-mix(in srgb, ${color} 12%, transparent)`, boxShadow: `inset 1px 0 ${edge}, inset -1px 0 ${edge}${cap}` }
: undefined;
const rise = (delay: number) => ({
initial: instant ? { opacity: 0 } : { opacity: 0, y: 20 },
animate: inView ? { opacity: 1, y: 0 } : undefined,
transition: { duration: instant ? 0.3 : 0.7, delay: instant ? 0 : delay, ease: EASE },
});
const fade = (delay: number) => ({
initial: { opacity: 0 },
animate: { opacity: inView ? 1 : 0 },
transition: { duration: instant ? 0.3 : 0.5, delay: instant ? 0 : delay },
});
return (
<section ref={section} aria-labelledby={id} className={`@container ${className}`}>
<div className="mx-auto max-w-5xl">
<motion.div {...rise(0)} className="max-w-2xl">
{eyebrow && <p className="mb-3 text-xs font-medium tracking-[0.2em] uppercase opacity-70 @2xl:text-sm">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.6rem,7cqw,2.75rem)] leading-[1.08] font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-sm text-pretty opacity-70 @2xl:text-lg">{subtitle}</p>}
</motion.div>
<motion.div {...rise(0.1)} className="mt-6 flex flex-wrap items-center justify-between gap-3 @2xl:mt-10">
<div role="group" aria-label={words.compareWith} className={`flex flex-wrap gap-2 @2xl:invisible ${rivals.length > 1 ? "" : "invisible"}`}>
{rivals.map((i) => (
<button
key={plans[i].name}
type="button"
aria-pressed={i === rival}
onClick={() => setRival(i)}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current ${i === rival ? "border-current" : "border-current/20 opacity-70"}`}
>
{plans[i].name}
</button>
))}
</div>
<button
type="button"
role="switch"
aria-checked={Boolean(onlyDifferences)}
onClick={() => setOnlyDifferences(!onlyDifferences)}
className="inline-flex items-center gap-2 rounded-full text-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
>
<span aria-hidden className="relative h-5 w-9 rounded-full border border-current/25 transition-colors" style={onlyDifferences ? { backgroundColor: color, borderColor: color } : undefined}>
<motion.span
className="absolute top-0.5 left-0.5 size-3.5 rounded-full"
style={{ backgroundColor: onlyDifferences ? ink : "currentColor" }}
animate={{ x: onlyDifferences ? 16 : 0 }}
transition={instant ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 32 }}
/>
</span>
{words.onlyDifferences}
</button>
</motion.div>
<p role="status" className="sr-only">
{onlyDifferences === null ? "" : words.rowsShown(shown)}
</p>
<table className="mt-4 w-full table-fixed border-separate border-spacing-0 text-sm">
<caption className="sr-only">{title}</caption>
<thead>
<motion.tr {...fade(0.15)}>
<th scope="col" className="w-[38%] pr-3 pb-4 text-left align-bottom text-xs font-medium opacity-60 @2xl:w-auto">
{words.feature}
</th>
{plans.map((plan, i) => (
<th key={plan.name} scope="col" className={`rounded-t-2xl px-2 py-4 text-center align-bottom @2xl:px-3 ${column(i)}`} style={lane(i, `, inset 0 1px ${edge}`)}>
<span className="block text-sm font-semibold @2xl:text-base">{plan.name}</span>
{plan.note && <span className="mt-0.5 block text-xs font-normal opacity-60">{plan.note}</span>}
</th>
))}
</motion.tr>
</thead>
<tbody>
{rows.map((row, r) => {
const hide = Boolean(onlyDifferences) && matches(row);
const delay = 0.25 + r * 0.06;
const rule = hide ? "" : "border-t border-current/10";
const cell = {
variants: FOLD,
initial: false,
animate: hide ? "shut" : "open",
transition: { duration: instant ? 0 : 0.35, ease: EASE },
className: "overflow-hidden",
} as const;
return (
<motion.tr key={row.label} aria-hidden={hide || undefined} {...fade(onlyDifferences === null ? delay : 0)}>
<th scope="row" className={`p-0 text-left font-normal ${rule}`}>
<motion.div {...cell}>
<div className="py-3 pr-3">
<span className="block">{row.label}</span>
{row.note && <span className="mt-0.5 block text-xs opacity-60">{row.note}</span>}
</div>
</motion.div>
</th>
{plans.map((plan, i) => (
<td key={plan.name} className={`p-0 text-center ${rule} ${column(i)}`} style={lane(i)}>
<motion.div {...cell}>
<div className="px-2 py-3 @2xl:px-3">
<Value value={row.values[i]} lit={i === featured} words={words} color={color} ink={ink} draw={inView} delay={delay} instant={instant} />
</div>
</motion.div>
</td>
))}
</motion.tr>
);
})}
</tbody>
<tfoot>
<motion.tr {...fade(0.3 + rows.length * 0.06)}>
<td className="border-t border-current/10" />
{plans.map((plan, i) => (
<td key={plan.name} className={`h-4 rounded-b-2xl border-t border-current/10 px-2 text-center @2xl:px-3 ${plan.href && plan.cta ? "py-3" : ""} ${column(i)}`} style={lane(i, `, inset 0 -1px ${edge}`)}>
{plan.href && plan.cta && (
<a
href={plan.href}
className={`inline-block rounded-lg px-3 py-1.5 text-xs font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current @2xl:text-sm ${i === featured ? "" : "border border-current/20"}`}
style={i === featured ? { backgroundColor: color, color: ink } : undefined}
>
{plan.cta}
</a>
)}
</td>
))}
</motion.tr>
</tfoot>
</table>
{children && (
<motion.div {...rise(0.4 + rows.length * 0.06)} className="mt-8 flex flex-wrap gap-3">
{children}
</motion.div>
)}
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/comparison-section.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/comparison-section-demo.tsx
import { ComparisonSection } from "@/components/ui/comparison-section"; const PLANS = [ { name: "Northwind", note: "$29 a month", href: "#start", cta: "Start free" }, { name: "Spreadsheets", note: "Free", href: "#sheets", cta: "Keep them" }, { name: "Agency", note: "$900 a month", href: "#agency", cta: "Get a quote" }, ]; const ROWS = [ { label: "Export to CSV", values: [true, true, true] }, { label: "Set up in a day", note: "No training needed", values: [true, false, false] }, { label: "Live dashboards", values: [true, false, true] }, { label: "Monthly report", values: [true, true, true] }, { label: "Team seats", values: ["Unlimited", "1", "3"] }, { label: "Support", values: ["Same day", false, "Weekly call"] }, ]; export default function ComparisonSectionExample() { return ( <div className="h-full overflow-y-auto"> <ComparisonSection headingAs="h3" eyebrow="Sample plans" title="How we stack up" subtitle="Turn on Differences only to fold the rows that match." plans={PLANS} rows={ROWS} className="min-h-full bg-[#0b0a14] px-5 py-10 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| plans | ComparisonPlan[] | โ | Each has name, plus optional note, href and cta. href and cta together make the button at the foot of the column. |
| rows | ComparisonRow[] | โ | Each has label and values, plus optional note. One value per plan: true, false or a short text. |
| title | string | โ | Heading above the table. |
| featured | number | 0 | Which plan gets the lit column, counted from 0. |
| eyebrow | string | โ | Small line above the title. |
| subtitle | string | โ | Line under the title. |
| children | ReactNode | โ | Goes under the table: a link or a button. |
| labels | object | English | Every word in the section. Give only the ones to change. |
| color | string | "#a78bfa" | Colour of the lit column. |
| ink | string | "#0b0a14" | Text colour on that colour. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |