Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, CircleHelp, Contrast, ExternalLink, MoveHorizontal, Rows3, TriangleAlert, X } from "lucide-react"
import { cn } from "@/lib/utils"
import type {
  FeatureComparisonCell,
  FeatureComparisonProduct,
  FeatureComparisonRow,
  FeatureComparisonStatus,
} from "./feature-comparison.contract"

const DAY_MS = 86_400_000

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/feature-comparison.json

Prompt

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 partial branch 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 ≠ absentunknown is 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 asOf is required and the age is computed from an injected now (never Date.now() during render, which would desync SSR from hydration). Past staleAfterDays the 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-nums column.
  • Frozen column, measured overflow — the capability column is sticky left-0 with an opaque background over border-separate (a translucent sticky cell shows the scrolling columns through it, and border-collapse erases sticky borders). The scroll hint and the tabIndex=0 scroll region appear only when a ResizeObserver says the table really is wider than its viewport.

On This Page