"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; /** 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 {value}; if (!value) return ( <> {words.no} ); return ( <> {words.yes} ); } /* 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(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(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 (
{eyebrow &&

{eyebrow}

} {title} {subtitle &&

{subtitle}

}
1 ? "" : "invisible"}`}> {rivals.map((i) => ( ))}

{onlyDifferences === null ? "" : words.rowsShown(shown)}

{plans.map((plan, i) => ( ))} {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 ( {plans.map((plan, i) => ( ))} ); })} ))}
{title}
{words.feature} {plan.name} {plan.note && {plan.note}}
{row.label} {row.note && {row.note}}
{plans.map((plan, i) => ( {plan.href && plan.cta && ( {plan.cta} )}
{children && ( {children} )}
); }