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

Team Grid

Сетка команды: aspect-ratio на рамке и object-fit на снимке принимают кадры любых пропорций, явные width/height убирают скачок вёрстки, а строка о человеке раскрывается через grid-template-rows 0fr→1fr — ровно на свою высоту.

csshoverrevealbasicreduced-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
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0