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

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
Что нужно в проекте до установки
  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

Код

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