Skip to main content

ns-ui

Compare Table Reach Rule

A tier comparison matrix where every feature row draws a real box-drawing rule that runs left-to-right and terminates with ┤ at the last tier that actually includes it, so the table's staircase of rule lengths is the comparison itself rather than a field of check glyphs.

Usage

registry/core/compare-table-reach-rule/demo.tsx
"use client";

import {
  ReachRuleTable,
  type CompareFeatureRow,
  type CompareTier,
} from "./component";

const TIERS: CompareTier[] = [
  { id: "hobby", name: "Hobby", price: "$0" },
  { id: "pro", name: "Pro", price: "$20/mo" },
  { id: "team", name: "Team", price: "$60/mo" },
  { id: "enterprise", name: "Enterprise", price: "Custom" },
];

const ROWS: CompareFeatureRow[] = [
  { id: "deploys", label: "Unlimited deployments", support: [true, true, true, true] },
  {
    id: "regions",
    label: "Edge regions",
    note: "Where your builds are served from",
    support: ["3", "18", "34", "34 + private"],
  },
  {
    id: "analytics",
    label: "Traffic analytics",
    note: "Retention window",
    support: [false, "30 days", "1 year", "3 years"],
  },
  { id: "seats", label: "Collaborator seats", support: ["1", "5", "25", "Unlimited"] },
  {
    id: "preview",
    label: "Password-protected previews",
    support: [false, true, true, true],
  },
  { id: "sso", label: "SAML single sign-on", support: [false, false, true, true] },
  {
    id: "audit",
    label: "Audit log export",
    note: "Streamed to a bucket you own",
    support: [false, false, false, true],
  },
  {
    id: "sla",
    label: "99.99% uptime SLA",
    support: [false, false, false, true],
  },
  {
    id: "checkout",
    label: "Self-serve checkout",
    note: "Enterprise is invoiced instead",
    support: [true, true, true, false],
  },
  {
    id: "forum",
    label: "Community forum support",
    note: "Replaced by a named contact on Enterprise",
    support: [true, true, true, false],
  },
  {
    id: "trial",
    label: "14-day trial, no card",
    support: [true, true, false, false],
  },
  {
    id: "badge",
    label: "Public project badge",
    note: "Free-tier attribution",
    support: [true, false, false, false],
  },
];

export default function CompareTableReachRuleDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background px-6 py-16">
      <div className="w-full max-w-4xl">
        <p className="mb-10 text-center font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / compare-table-reach-rule
        </p>
        <div className="mx-auto mb-10 max-w-xl text-center">
          <h1 className="text-4xl font-semibold tracking-tight text-foreground">
            Where each feature stops
          </h1>
          <p className="mt-3 text-sm leading-relaxed text-ns-muted">
            Every row draws a rule that ends at the last plan that includes it,
            so the staircase is the comparison.
          </p>
        </div>
        <ReachRuleTable tiers={TIERS} rows={ROWS} title="Plan comparison" />
        <p className="mt-10 text-center font-mono text-[11px] text-ns-muted">
          Hover or focus a row to redraw its rule. Click the label to pin it.
        </p>
      </div>
    </main>
  );
}

Component

registry/core/compare-table-reach-rule/component.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// ReachRuleTable — a tier comparison matrix where each feature row draws a real
// box-drawing rule that starts at the x-centre of the first tier column and
// terminates with ┤ on the x-centre of the LAST tier that includes it. Columns
// the feature skips are drawn ┄ rather than ─, so a non-monotone matrix is
// shown honestly instead of smoothed. The staircase of rule lengths is the
// comparison — there is no field of check glyphs to scan.
// ---------------------------------------------------------------------------

export type CompareTier = {
  id: string;
  /** tier name, e.g. "Pro" */
  name: string;
  /** price string, rendered verbatim — e.g. "$20/mo" or "Custom" */
  price: string;
};

export type CompareFeatureRow = {
  id: string;
  /** feature name — becomes the row's pin button label */
  label: string;
  /** optional second line under the label */
  note?: string;
  /** one entry per tier, in tier order. `false` = not included, a string = the
   *  tier-specific value ("14 days"), `true` = plainly included */
  support: (boolean | string)[];
};

