Compare Table Reach Rule

Comparison

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.

Install
npx shadcn add https://design.helpmarq.com/r/compare-table-reach-rule.json
Source
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;
  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(--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(--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-muted">{tier.price}</span>
              </div>
              <p className="mt-2 font-mono text-[11px] text-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-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-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-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-muted">{tier.price}</div>
                  <div className="mt-1.5 font-mono text-[10px] text-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-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-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-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;
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.

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, --muted for notes, prices and values, --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.

Tags
comparisontablepricingmatrixasciibox-drawingmarketing