Motiva
Мои коллекции
← каталог

Comparison Table

Таблица сравнения тарифов с липкой первой колонкой: на телефоне она едет вбок, а названия строк остаются на месте — один макет вместо второй, «мобильной» разметки с теми же данными.

cssscrollhovermouse-movebasic0reduced-motion ✓

Редактируемые поля

Секция «comparison». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.

ПропЧто этоТип
eyebrowНадпись сверхуtext
titleЗаголовокtext
subОписаниеrichtext
columnsКолонки (тарифы)list · до 5
rowsСтроки сравненияlist · до 20
noteПодпись под таблицейtext

Все пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
eyebrow?string"тарифы"
title?string"Что входит в каждый план"
sub?string"Переключиться можно в любой момент — ос…
columns?ComparisonColumn[]COLUMNS
rows?ComparisonRow[]ROWS
note?string"Цены указаны без НДС. Годовая оплата — …Подпись под таблицей.
background?ReactNodeСокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.
ComparisonColumn — что внутри
  • name: string
  • note?: string— Пометка под названием: цена, тариф.
  • featured?: boolean— Выделить колонку как рекомендуемую.
ComparisonRow — что внутри
  • label: string
  • cells: (boolean | string)[]— По значению на колонку: `true`/`false` — галочка и прочерк, строка — как есть.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/comparison-table.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import type { ReactNode } from "react";
import { useEffect, useRef } from "react";

export interface ComparisonColumn {
  name: string;
  /** Пометка под названием: цена, тариф. */
  note?: string;
  /** Выделить колонку как рекомендуемую. */
  featured?: boolean;
}

export interface ComparisonRow {
  label: string;
  /** По значению на колонку: `true`/`false` — галочка и прочерк, строка — как есть. */
  cells: (boolean | string)[];
}

export interface ComparisonTableProps {
  eyebrow?: string;
  title?: string;
  sub?: string;
  columns?: ComparisonColumn[];
  rows?: ComparisonRow[];
  /** Подпись под таблицей. */
  note?: string;
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция снимает собственную заливку, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
  tone?: "dark" | "light";
  /** Плотность скрима между фоном и текстом (формат «0.5»); 0 — выключить. */
  scrim?: number;
}

const COLUMNS: ComparisonColumn[] = [
  { name: "Старт", note: "бесплатно" },
  { name: "Команда", note: "$19/мес", featured: true },
  { name: "Компания", note: "договорная" },
];

const ROWS: ComparisonRow[] = [
  { label: "Проектов", cells: ["3", "без ограничений", "без ограничений"] },
  { label: "Участников", cells: ["1", "до 20", "сколько нужно"] },
  { label: "История версий", cells: ["7 дней", "1 год", "весь срок"] },
  { label: "Экспорт в код", cells: [true, true, true] },
  { label: "Своя дизайн-система", cells: [false, true, true] },
  { label: "Единый вход (SSO)", cells: [false, false, true] },
  { label: "Поддержка", cells: ["почта", "почта и чат", "выделенный инженер"] },
  { label: "Договор и счета", cells: [false, true, true] },
];

/**
 * Таблица сравнения тарифов.
 *
 * Главная проблема таких таблиц не в вёрстке, а в телефоне: колонок три-пять, и на 380 px
 * они либо сжимаются в нечитаемую кашу, либо требуют второй, «мобильной» разметки —
 * то есть второго места, где надо не забыть поправить данные.
 *
 * Здесь один макет на все ширины: обёртка прокручивается по горизонтали, а первая колонка
 * с названиями строк — `position: sticky; left: 0`. Пролистывая колонки, человек всё время
 * видит, ЧТО именно сравнивает; без липкой колонки на третьем экране прокрутки таблица
 * превращается в набор галочек без подписей.
 *
 * Заголовок строки — `<th scope="row">`, шапка — `<th scope="col">`: экранный диктор
 * объявляет ячейку как «Единый вход, Компания, есть», а не читает голое «есть».
 * Сама таблица — настоящая `<table>`, а не сетка из `div`: разметка здесь и есть смысл.
 */