const STEP_IN_MS = 14; // per glyph cell, drawing
const STEP_OUT_MS = 9; // per glyph cell, retracting
const MAX_IN_MS = 320; // total draw budget — long rules step faster, never slower
const REST_OPACITY = 0.42; // resting rule ink — the idle staircase must read
const DIM_OPACITY = 0.35; // tiers excluded by the active row
const TRACK_CELLS = 12; // completeness readout width
const STACK_BREAKPOINT = 720; // px — below this the matrix becomes stacked cards
const WIDTH_EPSILON = 2; // px — remeasure only past this much layout change

const GLYPH_RUN = "─";
const GLYPH_GAP = "┄";
const GLYPH_END = "┤";

const DEFAULT_TIERS: CompareTier[] = [
  { id: "hobby", name: "Hobby", price: "$0" },
  { id: "pro", name: "Pro", price: "$20/mo" },
  { id: "team", name: "Team", price: "$60/mo" },
  { id: "enterprise", name: "Enterprise", price: "Custom" },
];

// Ordered by reach, so the rules descend as a staircase at rest.
const DEFAULT_ROWS: CompareFeatureRow[] = [
  {
    id: "deployments",
    label: "Unlimited deployments",
    support: [true, true, true, true],
  },
  {
    id: "regions",
    label: "Edge regions",
    note: "Where builds are served from",
    support: ["3", "18", "34", "34 + private"],
  },
  {
    id: "analytics",
    label: "Traffic analytics",
    note: "Retention window",
    support: [false, "30 days", "1 year", "3 years"],
  },
  {
    id: "seats",
    label: "Collaborator seats",
    support: ["1", "5", "25", "Unlimited"],
  },
  {
    id: "sso",
    label: "SAML single sign-on",
    support: [false, false, true, true],
  },
  {
    id: "audit",
    label: "Audit log export",
    note: "Streamed to your own bucket",
    support: [false, false, false, true],
  },
  {
    id: "checkout",
    label: "Self-serve checkout",
    note: "Enterprise is invoiced instead",
    support: [true, true, true, false],
  },
  {
    id: "overage",
    label: "Usage overage on card",
    support: [true, true, true, false],
  },
  {
    id: "community",
    label: "Community forum support",
    note: "Superseded by a named contact on Enterprise",
    support: [true, true, true, false],
  },
  {
    id: "trial",
    label: "14-day trial without a card",
    support: [true, true, false, false],
  },
  {
    id: "sandbox",
    label: "Shared sandbox runners",
    note: "Dedicated runners replace these higher up",
    support: [true, true, false, false],
  },
  {
    id: "badge",
    label: "Public project badge",
    note: "Free-tier attribution",
    support: [true, false, false, false],
  },
];

const has = (v: boolean | string | undefined): boolean =>
  v === true || (typeof v === "string" && v.trim().length > 0);

/** highest tier index this feature still appears in, −1 if none */
function reachOf(row: CompareFeatureRow, n: number): number {
  for (let i = n - 1; i >= 0; i--) if (has(row.support[i])) return i;
  return -1;
}

type Geom = { text: string; left: number; width: number; len: number };
type Anim = { dir: 1 | -1; k: number };

