Feature Comparison
A dated us-vs-competitors capability table — yes / partial / no / not-disclosed told apart by shape and word, numeric rows with a per-row unit, a frozen capability column, and a verified-on date measured against an injected now.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/feature-comparison.jsonPrompt
Build a React + TypeScript + Tailwind "FeatureComparison" block (lucide-react
Check / Contrast / X / CircleHelp / MoveHorizontal / Rows3 / TriangleAlert /
ExternalLink) with zod. It compares YOUR product against COMPETITORS on a
marketing page, so its job is to be defensible, not flattering.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
products: { id, name, note?, source?: { label, href } }[];
rows: { id, feature, hint?, unit?, fractionDigits?, cells: Cell[] }[];
ownProductId?: string | null;
asOf: string /* YYYY-MM-DD, refined */ }
- Cell is a discriminated union on `kind`:
{ kind: "yes", note? }
{ kind: "partial", note: string /* REQUIRED, min(1) */ }
{ kind: "no", note? }
{ kind: "unknown", note? }
{ kind: "amount", value: number | null /* null = unlimited */, note? }
"partial" requiring its qualifier is structural, not a lint: a partial with no
reason is just a quieter "no", and the reader cannot act on it.
- Every product uses the same shape — there is no "ours" variant with extra
fields, which is what makes it impossible to render your column differently.
- Component props = the schema fields plus: now (string | number | Date,
REQUIRED), locale = "en-US", staleAfterDays = 180, title, description,
ownBadgeLabel = "Us", skeletonRows = 5, onRetry?, emptyState?, className, and
the rest of the native HTMLAttributes spread on the root <section>.
- superRefine, with each refinement guarding its own inputs (zod runs them all
even after one fails, so an unguarded data.rows[0].cells[2].kind would throw a
TypeError out of safeParse instead of returning { success: false }):
every row's cells.length === products.length; ownProductId must name an
existing product; product ids unique. href is refined to reject "", "#" and
javascript: URLs.
Behavior
- Four first-class branches: loading (skeleton table mirroring the real
anatomy, aria-hidden, with an sr-only role=status), empty, error (message +
"Try again" only when onRetry exists), ready. A "ready" payload with zero
products or zero rows renders the empty branch — a bare header row with
nothing under it is an empty state, not a table.
- Provenance is the point of this block. Render "Capabilities verified <date>"
from asOf, formatted with timeZone:"UTC" (asOf is a plain calendar date, so
formatting it in the reader's zone shows the previous day west of Greenwich),
plus a relative age computed as floor((now - asOf) / 86400000) through
Intl.RelativeTimeFormat. Older than staleAfterDays → a "re-verify" flag. A
negative age (asOf in the future) is a data bug, so it renders no age and no
flag rather than "in 3 days". The provenance line appears ONLY in the ready
branch: dating a skeleton dates rows that do not exist yet.
- Cell states are told apart by SHAPE AND WORD, never by colour: Check + "Yes",
a half-filled Contrast disc + "Partial", X + "No", CircleHelp + "Not
disclosed". Greyscale printing and colour-blind readers lose nothing. lucide
ships icons stroked, so the Contrast disc needs [&>path]:fill-current on that
one glyph to read as half.
- "unknown" never collapses into "no": "we could not verify it" and "they do not
have it" are different claims about someone else's product. A missing cell
(ragged payload that skipped parse) renders "Not disclosed" for the same
reason, and never throws.
- Numeric rows live in the same table as boolean rows. The unit and the decimals
are declared ONCE on the row, not per cell, so two columns cannot disagree
about them; the unit prints in the row heading ("Audit log retention (days)")
and the cells stay a clean tabular-nums column. amount.value === null renders
"Unlimited".
- Own-column restraint — the rule this component exists to enforce. The own
column gets exactly two things: a bg-primary/5 tint and an "Us" pill. Glyphs,
labels, font weight, size and tone are identical in every column, so a
competitor's "Yes" is drawn with the same check as yours. Do NOT dim, grey
out, shrink, or lower the opacity of competitor cells; do NOT sort rows so the
ones you win float up; do NOT render a score, a win count or a "winner" badge.
ownProductId: null renders a completely unattributed table.
- Narrow screens: the table is horizontally scrollable with the capability
column frozen (sticky left-0, opaque bg-card — a translucent sticky cell lets
the scrolling columns show through it). border-separate + border-spacing-0 is
mandatory: border-collapse drops the borders of sticky cells. Overflow is
MEASURED with a ResizeObserver on the viewport AND on the table (the viewport
keeps its width while the content grows), not guessed from a breakpoint; only
when it really overflows does the scroll hint render and the viewport become
role=region + aria-label + tabIndex=0. A tab stop on a table that fits is
noise, and a landmark per comparison pollutes screen-reader navigation.
- Qualifiers must never be clipped: no fixed cell height, no line-clamp, no
overflow:hidden on cells. Long notes wrap (wrap-anywhere + min-w-0) and the
row grows.
Rendering & styling
- Real semantics: <table> with an sr-only <caption>, <th scope="col"> per
product, <th scope="row"> per capability. sr-only on a <caption> is safe (the
absolute positioning blockifies the table-caption box); sr-only on <table>
itself is NOT — width:1px is only a lower bound there and the table stays full
width, pushing the page into horizontal scroll.
- Every <td> carries aria-label="<product> — <feature>: <state>[. <note>]", with
the unit spoken for amounts. A screen-reader user who lands on a cell must not
hear "check" with no idea which product or which capability it belongs to.
- Semantic tokens only: bg-card shell and frozen column, border/border-t/
border-r rules, text-muted-foreground for supporting copy and for the two
"absent-ish" states, text-foreground for yes/partial/amounts, primary reserved
for the own column's tint and pill, destructive for the staleness flag.
- Source links are real external <a href target="_blank" rel="noreferrer"> with
an sr-only "(opens in a new tab)"; the contract rejects "" and "#" so the
footer can never be decorative.
- prefers-reduced-motion: the only animation is the loading pulse, which carries
motion-reduce:animate-none.
Customization levers
- Column count: min-w-32 / @md:min-w-40 per product column and w-40 /
@md:w-56 for the frozen column set where scrolling starts; widen the frozen
column for long capability names, narrow the product columns to fit more
before scrolling.
- State vocabulary: SUPPORT_META is one table mapping kind → { Icon, label,
tone, iconClass }. Rename "Not disclosed" to "Unverified", swap Contrast for a
half-star, or add a fifth kind (e.g. "beta") by extending the zod union and
adding one row to that map.
- Emphasis: drop the tint entirely (ownProductId={null}) for a neutral
analyst-style table, or move the "Us" pill above the name. Keep the glyphs
untouched — that is the invariant, not a style choice.
- Provenance strictness: staleAfterDays tunes the re-verify threshold; make the
flag block publication by rendering the error branch when the data is stale.
- Density: px-4/py-3 on cells and text-sm on the table are the only size knobs;
a print stylesheet usually wants py-2 plus the scroll wrapper unset.
- Row grouping: to section the matrix ("Security", "Limits"), render a full-width
<th colSpan> row between groups — cells align by index either way.Concepts
- Partial is a third state, not a soft no — the
partialbranch of the union requires its qualifier (note: z.string().min(1)), so "supported with a limit" always arrives with the limit attached. The cell has no fixed height and no clamp: a qualifier that gets cut off has turned back into an unexplained mark. - Not disclosed ≠ absent —
unknownis its own state because a comparison is a public claim about somebody else's product. Rendering "we could not verify this" as an X is the cheapest way to end up correcting the table in public; a ragged cell that never arrived falls into the same state instead of throwing. - Shape and word, never colour — a check, a half-filled disc, a cross and a question mark, each with its own label. The table survives greyscale printing, colour blindness and a stylesheet that fails to load, which is also why the own column can afford to be the only coloured thing on screen.
- Restrained emphasis — the own column buys a tint and a pill and nothing else. Every glyph, label and weight is identical across columns by construction, so the classic dishonest move — drawing a competitor's "Yes" faintly enough to read as a "No" — has nowhere to live. No score, no win count, no reordering by who wins.
- Provenance is data, not a footnote — competitor capabilities change weekly, so
asOfis required and the age is computed from an injectednow(neverDate.now()during render, which would desync SSR from hydration). PaststaleAfterDaysthe table flags itself, and every column can carry the vendor page the claim was read from. - Unit on the row — declaring the unit and the decimals once per row is what lets numeric and boolean rows share one table without a cell ever showing "500 GB" next to "0.5 TB"; the unit prints in the row heading and the cells stay a clean
tabular-numscolumn. - Frozen column, measured overflow — the capability column is
sticky left-0with an opaque background overborder-separate(a translucent sticky cell shows the scrolling columns through it, andborder-collapseerases sticky borders). The scroll hint and thetabIndex=0scroll region appear only when aResizeObserversays the table really is wider than its viewport.
Order Confirmation
The page you land on once the money has moved — a dictatable, one-click-copyable order number, an itemised table whose money is refined to add up, a delivery window rather than a promised day, ship / pickup / digital fulfillment, and print styles that survive paper.
Case Study Grid
A four-state customer-story grid where the quantified result is the first-class field: every metric carries value, unit, direction and a required baseline (or says it has none), industry and company-size facets really filter with truthful counts, and logos of any aspect ratio are optically area-matched.