← каталог
Team Grid
Сетка команды: aspect-ratio на рамке и object-fit на снимке принимают кадры любых пропорций, явные width/height убирают скачок вёрстки, а строка о человеке раскрывается через grid-template-rows 0fr→1fr — ровно на свою высоту.
csshoverrevealbasic0reduced-motion ✓
Редактируемые поля
Секция «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в корне — с алиасами в форме"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.
Экспорт
Код
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.5»); 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>{`
.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 { 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: строка едет ровно на свою высоту. max-height анимируется от
произвольного числа и либо режет длинный текст, либо оставляет дыру под коротким */
.tmg-about { display: grid; grid-template-rows: 0fr;
transition: grid-template-rows 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 { grid-template-rows: 1fr; }
.tmg-about > p { overflow: hidden; margin: 0;
font-size: var(--ds-text-sm, 0.86rem); line-height: var(--ds-leading-body, 1.55);
color: var(--ds-muted, oklch(0.72 0.02 265)); }
.tmg-about > p > span { display: block; padding-top: var(--ds-3, 12px); }
/* СОКЕТ ФОНА: слой на 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; }
}
`}</style>
{background && <div className="tmg-bg">{background}</div>}
<div className="tmg-in">
{eyebrow && <p className="tmg-eyebrow">{eyebrow}</p>}
<h2 className="tmg-h">{title}</h2>
{sub && <p className="tmg-sub">{sub}</p>}
<ul className="tmg-grid">
{items.map((m) => (
<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" />
</div>
<div className="tmg-body">
<h3 className="tmg-name">{m.name}</h3>
<p className="tmg-role">{m.role}</p>
{m.about && (
<div className="tmg-about">
<p>
<span>{m.about}</span>
</p>
</div>
)}
</div>
</li>
))}
</ul>
</div>
</section>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0