export function ReachRuleTable({
  tiers = DEFAULT_TIERS,
  rows = DEFAULT_ROWS,
  title = "Plan comparison",
  className = "",
}: {
  /** tier columns, cheapest first */
  tiers?: CompareTier[];
  /** feature rows, one `support` entry per tier */
  rows?: CompareFeatureRow[];
  /** accessible name for the matrix */
  title?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}) {
  const n = tiers.length;

  const [activeId, setActiveId] = useState<string | null>(null);
  const [pinnedId, setPinnedId] = useState<string | null>(null);
  const [stacked, setStacked] = useState(false);
  const [reduced, setReduced] = useState(false);

  const rootRef = useRef<HTMLDivElement | null>(null);
  const headRowRef = useRef<HTMLDivElement | null>(null);
  const headCellRefs = useRef<(HTMLDivElement | null)[]>([]);
  const probeRef = useRef<HTMLSpanElement | null>(null);
  const ruleRefs = useRef<Map<string, HTMLSpanElement>>(new Map());

  const geomRef = useRef<Map<string, Geom>>(new Map());
  const animRef = useRef<Map<string, Anim>>(new Map());
  const rafRef = useRef(0);
  const lastWidthRef = useRef(-1);
  const pinnedRef = useRef<string | null>(null);
  pinnedRef.current = pinnedId;
  const activeRef = useRef<string | null>(null);
  activeRef.current = activeId;

  const reaches = useMemo(
    () => rows.map((r) => reachOf(r, n)),
    [rows, n]
  );

  // completeness per tier: how many features that tier actually includes
  const included = useMemo(
    () => tiers.map((_, i) => rows.reduce((a, r) => a + (has(r.support[i]) ? 1 : 0), 0)),
    [tiers, rows]
  );

  const gridStyle = useMemo(
    () => ({
      gridTemplateColumns: `minmax(12rem,1.4fr) repeat(${n}, minmax(6rem,1fr))`,
    }),
    [n]
  );

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const sync = () => setReduced(mq.matches);
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);

  /** body glyphs + the ┤ cap live in two child spans, so the terminator can
   *  take accent ink on a pinned row while the run stays --foreground */
  const parts = (id: string) => {
    const el = ruleRefs.current.get(id);
    if (!el) return null;
    const body = el.firstElementChild as HTMLElement | null;
    const cap = el.lastElementChild as HTMLElement | null;
    if (!body || !cap || body === cap) return null;
    return { el, body, cap };
  };

  /** paint one rule at a given glyph count */
  const paint = useCallback((id: string, k: number) => {
    const p = parts(id);
    const g = geomRef.current.get(id);
    if (!p || !g) return;
    const shown = Math.max(0, Math.min(g.len, Math.round(k)));
    p.body.textContent = shown > 0 ? g.text.slice(0, shown - 1) : "";
    p.cap.textContent = shown > 0 ? GLYPH_END : "";
    p.cap.style.color =
      pinnedRef.current === id ? "var(--ns-accent)" : "currentColor";
    const t = g.len > 0 ? shown / g.len : 1;
    p.el.style.opacity = String(REST_OPACITY + (1 - REST_OPACITY) * t);
  }, []);

  const settle = useCallback((id: string, lit: boolean) => {
    const p = parts(id);
    const g = geomRef.current.get(id);
    if (!p || !g) return;
    p.body.textContent = g.text.slice(0, g.len - 1);
    p.cap.textContent = GLYPH_END;
    p.cap.style.color =
      pinnedRef.current === id ? "var(--ns-accent)" : "currentColor";
    p.el.style.opacity = String(lit ? 1 : REST_OPACITY);
  }, []);

  /** single shared rAF stepping every in-flight rule */
  const tick = useCallback(
    (now: number, prev: number) => {
      const dt = Math.min(64, now - prev);
      const anims = animRef.current;
      for (const [id, a] of anims) {
        const g = geomRef.current.get(id);
        if (!g) {
          anims.delete(id);
          continue;
        }
        const len = Math.max(1, g.len);
        const stepMs =
          a.dir === 1 ? Math.min(STEP_IN_MS, MAX_IN_MS / len) : STEP_OUT_MS;
        a.k += (dt / stepMs) * a.dir;
        if (a.dir === 1 && a.k >= g.len) {
          settle(id, true);
          anims.delete(id);
        } else if (a.dir === -1 && a.k <= 0) {
          settle(id, pinnedRef.current === id);
          anims.delete(id);
        } else {
          paint(id, a.k);
        }
      }
    },
    [paint, settle]
  );

  const pump = useCallback(() => {
    if (rafRef.current) return;
    let prev = performance.now();
    const loop = (now: number) => {
      tick(now, prev);
      prev = now;
      if (animRef.current.size > 0) {
        rafRef.current = requestAnimationFrame(loop);
      } else {
        rafRef.current = 0;
      }
    };
    rafRef.current = requestAnimationFrame(loop);
  }, [tick]);

  const drive = useCallback(
    (id: string, dir: 1 | -1) => {
      const g = geomRef.current.get(id);
      if (!g) return;
      if (reduced) {
        animRef.current.delete(id);
        settle(id, dir === 1 || pinnedRef.current === id);
        return;
      }
      const cur = animRef.current.get(id);
      const k = dir === 1 ? 0 : cur ? cur.k : g.len;
      animRef.current.set(id, { dir, k });
      if (dir === 1) paint(id, 0);
      pump();
    },
    [paint, pump, reduced, settle]
  );

  /** one measurement pass: header centres → per-row rule text + box */
  const layout = useCallback(
    (force: boolean) => {
      const root = rootRef.current;
      const headRow = headRowRef.current;
      if (!root) return;

      const w = root.clientWidth;
      if (w < 2) return;
      if (!force && Math.abs(w - lastWidthRef.current) <= WIDTH_EPSILON) return;
      lastWidthRef.current = w;

      const nextStacked = w < STACK_BREAKPOINT;
      setStacked(nextStacked);
      if (nextStacked || !headRow) return;

      const probe = probeRef.current;
      const chW = probe ? probe.getBoundingClientRect().width / 40 : 8;
      if (!(chW > 0.5)) return;

      const rowLeft = headRow.getBoundingClientRect().left;
      const centres: number[] = [];
      for (let i = 0; i < n; i++) {
        const cell = headCellRefs.current[i];
        if (!cell) return;
        const r = cell.getBoundingClientRect();
        centres.push(r.left + r.width / 2 - rowLeft);
      }

      rows.forEach((row, ri) => {
        const reach = reaches[ri];
        const el = ruleRefs.current.get(row.id);
        if (reach < 0) {
          geomRef.current.delete(row.id);
          const p = el ? parts(row.id) : null;
          if (p) {
            p.body.textContent = "";
            p.cap.textContent = "";
          }
          return;
        }
        const left = centres[0];
        const width = Math.max(0, centres[reach] - left);
        const cells = Math.max(0, Math.round(width / chW));
        let text = "";
        for (let j = 0; j < cells; j++) {
          const x = left + j * chW;
          // nearest tier column owns this glyph
          let col = 0;
          let best = Infinity;
          for (let i = 0; i <= reach; i++) {
            const d = Math.abs(centres[i] - x);
            if (d < best) {
              best = d;
              col = i;
            }
          }
          text += has(row.support[col]) ? GLYPH_RUN : GLYPH_GAP;
        }
        text += GLYPH_END;
        const g: Geom = {
          text,
          left: left - chW / 2,
          width: width + chW,
          len: text.length,
        };
        geomRef.current.set(row.id, g);
        if (el) {
          el.style.left = `${g.left}px`;
          el.style.width = `${g.width}px`;
          if (!animRef.current.has(row.id)) {
            settle(row.id, pinnedRef.current === row.id);
          }
        }
      });
    },
    [n, reaches, rows, settle]
  );

  useEffect(() => {
    layout(true);
    let alive = true;
    // the 1ch probe is measured in whatever font is resolved at mount; when the
    // mono face swaps in the pitch changes, so re-measure once fonts settle or
    // every ┤ lands off its column centre
    if (typeof document !== "undefined" && document.fonts) {
      document.fonts.ready.then(() => {
        if (alive) layout(true);
      });
    }
    const root = rootRef.current;
    if (!root) {
      return () => {
        alive = false;
      };
    }
    const ro = new ResizeObserver(() => layout(false));
    ro.observe(root);
    return () => {
      alive = false;
      ro.disconnect();
    };
  }, [layout]);

  // crossing back above the breakpoint remounts the matrix — the header cells
  // only exist after that paint, so measure once more on the flip
  useEffect(() => {
    if (stacked) return;
    const id = requestAnimationFrame(() => layout(true));
    return () => cancelAnimationFrame(id);
  }, [stacked, layout]);

  useEffect(
    () => () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      rafRef.current = 0;
      animRef.current.clear();
    },
    []
  );

  const enter = (id: string) => {
    activeRef.current = id;
    setActiveId(id);
    drive(id, 1);
  };
  const leave = (id: string) => {
    if (activeRef.current === id) activeRef.current = null;
    setActiveId((cur) => (cur === id ? null : cur));
    if (pinnedRef.current === id) {
      animRef.current.delete(id);
      settle(id, true);
      return;
    }
    drive(id, -1);
  };
  // side effects live in the handler, never inside the state updater — a
  // StrictMode double-invoke would otherwise paint twice
  const togglePin = (id: string) => {
    const prev = pinnedRef.current;
    const next = prev === id ? null : id;
    pinnedRef.current = next;
    setPinnedId(next);
    if (prev && prev !== next) settle(prev, activeRef.current === prev);
    if (next) settle(next, true);
  };

  const activeRow = activeId ? rows.find((r) => r.id === activeId) : undefined;
  const shownRow = activeRow ?? (pinnedId ? rows.find((r) => r.id === pinnedId) : undefined);

  const track = (i: number) => {
    const filled = Math.round((TRACK_CELLS * included[i]) / Math.max(1, rows.length));
    return "▪".repeat(filled) + "·".repeat(Math.max(0, TRACK_CELLS - filled));
  };

  return (
    <div
      ref={rootRef}
      className={`w-full text-foreground ${className}`.trim()}
    >
      {/* mono advance probe — measured, never seen */}
      <span
        ref={probeRef}
        aria-hidden
        className="pointer-events-none absolute -left-[9999px] top-0 font-mono text-[13px] leading-none"
        style={{ whiteSpace: "pre", letterSpacing: 0 }}
      >
        {GLYPH_RUN.repeat(40)}
      </span>

      {stacked ? (
        <div className="flex flex-col gap-3">
          {tiers.map((tier, i) => (
            <section
              key={tier.id}
              aria-label={`${tier.name} — ${tier.price}`}
              className="rounded-md border border-border bg-surface p-4"
            >
              <div className="flex items-baseline justify-between">
                <h3 className="text-sm font-medium">{tier.name}</h3>
                <span className="font-mono text-xs text-ns-muted">{tier.price}</span>
              </div>
              <p className="mt-2 font-mono text-[11px] text-ns-muted">
                <span aria-hidden style={{ letterSpacing: "0.12em" }}>
                  {track(i)}
                </span>
                <span className="ml-2">
                  {included[i]}/{rows.length}
                </span>
              </p>
              <ul className="mt-3 flex flex-col gap-1.5">
                {rows.map((row) => {
                  const on = has(row.support[i]);
                  const val = typeof row.support[i] === "string" ? (row.support[i] as string) : null;
                  return (
                    <li
                      key={row.id}
                      className={`flex items-baseline justify-between gap-3 text-[13px] ${
                        on ? "text-foreground" : "text-ns-muted"
                      }`}
                    >
                      <span className="flex items-baseline gap-2">
                        <span aria-hidden className="font-mono text-[11px]">
                          {on ? "●" : "○"}
                        </span>
                        <span>{row.label}</span>
                        <span className="sr-only">
                          {on ? "included" : "not included"}
                        </span>
                      </span>
                      {val ? (
                        <span className="font-mono text-[11px] text-ns-muted">{val}</span>
                      ) : null}
                    </li>
                  );
                })}
              </ul>
            </section>
          ))}
        </div>
      ) : (
        <div
          role="table"
          aria-label={title}
          className="overflow-hidden rounded-md border border-border bg-surface"
        >
          {/* header */}
          <div
            ref={headRowRef}
            role="row"
            className="grid items-end gap-x-2 border-b border-border px-4 pb-3 pt-4"
            style={gridStyle}
          >
            <div role="columnheader" className="font-mono text-[11px] uppercase tracking-[0.18em] text-ns-muted">
              Feature
            </div>
            {tiers.map((tier, i) => {
              const dim = shownRow ? !has(shownRow.support[i]) : false;
              return (
                <div
                  key={tier.id}
                  role="columnheader"
                  ref={(el) => {
                    headCellRefs.current[i] = el;
                  }}
                  className="text-center transition-opacity duration-200"
                  style={{ opacity: dim ? DIM_OPACITY : 1 }}
                >
                  <div className="text-sm font-medium">{tier.name}</div>
                  <div className="font-mono text-[11px] text-ns-muted">{tier.price}</div>
                  <div className="mt-1.5 font-mono text-[10px] text-ns-muted">
                    <span aria-hidden style={{ letterSpacing: "0.1em" }}>
                      {track(i)}
                    </span>
                    <span className="ml-1.5">
                      {included[i]}/{rows.length}
                    </span>
                  </div>
                </div>
              );
            })}
          </div>

          {/* feature rows */}
          {rows.map((row, ri) => {
            const isActive = activeId === row.id;
            const isPinned = pinnedId === row.id;
            const reach = reaches[ri];
            return (
              <div
                key={row.id}
                role="row"
                className="relative grid items-start gap-x-2 border-b border-border/60 px-4 py-2.5 last:border-b-0"
                style={gridStyle}
              >
                <div role="rowheader" className="min-w-0">
                  <button
                    type="button"
                    data-compare-row={row.id}
                    aria-pressed={isPinned}
                    onPointerEnter={() => enter(row.id)}
                    onPointerLeave={() => leave(row.id)}
                    onFocus={() => enter(row.id)}
                    onBlur={() => leave(row.id)}
                    onClick={() => togglePin(row.id)}
                    className={`-mx-1 block w-full rounded-sm px-1 text-left text-[13px] leading-5 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent ${
                      isActive || isPinned ? "text-foreground" : "text-foreground/80"
                    }`}
                  >
                    {row.label}
                    {isPinned ? (
                      <span className="sr-only"> (pinned)</span>
                    ) : null}
                  </button>
                  {row.note ? (
                    <p className="mt-0.5 px-0 text-[11px] leading-4 text-ns-muted">
                      {row.note}
                    </p>
                  ) : null}
                </div>

                {tiers.map((tier, i) => {
                  const raw = row.support[i];
                  const on = has(raw);
                  const dim = (isActive || (!activeId && isPinned)) && !on;
                  return (
                    <div
                      key={tier.id}
                      role="cell"
                      className="text-center transition-opacity duration-200"
                      style={{ opacity: dim ? DIM_OPACITY : 1 }}
                    >
                      {/* the rule seats on the row's first line; values hang below it */}
                      <span aria-hidden className="block h-5" />
                      {typeof raw === "string" ? (
                        <span className="block font-mono text-[11px] leading-4 text-ns-muted">
                          {raw}
                        </span>
                      ) : null}
                      <span className="sr-only">
                        {on
                          ? typeof raw === "string"
                            ? raw
                            : "included"
                          : "not included"}
                      </span>
                    </div>
                  );
                })}

                {reach >= 0 ? (
                  <span
                    aria-hidden
                    ref={(el) => {
                      if (el) ruleRefs.current.set(row.id, el);
                      else ruleRefs.current.delete(row.id);
                    }}
                    data-reach-rule={row.id}
                    className="pointer-events-none absolute font-mono text-[13px] leading-none"
                    style={{
                      top: "1.24rem",
                      transform: "translateY(-50%)",
                      whiteSpace: "pre",
                      letterSpacing: 0,
                      overflow: "hidden",
                      opacity: REST_OPACITY,
                      color: "var(--foreground)",
                    }}
                  >
                    <span />
                    <span />
                  </span>
                ) : null}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

export default ReachRuleTable;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/compare-table-reach-rule.tsx, so point the import there.

Use when Pick compare-table-reach-rule when several plans are compared feature by feature and the reader should see how far up the tiers each feature survives without scanning a grid of checkmarks — the rule length per row is the answer. pricing-scale is the better pick when two plans are being weighed against each other rather than a whole matrix enumerated, and feature-grid-ascii-rule when the relationships between features, not their tier reach, are the subject.

Install

npx shadcn add https://design.helpmarq.com/r/compare-table-reach-rule.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <ReachRuleTable tiers? rows? title? className?> as ONE CSS grid with gridTemplateColumns `minmax(12rem,1.4fr) repeat(N, minmax(6rem,1fr))`, over tiers: CompareTier[] ({id,name,price}) in ascending price order and rows: CompareFeatureRow[] ({id, label, note?, support: (boolean|string)[]}) with one support entry per tier (false = excluded, true = plainly included, a string = the tier-specific value such as "30 days", rendered verbatim under the rule). Each row's label cell is a real <button data-compare-row={id} aria-pressed> that PINS the row on click. THE MECHANIC: for each row compute reach = the highest index i where support[i] is truthy (−1 if none). An aria-hidden absolutely-positioned <span data-reach-rule> is laid over the row, left = the x-centre of tier column 0 and width = (x-centre of column `reach` − left), both taken from a single cached measurement pass that reads getBoundingClientRect on the N header cells plus one offscreen 40-glyph mono probe for the exact 1ch advance; the pass is re-run by a ResizeObserver on the grid root only when its width moves by more than 2px (WIDTH_EPSILON), never per frame. The rule's text is generated per cell at that 1ch pitch: each glyph slot is attributed to the nearest tier centre and drawn `─` when that tier includes the feature or `┄` when it does NOT — so a non-monotone matrix (a feature a mid tier lacks but a higher tier has) reads as a dotted gap inside an otherwise solid run instead of being smoothed away — and the final character is `┤`, held in its own child span so it alone can take accent ink. The rule sits on the row's first-line centre with `white-space:pre; letter-spacing:0; overflow:hidden`. RESTING INK: every row's rule is rendered at full reach at REST_OPACITY 0.42 in --foreground, so the idle frame is a descending staircase of faint rules each capped by ┤ under a different tier — the comparison is legible with no interaction. ACTIVATION: on pointerenter or focus of the row's button that rule steps to opacity 1 and RE-DRAWS from zero glyphs to full reach on one shared rAF (a single loop pumping a Map of in-flight rows, self-cancelling when the map empties) at 14ms per cell with the total capped at 320ms — a per-cell reveal rather than a CSS width transition, so the ┤ terminator lands on its exact cell instead of sliding through the intermediate ones — and every tier column that lacks the feature drops to opacity 0.35. On pointerleave/blur the rule retracts right-to-left at STEP_OUT_MS 9ms per cell and relaxes back to 0.42 unless the row is pinned. The measurement pass re-runs once on document.fonts.ready, because the 1ch probe measured before the mono face swaps in gives the wrong pitch and every ┤ would land off its column centre. Only one row is active at a time; a pinned row stays at full ink, keeps aria-pressed=true and is what the rail relaxes back to. HEADER TALLY: under each tier's name and price sits a mono completeness readout — a 12-cell `▪`/`·` track filled to round(12 * included[i] / rows.length) followed by the literal fraction (`9/12`), where included[i] is the number of rows that tier actually includes. RESPONSIVE: the same ResizeObserver flips the component below 720px to one stacked card per tier — a feature list with ● / ○ per row, the tier-specific value on the right, and the same 12-cell track at the card head; the glyph rules are decoration and are simply not rendered there. Colour is token-only — --border for rules and separators, --foreground for ink, --ns-muted for notes, prices and values, --ns-accent used ONLY on the focus-visible outline and on the ┤ terminator of the pinned row. Screen readers get the matrix as role=table/row/rowheader/cell with a per-cell sr-only "included" / "not included" / value string, so no meaning lives only in the glyphs. prefers-reduced-motion (watched live via matchMedia) drops the per-cell stagger entirely: the rule snaps to full reach and full ink in a single paint and relaxes the same way. Zero dependencies. Props: tiers (CompareTier[]), rows (CompareFeatureRow[]), title (accessible name for the matrix), className.

Props

PropTypeDefaultDescription
tiers?CompareTier[]DEFAULT_TIERStier columns, cheapest first
rows?CompareFeatureRow[]DEFAULT_ROWSfeature rows, one `support` entry per tier
title?string"Plan comparison"accessible name for the matrix
className?string—extra classes merged onto the rendered root element