Team Grid
Сетка команды: aspect-ratio на рамке и object-fit на снимке принимают кадры любых пропорций, явные width/height убирают скачок вёрстки, а строка о человеке раскрывается через grid-template-rows 0fr→1fr — ровно на свою высоту.
Редактируемые поля
Секция «team». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.
| Проп | Что это | Тип |
|---|---|---|
| eyebrow | Надпись сверху | text |
| title | Заголовок | text |
| sub | Описание | richtext |
| items | Люди | list · до 12 |
Все пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| eyebrow? | string | "команда" | |
| title? | string | "Восемь человек, из которых четверо разг… | |
| sub? | string | "Без прослойки менеджеров: задачу обсужд… | |
| items? | TeamMember[] | ITEMS | |
| background? | ReactNode | — | Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой. |
TeamMember — что внутри
- name: string
- role: string
- about?: string— Строка, которая раскрывается при наведении и по фокусу с клавиатуры.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/team-grid.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
const css = `.tmg { 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); }
.tmg-in { max-width: var(--ds-container, 1120px); margin-inline: auto;
padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }
.tmg-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)); }
.tmg-h { margin: 0; max-width: 22ch; 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); }
.tmg-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)); }
.tmg-grid { margin: var(--ds-7, 54px) 0 0; padding: 0; list-style: none;
display: grid; gap: var(--ds-5, 24px);
grid-template-columns: repeat(auto-fit, minmax(196px, 1fr)); }
.tmg-card { border-radius: var(--ds-radius-lg, 20px); overflow: hidden;
background: var(--ds-surface, oklch(0.18 0.02 275));
border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
transition: border-color var(--ds-base, 0.32s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
.tmg-card:hover, .tmg-card:focus-within {
border-color: var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.35)); }
/* aspect-ratio на РАМКЕ + object-fit на снимке: в сетку одинаково ложатся кадры
любых пропорций, а кадрирует их браузер */
.tmg-shot { position: relative; aspect-ratio: 1; overflow: hidden;
background: var(--ds-surface-2, oklch(0.22 0.025 275)); }
.tmg-shot img { display: block; width: 100%; height: 100%; object-fit: cover;
filter: saturate(0.55);
transition: filter var(--ds-base, 0.32s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
scale var(--ds-slow, 0.6s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
.tmg-card:hover img, .tmg-card:focus-within img { filter: saturate(1); scale: 1.04; }
.tmg-body { padding: var(--ds-4, 16px); }
.tmg-name { margin: 0; font-size: var(--ds-text-md, 1rem); font-weight: 680;
letter-spacing: var(--ds-tracking-tight, -0.03em); }
.tmg-role { margin: 2px 0 0; font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: var(--ds-text-xs, 0.75rem);
color: var(--ds-accent, oklch(0.78 0.16 300)); }
/* строка о человеке — панель ПОВЕРХ снимка, выезжает из-под нижней кромки.
Раньше она раскрывалась в теле карточки (0fr → 1fr) и меняла её высоту —
auto-fit тянул за ней весь ряд, и секция прыгала на каждом наведении.
Оверлей высоту сетки не трогает вовсе. Цвета панели свои, не токены темы:
она лежит на ФОТОГРАФИИ, и контраст обязан переживать любой кадр */
.tmg-about { position: absolute; inset: auto 0 0 0;
padding: var(--ds-5, 26px) var(--ds-4, 16px) var(--ds-3, 12px);
background: linear-gradient(to top, oklch(0.1 0.02 270 / 0.92), oklch(0.1 0.02 270 / 0.55) 60%, transparent);
translate: 0 100%;
transition: translate var(--ds-base, 0.32s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
.tmg-card:hover .tmg-about, .tmg-card:focus-within .tmg-about { translate: 0 0; }
.tmg-about > p { margin: 0;
font-size: var(--ds-text-sm, 0.86rem); line-height: var(--ds-leading-body, 1.5);
color: oklch(0.95 0.01 265); }
/* СОКЕТ ФОНА: слой на z-index -1 внутри своего stacking context */
.tmg-bg { position: absolute; inset: 0; z-index: -1; }
.tmg[data-slot-bg] { background: transparent; }
.tmg-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
.tmg[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); }
.tmg[data-tone="dark"] .tmg-bg::after {
background: oklch(0.09 0.02 265 / calc(var(--tmg-scrim, 0.5) * 0.9)); }
.tmg[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); }
.tmg[data-tone="light"] .tmg-bg::after {
background: oklch(0.99 0 0 / calc(var(--tmg-scrim, 0.5) * 0.9)); }
@media (prefers-reduced-motion: reduce) {
.tmg-shot img, .tmg-about { transition: none; }
}
`;
import type { ReactNode } from "react";
export interface TeamMember {
name: string;
role: string;
/** Квадратный снимок; путь вида `https://motiva.pages.dev/img/<id>-400x400.jpg`. */
photo: string;
/** Строка, которая раскрывается при наведении и по фокусу с клавиатуры. */
about?: string;
}
export interface TeamGridProps {
eyebrow?: string;
title?: string;
sub?: string;
items?: TeamMember[];
/**
* Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
* Занят — секция снимает собственную заливку, чтобы не спорить за слой.
*/
background?: ReactNode;
/** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
tone?: "dark" | "light";
/** Плотность скрима между фоном и текстом; 0 — выключить. */
scrim?: number;
}
const ITEMS: TeamMember[] = [
{ name: "Jane Doe", role: "фронтенд", photo: "https://motiva.pages.dev/img/1027-400x400.jpg", about: "Делает так, чтобы интерфейс не дёргался ни на каком железе." },
{ name: "Mary Major", role: "дизайн", photo: "https://motiva.pages.dev/img/823-400x400.jpg", about: "Считает, что макет без состояний — это картинка, а не дизайн." },
{ name: "John Roe", role: "бэкенд", photo: "https://motiva.pages.dev/img/91-400x400.jpg", about: "Отвечает за то, чтобы данные приходили раньше, чем их успели ждать." },
{ name: "Alex Poe", role: "продукт", photo: "https://motiva.pages.dev/img/1005-400x400.jpg", about: "Переводит «хочется красиво» в список того, что реально нужно сделать." },
];
/**
* Сетка команды: снимок, имя, роль и строка о человеке, которая раскрывается при наведении
* и по фокусу с клавиатуры.
*
* Три вещи, из-за которых такие сетки обычно выглядят собранными наспех:
*
* 1. **Разный размер снимков.** Тут карточке задан `aspect-ratio`, а снимку — `object-fit:
* cover`, поэтому в сетку одинаково ложатся и квадрат, и вертикаль, и панорама. Сетка
* держит ритм, а кадрирование берёт на себя браузер.
* 2. **Скачок вёрстки при подгрузке.** У каждого `<img>` явные `width`/`height` и
* `loading="lazy"`: место резервируется до загрузки, поэтому текст под сеткой не
* подпрыгивает, когда приезжают снимки.
* 3. **Раскрытие через `max-height`.** Оно анимируется от произвольного числа, а не от
* содержимого, и либо режет длинный текст, либо оставляет дыру под коротким. Здесь
* `grid-template-rows: 0fr → 1fr` — строка едет ровно на свою высоту, какой бы она ни была.
*
* Раскрытие висит и на `:hover`, и на `:focus-within` — с клавиатуры карточка ведёт себя
* так же, как под курсором.
*/
export function TeamGrid({
eyebrow = "команда",
title = "Восемь человек, из которых четверо разговаривают с вами",
sub = "Без прослойки менеджеров: задачу обсуждаете с теми, кто её и делает.",
items = ITEMS,
background,
tone,
scrim = 0.5,
}: TeamGridProps) {
return (
<section
className="tmg"
data-slot-bg={background ? "" : undefined}
data-tone={tone}
style={{ textAlign: "start", ["--tmg-scrim" as string]: scrim }}
>
<style href="motiva-team-grid" precedence="medium">{css}</style>
{background && <div className="tmg-bg">{background}</div>}
<div className="tmg-in">
{/* data-slot: адрес слота для inline-правки в конструкторе (/build) */}
{eyebrow && <p className="tmg-eyebrow" data-slot="eyebrow">{eyebrow}</p>}
{title && <h2 className="tmg-h" data-slot="title">{title}</h2>}
{sub && <p className="tmg-sub" data-slot="sub">{sub}</p>}
<ul className="tmg-grid">
{items.map((m, i) => (
<li className="tmg-card" key={m.name} tabIndex={0}>
<div className="tmg-shot">
{/* явные width/height + lazy: место под снимок занято до загрузки,
поэтому текст под сеткой не подпрыгивает, когда кадры приезжают */}
<img src={m.photo} alt={m.name} width={400} height={400} loading="lazy" decoding="async" />
{/* строка о человеке — ОВЕРЛЕЕМ поверх снимка, а не в теле карточки:
раскрытие в теле меняло высоту карточки, auto-fit тянул за ней ВЕСЬ
ряд, и секция прыгала на каждом наведении */}
{m.about && (
<div className="tmg-about">
<p>
<span data-slot="items" data-idx={i} data-field="about">{m.about}</span>
</p>
</div>
)}
</div>
<div className="tmg-body">
<h3 className="tmg-name" data-slot="items" data-idx={i} data-field="name">{m.name}</h3>
<p className="tmg-role" data-slot="items" data-idx={i} data-field="role">{m.role}</p>
</div>
</li>
))}
</ul>
</div>
</section>
);
}