← каталог
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Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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