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

Blog Grid

Сетка статей, у которой в ряду совпадают все линии — низ обложки, дата, заголовок, врезка, подпись. Держит это grid-template-rows: subgrid: карточка встаёт в строки родителя, а не заводит свои, поэтому заголовки не надо обрезать и подгонять по высоте.

csshovermouse-moveintermediate0reduced-motion ✓

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

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

ПропЧто этоТип
eyebrowНадпись сверхуtext
titleЗаголовокtext
subОписаниеrichtext
postsСтатьиlist · до 9

Все пропсы

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

ПропТипПо умолчаниюЧто это
eyebrow?string"журнал"
title?string"Пишем о том, что сломали и починили"
sub?string"Без обзоров трендов: только свои случаи…
posts?Post[]POSTS
background?ReactNodeСокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.
Post — что внутри
  • title: string
  • excerpt: string
  • date: string— Дата в том виде, в каком её показывать (формат «12 марта»).
  • rubric?: string— Рубрика (формат «разбор»).
  • author: string
  • readTime?: string— Сколько читать (формат «7 мин»).
  • href?: string
  • image?: string
  • imageW?: number
  • imageH?: number

Установка

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

Код

"use client";

import type { ReactNode } from "react";
import { useEffect, useRef } from "react";

export interface Post {
  title: string;
  excerpt: string;
  /** Дата в том виде, в каком её показывать (формат «12 марта»). */
  date: string;
  /** Рубрика (формат «разбор»). */
  rubric?: string;
  author: string;
  /** Сколько читать (формат «7 мин»). */
  readTime?: string;
  href?: string;
  image?: string;
  imageW?: number;
  imageH?: number;
}

export interface BlogGridProps {
  eyebrow?: string;
  title?: string;
  sub?: string;
  posts?: Post[];
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция снимает собственную заливку, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
  tone?: "dark" | "light";
  /** Плотность скрима между фоном и текстом (формат «0.5»); 0 — выключить. */
  scrim?: number;
}

const POSTS: Post[] = [
  {
    title: "Почему мы выкинули собственный планировщик",
    excerpt: "Два года он работал лучше готового. На третий выяснилось, что чинить его умеет один человек, и тот в отпуске.",
    date: "12 марта", rubric: "разбор", author: "Jane Doe", readTime: "7 мин",
    image: "https://motiva.pages.dev/img/1018-600x400.jpg", imageW: 600, imageH: 400,
  },
  {
    title: "Сто тысяч запросов в минуту и один индекс",
    excerpt: "История про то, как отсутствие одной строки в схеме обошлось в неделю разбирательств и половину квартальной сметы на серверы.",
    date: "4 марта", rubric: "инфраструктура", author: "John Roe", readTime: "11 мин",
    image: "https://motiva.pages.dev/img/1035-600x400.jpg", imageW: 600, imageH: 400,
  },
  {
    title: "Дизайн-система без дизайнера",
    excerpt: "Что получается, когда правила пишут разработчики.",
    date: "27 февраля", rubric: "процесс", author: "Mary Major", readTime: "5 мин",
    image: "https://motiva.pages.dev/img/1043-600x400.jpg", imageW: 600, imageH: 400,
  },
];

/**
 * Сетка статей, у которой в каждом ряду совпадают ВСЕ линии: низ обложки, строка даты,
 * заголовок, врезка и подпись автора.
 *
 * Само по себе это не получается. Обычная карточка — отдельный грид со своими строками,
 * и высота каждой строки зависит только от её содержимого: заголовок в две строки сдвигает
 * вниз всё, что под ним, и подписи в ряду «пляшут» по вертикали. Лечат это обычно двумя
 * способами, и оба плохие: обрезать заголовок до одной строки (терять смысл ради ровности)
 * или задать ему высоту в пикселях (сломается при другом кегле, другом языке и на телефоне).
 *
 * Правильный ответ — `grid-template-rows: subgrid`: карточка перестаёт заводить собственные
 * строки и встаёт в строки РОДИТЕЛЬСКОГО грида. Высота каждой строки считается по самой
 * длинной карточке в ряду, поэтому линии общие, а тексты не тронуты. Ряд при этом остаётся
 * адаптивным: колонки набираются `auto-fit` и на телефоне их становится одна.
 *
 * Обложки приходят разных пропорций, поэтому у рамки задано `aspect-ratio`, а снимок кроется
 * `object-fit: cover`; явные `width`/`height` у `img` держат место до загрузки — без них
 * текст под сеткой подпрыгивает по мере того, как приезжают картинки.
 */
