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

Team Spotlight

Один человек крупно — вариант team-grid для случая, когда лицо одно и оно продаёт: автор курса, основатель, эксперт. Портрет с плашкой-ролью на снимке (цвета свои — контраст переживает любой кадр), био и факты однопиксельной сеткой на фоне-подложке.

cssbasic~1 КБreduced-motion ✓
↗

Редактируемые поля

Секция «team». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.

ПропЧто этоТип
eyebrowНадпись сверхуtext
nameИмяtext
roleРольtext
bioБиоtext
photoСнимокimage
factsФактыlist · до 4

Все пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
eyebrow?string"кто ведёт"
name?string"Jane Doe"
role?string"продуктовый дизайнер"
bio?string"Делала интерфейсы, которыми пользуются …
facts?SpotlightFact[]FACTS
background?ReactNode—Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.
SpotlightFact — что внутри
  • value: string— Крупное значение (формат «12 лет»).
  • label: string— Подпись под ним.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/team-spotlight.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

Код

import type { ReactNode } from "react";
const css = `.tsp { 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); }
.tsp-in { max-width: var(--ds-container, 1120px); margin-inline: auto;
  padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px));
  display: grid; gap: clamp(24px, 4vw, 54px);
  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); align-items: center; }
@media (max-width: 760px) { .tsp-in { grid-template-columns: minmax(0, 1fr); } }

.tsp-shot { position: relative; border-radius: var(--ds-radius-lg, 20px); overflow: hidden;
  aspect-ratio: 4 / 5; background: var(--ds-surface-2, oklch(0.22 0.025 275)); }
.tsp-shot img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* подпись-роль лежит НА снимке: цвета свои, не темы — контраст обязан пережить кадр */
.tsp-badge { position: absolute; left: 14px; bottom: 14px;
  padding: 7px 12px; border-radius: var(--ds-radius-pill, 999px);
  background: oklch(0.1 0.02 270 / 0.78);
  color: oklch(0.95 0.01 265);
  font: 500 var(--ds-text-xs, 0.74rem) / 1.3 var(--ds-font-mono, ui-monospace, monospace);
  letter-spacing: 0.08em; }

.tsp-eyebrow { margin: 0 0 var(--ds-2, 8px);
  font-family: var(--ds-font-mono, ui-monospace, monospace);
  font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ds-accent, oklch(0.72 0.16 300)); }
.tsp-name { margin: 0; font-size: var(--ds-text-xl, 2rem); font-weight: 780;
  letter-spacing: var(--ds-tracking-tight, -0.03em); }
.tsp-bio { margin: var(--ds-4, 16px) 0 0; max-width: 56ch;
  font-size: var(--ds-text-md, 1rem); line-height: var(--ds-leading-body, 1.6);
  color: var(--ds-muted, oklch(0.72 0.02 265)); }

.tsp-facts { margin: var(--ds-6, 36px) 0 0; padding: 0; list-style: none;
  display: grid; gap: 1px;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  background: var(--ds-border, oklch(0.3 0.02 275));
  border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
  border-radius: var(--ds-radius, 14px); overflow: hidden; }
.tsp-fact { display: grid; gap: 3px; padding: 14px 16px;
  background: var(--ds-surface, oklch(0.17 0.02 273)); }
.tsp-fact b { font-size: var(--ds-text-lg, 1.25rem); font-weight: 750;
  letter-spacing: var(--ds-tracking-tight, -0.03em); }
.tsp-fact span { font-family: var(--ds-font-mono, ui-monospace, monospace);
  font-size: var(--ds-text-xs, 0.72rem);
  color: var(--ds-muted, oklch(0.72 0.02 265)); }

/* СОКЕТ ФОНА — контракт как у остальных секций */
.tsp-bg { position: absolute; inset: 0; z-index: -1; }
.tsp[data-slot-bg] { background: transparent; }
.tsp-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
.tsp[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); }
.tsp[data-tone="dark"] .tsp-bg::after {
  background: oklch(0.09 0.02 265 / calc(var(--tsp-scrim, 0.5) * 0.9)); }
.tsp[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); }
.tsp[data-tone="light"] .tsp-bg::after {
  background: oklch(0.99 0 0 / calc(var(--tsp-scrim, 0.5) * 0.9)); }
`;

export interface SpotlightFact {
  /** Крупное значение (формат «12 лет»). */
  value: string;
  /** Подпись под ним. */
  label: string;
}

export interface TeamSpotlightProps {
  eyebrow?: string;
  name?: string;
  role?: string;
  bio?: string;
  /** Квадратный или портретный снимок; путь вида `https://motiva.pages.dev/img/<id>-600x750.jpg`. */
  photo?: string;
  facts?: SpotlightFact[];
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция снимает собственную заливку, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
  tone?: "dark" | "light";
  /** Плотность скрима между фоном и текстом; 0 — выключить. */
  scrim?: number;
}

const FACTS: SpotlightFact[] = [
  { value: "12 лет", label: "в продуктовой разработке" },
  { value: "40+", label: "запущенных интерфейсов" },
  { value: "3", label: "команды выросли из нуля" },
];

/**
 * Один человек крупно — вариант `team-grid` для случая, когда лицо ОДНО и оно
 * продаёт: автор курса, основатель, ведущий эксперт. Сетка говорит «нас много»,
 * прожектор говорит «вот кто отвечает лично».
 *
 * Снимок держит колонку с честным aspect-ratio (место занято до загрузки), роль
 * лежит плашкой НА снимке — её цвета свои, не темы: контраст обязан пережить любой
 * кадр. Факты — та же однопиксельная сетка на фоне-подложке, что у logos-grid.
 */
export function TeamSpotlight({
  eyebrow = "кто ведёт",
  name = "Jane Doe",
  role = "продуктовый дизайнер",
  bio = "Делала интерфейсы, которыми пользуются миллионы, и команды, которые их не боятся менять. Здесь — всё, что сама хотела бы услышать десять лет назад: без теории ради теории.",
  photo = "https://motiva.pages.dev/img/1027-400x400.jpg",
  facts = FACTS,
  background,
  tone,
  scrim = 0.5,
}: TeamSpotlightProps) {
  return (
    <section
      className="tsp"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ textAlign: "start", ["--tsp-scrim" as string]: scrim }}
    >
      <style href="motiva-team-spotlight" precedence="medium">{css}</style>

      {background && <div className="tsp-bg">{background}</div>}

      <div className="tsp-in">
        <figure className="tsp-shot" style={{ margin: 0 }}>
          <img src={photo} alt={name} loading="lazy" decoding="async" />
          {role && <figcaption className="tsp-badge" data-slot="role">{role}</figcaption>}
        </figure>

        <div>
          {eyebrow && <p className="tsp-eyebrow" data-slot="eyebrow">{eyebrow}</p>}
          {name && <h2 className="tsp-name" data-slot="name">{name}</h2>}
          {bio && <p className="tsp-bio" data-slot="bio">{bio}</p>}

          <ul className="tsp-facts">
            {facts.map((f, i) => (
              <li className="tsp-fact" key={f.label}>
                <b data-slot="facts" data-idx={i} data-field="value">{f.value}</b>
                <span data-slot="facts" data-idx={i} data-field="label">{f.label}</span>
              </li>
            ))}
          </ul>
        </div>
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0