export function ComparisonTable({
  eyebrow = "тарифы",
  title = "Что входит в каждый план",
  sub = "Переключиться можно в любой момент — остаток пересчитаем.",
  columns = COLUMNS,
  rows = ROWS,
  note = "Цены указаны без НДС. Годовая оплата — минус два месяца.",
  background,
  tone,
  scrim = 0.5,
}: ComparisonTableProps) {
  const table = useRef<HTMLTableElement>(null);

  /**
   * Перекрестье под указателем: подсвечиваются и строка, и КОЛОНКА. В широкой таблице глаз
   * теряет строку на полпути вправо — это и есть та ошибка чтения, ради которой сравнение
   * вообще смотрят. Строку подсвечивает CSS сам (`tr:hover`), а вот колонки в CSS нет:
   * селектора «столбец под курсором» не существует, `nth-child` не принимает переменную,
   * а `<col>` не умеет `:hover`. Поэтому номер колонки пишется атрибутом на таблицу —
   * ОДНА запись при переходе между колонками, а не на каждое движение мыши.
   *
   * Слушатель `pointerover`, а не `pointermove`: он срабатывает при СМЕНЕ элемента под
   * указателем, то есть ровно тогда, когда что-то и должно поменяться. Ни rAF, ни чтений
   * раскладки здесь не нужно вовсе — считать нечего.
   */
  useEffect(() => {
    const el = table.current;
    if (!el) return;
    const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
    if (!fine) return;
    const onOver = (e: PointerEvent) => {
      const cell = (e.target as HTMLElement).closest<HTMLTableCellElement>("td, th");
      const i = cell?.cellIndex ?? -1;
      if (i > 0) el.setAttribute("data-col", String(i + 1));
      else el.removeAttribute("data-col");
    };
    const onLeave = () => el.removeAttribute("data-col");
    el.addEventListener("pointerover", onOver);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      el.removeEventListener("pointerover", onOver);
      el.removeEventListener("pointerleave", onLeave);
    };
  }, [columns]);

  return (
    <section
      className="cmp"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ textAlign: "start", ["--cmp-scrim" as string]: scrim }}
    >
      <style>{`
        .cmp { position: relative; isolation: isolate; width: 100%;
          padding-block: var(--ds-section-y, var(--ds-8, 80px));
          background: var(--ds-bg, oklch(0.14 0.02 275));
          color: var(--ds-fg, oklch(0.95 0.01 265));
          font-family: var(--ds-font, system-ui, sans-serif); }
        .cmp-in { max-width: var(--ds-container, 1120px); margin-inline: auto;
          padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }

        .cmp-eyebrow { margin: 0 0 var(--ds-3, 12px); text-transform: uppercase;
          font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.14em;
          color: var(--ds-accent, oklch(0.78 0.16 300)); }
        .cmp-h { margin: 0; max-width: 20ch; text-wrap: balance;
          font-size: var(--ds-text-xl, 2rem); font-weight: 780; line-height: 1.1;
          letter-spacing: var(--ds-tracking-tight, -0.03em); }
        .cmp-sub { margin: var(--ds-3, 12px) 0 0; max-width: 52ch;
          font-size: var(--ds-text-md, 1rem); line-height: var(--ds-leading-body, 1.55);
          color: var(--ds-muted, oklch(0.72 0.02 265)); }

        /* один макет на все ширины: узко — таблица едет вбок, а не ломается */
        .cmp-scroll { margin-top: var(--ds-6, 36px); overflow-x: auto;
          /* без max-width таблица со своим min-width раздвигает документ: скролл-контейнер
             клипует ПОКАЗ, но собственную ширину всё равно берёт по содержимому */
          max-width: 100%;
          border-radius: var(--ds-radius-lg, 20px);
          border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
          background: var(--ds-surface, oklch(0.18 0.02 275)); }
        .cmp-t { width: 100%; border-collapse: separate; border-spacing: 0;
          min-width: 560px; font-size: var(--ds-text-sm, 0.9rem); }
        .cmp-t th, .cmp-t td { padding: var(--ds-3, 12px) var(--ds-4, 16px); text-align: left;
          border-bottom: 1px solid var(--ds-border, oklch(0.3 0.02 275)); }
        .cmp-t tbody tr:last-child th, .cmp-t tbody tr:last-child td { border-bottom: 0; }

        /* липкая первая колонка: прокручивая тарифы, видно, ЧТО сравниваешь.
           Без неё на третьем экране остаётся набор галочек без подписей */
        .cmp-t th[scope="row"], .cmp-th-corner { position: sticky; left: 0; z-index: 2;
          background: var(--ds-surface, oklch(0.18 0.02 275));
          font-weight: 560; color: var(--ds-fg, oklch(0.95 0.01 265));
          box-shadow: 1px 0 0 var(--ds-border, oklch(0.3 0.02 275)); }
        .cmp-t thead th { position: sticky; top: 0; z-index: 1;
          background: var(--ds-surface-2, oklch(0.22 0.025 275));
          font-weight: 680; letter-spacing: var(--ds-tracking-tight, -0.03em); }
        .cmp-th-corner { z-index: 3; background: var(--ds-surface-2, oklch(0.22 0.025 275)); }

        .cmp-col-note { display: block; margin-top: 2px; font-weight: 400;
          font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem);
          color: var(--ds-muted, oklch(0.72 0.02 265)); }
        .cmp-t th[data-featured] { color: var(--ds-accent, oklch(0.78 0.16 300)); }
        .cmp-t td[data-featured] { background: var(--ds-accent-soft, oklch(0.72 0.16 300 / 0.16)); }

        /* Перекрестье. Правила на колонку выписаны по номерам: nth-child не принимает
           переменную, поэтому «столбец под курсором» задаётся атрибутом на таблице.
           Шесть колонок с запасом — больше в сравнении не читается и на широком экране. */
        .cmp-t tbody tr:hover th[scope="row"], .cmp-t tbody tr:hover td {
          background: var(--ds-surface-2, oklch(0.22 0.025 275)); }
        .cmp-t[data-col="2"] tbody td:nth-child(2), .cmp-t[data-col="2"] thead th:nth-child(2),
        .cmp-t[data-col="3"] tbody td:nth-child(3), .cmp-t[data-col="3"] thead th:nth-child(3),
        .cmp-t[data-col="4"] tbody td:nth-child(4), .cmp-t[data-col="4"] thead th:nth-child(4),
        .cmp-t[data-col="5"] tbody td:nth-child(5), .cmp-t[data-col="5"] thead th:nth-child(5),
        .cmp-t[data-col="6"] tbody td:nth-child(6), .cmp-t[data-col="6"] thead th:nth-child(6),
        .cmp-t[data-col="7"] tbody td:nth-child(7), .cmp-t[data-col="7"] thead th:nth-child(7) {
          background: var(--ds-accent-soft, oklch(0.72 0.16 300 / 0.1)); }
        .cmp-t th, .cmp-t td { transition: background var(--ds-fast, 0.18s) linear; }

        .cmp-yes { color: var(--ds-accent, oklch(0.78 0.16 300)); font-weight: 700; }
        .cmp-no { color: var(--ds-muted, oklch(0.72 0.02 265)); opacity: 0.6; }
        .cmp-note { margin: var(--ds-4, 16px) 0 0; font-size: var(--ds-text-xs, 0.75rem);
          color: var(--ds-muted, oklch(0.72 0.02 265)); }

        /* СОКЕТ ФОНА: слой на z-index -1 внутри своего stacking context */
        .cmp-bg { position: absolute; inset: 0; z-index: -1; }
        .cmp[data-slot-bg] { background: transparent; }
        .cmp-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
        .cmp[data-tone="dark"] { --ds-fg: oklch(0.96 0.01 265); --ds-muted: oklch(0.84 0.02 265);
          --ds-border: oklch(0.98 0.01 265 / 0.16); color: var(--ds-fg); }
        .cmp[data-tone="dark"] .cmp-bg::after {
          background: oklch(0.09 0.02 265 / calc(var(--cmp-scrim, 0.5) * 0.9)); }
        .cmp[data-tone="light"] { --ds-fg: oklch(0.2 0.02 265); --ds-muted: oklch(0.42 0.02 265);
          --ds-border: oklch(0.2 0.02 265 / 0.14); color: var(--ds-fg); }
        .cmp[data-tone="light"] .cmp-bg::after {
          background: oklch(0.99 0 0 / calc(var(--cmp-scrim, 0.5) * 0.9)); }
      `}</style>

      {background && <div className="cmp-bg">{background}</div>}

      <div className="cmp-in">
        {eyebrow && <p className="cmp-eyebrow">{eyebrow}</p>}
        <h2 className="cmp-h">{title}</h2>
        {sub && <p className="cmp-sub">{sub}</p>}

        <div className="cmp-scroll" tabIndex={0} role="region" aria-label={title}>
          <table className="cmp-t" ref={table}>
            <thead>
              <tr>
                <th className="cmp-th-corner" scope="col">
                  <span className="cmp-col-note">сравнение</span>
                </th>
                {columns.map((c) => (
                  <th key={c.name} scope="col" data-featured={c.featured ? "" : undefined}>
                    {c.name}
                    {c.note && <span className="cmp-col-note">{c.note}</span>}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {rows.map((r) => (
                <tr key={r.label}>
                  <th scope="row">{r.label}</th>
                  {r.cells.map((cell, i) => (
                    <td key={columns[i]?.name ?? i} data-featured={columns[i]?.featured ? "" : undefined}>
                      {typeof cell === "boolean" ? (
                        <span className={cell ? "cmp-yes" : "cmp-no"}>
                          {cell ? "✓" : "—"}
                          <span style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>
                            {cell ? "есть" : "нет"}
                          </span>
                        </span>
                      ) : (
                        cell
                      )}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>

        {note && <p className="cmp-note">{note}</p>}
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0