export function BlogGrid({
  eyebrow = "журнал",
  title = "Пишем о том, что сломали и починили",
  sub = "Без обзоров трендов: только свои случаи, с числами и последствиями.",
  posts = POSTS,
  background,
  tone,
  scrim = 0.5,
}: BlogGridProps) {
  const list = useRef<HTMLUListElement>(null);

  /**
   * Блик под курсором на карточке. Слушатель ОДИН на всю сетку, а не по одному на карточку:
   * девять карточек — девять подписок, которые ещё и надо снимать; делегирование даёт одну.
   * Карточка под указателем находится через `closest`, координаты пишутся ей в переменные.
   *
   * Координаты считаются в кадре (одно чтение рамки), обработчик только запоминает точку,
   * цикл засыпает сразу после записи — блик не инерционный, догонять нечего.
   */
  useEffect(() => {
    const el = list.current;
    if (!el) return;
    const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!fine || frozen) return;

    let hot: HTMLElement | null = null;
    let clientX = 0;
    let clientY = 0;
    let raf = 0;

    const frame = () => {
      raf = 0;
      if (!hot) return;
      const box = hot.getBoundingClientRect(); // одно чтение на кадр
      hot.style.setProperty("--mx", `${(clientX - box.left).toFixed(0)}px`);
      hot.style.setProperty("--my", `${(clientY - box.top).toFixed(0)}px`);
    };
    const onMove = (e: PointerEvent) => {
      const card = (e.target as HTMLElement).closest<HTMLElement>(".blg-card");
      if (card !== hot) {
        hot?.removeAttribute("data-hot");
        card?.setAttribute("data-hot", "");
        hot = card;
      }
      clientX = e.clientX;
      clientY = e.clientY;
      if (!raf) raf = requestAnimationFrame(frame);
    };
    const onLeave = () => {
      hot?.removeAttribute("data-hot");
      hot = null;
    };
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerleave", onLeave);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [posts]);

  return (
    <section
      className="blg"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ textAlign: "start", ["--blg-scrim" as string]: scrim }}
    >
      <style>{`
        .blg { 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); }
        .blg-in { max-width: var(--ds-container, 1120px); margin-inline: auto;
          padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }

        .blg-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)); }
        .blg-h { margin: 0; max-width: 20ch; 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); }
        .blg-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)); }

        .blg-list { margin: var(--ds-7, 54px) 0 0; padding: 0; list-style: none;
          display: grid; gap: var(--ds-6, 36px) var(--ds-5, 24px);
          grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); }

        /* Карточка занимает ПЯТЬ строк родителя и наследует их: обложка, дата, заголовок,
           врезка, подпись. Без subgrid каждая карточка делила бы высоту по-своему. */
        .blg-card { position: relative; isolation: isolate; grid-row: span 5;
          display: grid; grid-template-rows: subgrid; gap: var(--ds-2, 8px); }
        /* Блик. z-index: -1 внутри своего stacking context — поверх фона карточки,
           но ПОД текстом: иначе полупрозрачное пятно ложится на буквы и мылит их. */
        .blg-card::before { content: ""; position: absolute; inset: -12px; z-index: -1;
          border-radius: var(--ds-radius-lg, 18px); pointer-events: none; opacity: 0;
          background: radial-gradient(240px circle at var(--mx, 50%) var(--my, 50%),
            var(--ds-accent-soft, oklch(0.72 0.16 300 / 0.1)), transparent 68%);
          transition: opacity var(--ds-base, 0.32s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .blg-card[data-hot]::before { opacity: 1; }

        /* Ссылка одна — на заголовке, а кликабельна вся карточка: псевдоэлемент растянут
           по ней. Обернуть карточку в <a> нельзя: тогда ссылка стала бы элементом грида
           вместо <li>, и subgrid пришлось бы вкладывать вторым уровнем. */
        .blg-link { text-decoration: none; color: inherit; }
        .blg-link::after { content: ""; position: absolute; inset: 0;
          border-radius: var(--ds-radius, 12px); }
        .blg-card:has(.blg-link:focus-visible) {
          outline: 2px solid var(--ds-accent, oklch(0.78 0.16 300)); outline-offset: 6px;
          border-radius: var(--ds-radius, 12px); }
        .blg-link:focus-visible { outline: none; }

        .blg-cover { margin: 0; overflow: hidden; border-radius: var(--ds-radius, 12px);
          aspect-ratio: 16 / 10; background: var(--ds-surface, oklch(0.18 0.02 275));
          border: 1px solid var(--ds-border, oklch(0.3 0.02 275)); }
        .blg-cover img { display: block; width: 100%; height: 100%; object-fit: cover;
          transition: scale var(--ds-slow, 0.6s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .blg-card:is(:hover, :focus-within) .blg-cover img { scale: 1.04; }

        .blg-meta { display: flex; gap: 8px; align-items: baseline;
          font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.03em;
          color: var(--ds-muted, oklch(0.72 0.02 265)); }
        .blg-rubric { color: var(--ds-accent, oklch(0.78 0.16 300)); }
        .blg-t { margin: 0; font-size: var(--ds-text-lg, 1.25rem); font-weight: 660;
          line-height: var(--ds-leading-tight, 1.15); text-wrap: balance;
          letter-spacing: var(--ds-tracking-tight, -0.03em); }
        .blg-card:is(:hover, :focus-within) .blg-t { color: var(--ds-accent, oklch(0.78 0.16 300)); }
        .blg-x { margin: 0; font-size: var(--ds-text-sm, 0.86rem);
          line-height: var(--ds-leading-body, 1.55); text-wrap: pretty;
          color: var(--ds-muted, oklch(0.72 0.02 265)); }
        .blg-by { display: flex; gap: 8px; align-items: baseline; align-self: end;
          padding-top: var(--ds-2, 8px);
          border-top: 1px solid var(--ds-border, oklch(0.3 0.02 275));
          font-size: var(--ds-text-xs, 0.75rem);
          color: var(--ds-muted, oklch(0.72 0.02 265)); }
        .blg-time { margin-left: auto; font-family: var(--ds-font-mono, ui-monospace, monospace); }

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

        @media (prefers-reduced-motion: reduce) {
          .blg-cover img { transition: none; }
          .blg-card::before { display: none; }
        }
      `}</style>

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

      <div className="blg-in">
        {eyebrow && <p className="blg-eyebrow">{eyebrow}</p>}
        <h2 className="blg-h">{title}</h2>
        {sub && <p className="blg-sub">{sub}</p>}

        <ul className="blg-list" ref={list}>
          {posts.map((p) => (
            <li className="blg-card" key={p.title}>
              <figure className="blg-cover">
                {p.image && (
                  <img
                    src={p.image}
                    alt=""
                    width={p.imageW}
                    height={p.imageH}
                    loading="lazy"
                    decoding="async"
                  />
                )}
              </figure>
              <p className="blg-meta">
                <span>{p.date}</span>
                {p.rubric && <span className="blg-rubric">{p.rubric}</span>}
              </p>
              <h3 className="blg-t">
                <a className="blg-link" href={p.href ?? "#"}>
                  {p.title}
                </a>
              </h3>
              <p className="blg-x">{p.excerpt}</p>
              <p className="blg-by">
                <span>{p.author}</span>
                {p.readTime && <span className="blg-time">{p.readTime}</span>}
              </p>
            </li>
          ))}
        </ul>
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0