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

Blog Index

Журнальный список статей: крупные строки, соседи наведённой приглушаются, а за курсором плывёт снимок статьи. Вариант blog-grid для редакционного указателя вместо «товарных» карточек. Превью — по курсорному шаблону: обработчик запоминает координату и будит цикл, значения догоняют lerp'ом, цикл спит в покое; на таче превью не заводится вовсе.

csshovermouse-moveintermediate~1 КБreduced-motion ✓
↗

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

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

ПропЧто этоТип
eyebrowНадпись сверхуtext
titleЗаголовокtext
itemsСтатьиlist · до 8

Все пропсы

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

ПропТипПо умолчаниюЧто это
eyebrow?string"журнал"
title?string"Что мы пишем"
items?BlogIndexItem[]ITEMS
background?ReactNode—Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.
BlogIndexItem — что внутри
  • title: string
  • tag: string
  • date: string

Установка

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

Код

"use client";

import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
const css = `.bgi { 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); }
.bgi-in { max-width: var(--ds-container, 1120px); margin-inline: auto;
  padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }
.bgi-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)); }
.bgi-h { margin: 0 0 var(--ds-6, 40px);
  font-size: var(--ds-text-xl, 2rem); font-weight: 700;
  letter-spacing: var(--ds-tracking-tight, -0.03em); }

.bgi-wrap { position: relative; }
.bgi-list { margin: 0; padding: 0; list-style: none;
  border-top: 1px solid var(--ds-border, oklch(0.3 0.02 275)); }
.bgi-row { display: grid; grid-template-columns: 1fr auto; align-items: baseline;
  gap: 6px clamp(14px, 4vw, 40px); padding: clamp(16px, 2.6vw, 26px) 0;
  border-bottom: 1px solid var(--ds-border, oklch(0.3 0.02 275));
  text-decoration: none; color: inherit; }
.bgi-title { margin: 0; min-width: 0;
  font-size: clamp(1.15rem, 2.6vw, 1.7rem); font-weight: 650;
  letter-spacing: var(--ds-tracking-tight, -0.03em); line-height: 1.2;
  transition: color var(--ds-fast, 0.18s) var(--ds-ease-out, ease-out); }
.bgi-meta { display: flex; gap: 14px; align-items: baseline;
  font-family: var(--ds-font-mono, ui-monospace, monospace);
  font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.08em;
  color: var(--ds-muted, oklch(0.72 0.02 265)); white-space: nowrap; }
.bgi-tag { padding: 4px 9px; border-radius: var(--ds-radius-pill, 999px);
  border: 1px solid var(--ds-border, oklch(0.3 0.02 275)); }
.bgi-row:hover .bgi-title,
.bgi-row:focus-visible .bgi-title { color: var(--ds-accent, oklch(0.72 0.16 300)); }
.bgi-row:focus-visible { outline: 2px solid var(--ds-accent, oklch(0.72 0.16 300));
  outline-offset: -2px; border-radius: 8px; }
/* приглушение соседей — тем же :has-приёмом, что у сетки логотипов */
.bgi-list:has(.bgi-row:hover) .bgi-row:not(:hover) { opacity: 0.45; }
.bgi-row { transition: opacity var(--ds-base, 0.32s) var(--ds-ease-out, ease-out); }

/* превью за курсором: слой живёт на списке, двигается ТОЛЬКО transform'ом.
   pointer-events: none обязателен — плывущая картинка не должна красть ховер строк */
.bgi-peek { position: absolute; left: 0; top: 0; z-index: 2; width: clamp(180px, 24vw, 300px);
  aspect-ratio: 4 / 3; border-radius: var(--ds-radius, 14px); overflow: hidden;
  pointer-events: none; opacity: 0;
  box-shadow: 0 18px 44px oklch(0 0 0 / 0.4);
  transition: opacity var(--ds-fast, 0.18s) var(--ds-ease-out, ease-out); }
.bgi-peek.is-on { opacity: 1; }
.bgi-peek > img { width: 100%; height: 100%; object-fit: cover; display: block; }

@media (prefers-reduced-motion: reduce) {
  .bgi-row, .bgi-title, .bgi-peek { transition: none; }
}

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

export interface BlogIndexItem {
  title: string;
  tag: string;
  date: string;
  /** Снимок-превью; путь вида `https://motiva.pages.dev/img/<id>-600x400.jpg`. */
  image: string;
}

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

const ITEMS: BlogIndexItem[] = [
  { title: "Почему интерфейс дёргается, хотя код правильный", tag: "разбор", date: "12.08", image: "https://motiva.pages.dev/img/1015-600x800.jpg" },
  { title: "Сетка, которая переживает любой контент", tag: "вёрстка", date: "29.07", image: "https://motiva.pages.dev/img/1016-600x750.jpg" },
  { title: "Анимации, которые не мешают читать", tag: "моушен", date: "15.07", image: "https://motiva.pages.dev/img/1018-600x400.jpg" },
  { title: "Тёмная тема — не инверсия светлой", tag: "дизайн", date: "02.07", image: "https://motiva.pages.dev/img/1019-600x750.jpg" },
  { title: "Скролл как источник силы, а не событий", tag: "разбор", date: "18.06", image: "https://motiva.pages.dev/img/1021-600x750.jpg" },
];

/**
 * Журнальный список статей: крупные строки, а за курсором плывёт снимок наведённой —
 * вариант `blog-grid` для случая, когда карточки слишком «товарные», а нужен
 * редакционный указатель.
 *
 * Превью живёт по курсорному шаблону (AGENTS): обработчик указателя только
 * ЗАПОМИНАЕТ координату и будит цикл; в кадре значения ДОГОНЯЮТ цель интерполяцией;
 * цикл ЗАСЫПАЕТ, когда догнал и когда курсор ушёл. Смена снимка — по `pointerover`
 * (срабатывает при смене элемента под указателем, rAF для этого не нужен).
 * Слушателей ровно два, оба делегированы списку — не по паре на строку.
 *
 * На тач-экране «наведения» не существует — превью не заводится вовсе
 * (`(hover: hover) and (pointer: fine)`); под `prefers-reduced-motion` и `data-shot`
 * снимок появляется у точки входа без движения. Плывущая картинка не крадёт ховер:
 * `pointer-events: none`.
 */
export function BlogIndex({
  eyebrow = "журнал",
  title = "Что мы пишем",
  items = ITEMS,
  background,
  tone,
  scrim = 0.5,
}: BlogIndexProps) {
  const list = useRef<HTMLDivElement>(null);
  const peek = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState<number | null>(null);

  useEffect(() => {
    const root = list.current;
    const layer = peek.current;
    if (!root || !layer) return;
    if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let raf = 0;
    let tx = 0;
    let ty = 0;
    let x = 0;
    let y = 0;
    let seeded = false;

    const place = () => {
      // превью висит справа-сверху от точки курсора, в координатах списка
      layer.style.transform = `translate(${x + 26}px, ${y - layer.offsetHeight * 0.55}px)`;
    };
    const loop = () => {
      x += (tx - x) * 0.16;
      y += (ty - y) * 0.16;
      place();
      if (Math.hypot(tx - x, ty - y) > 0.5) {
        raf = requestAnimationFrame(loop);
      } else {
        raf = 0;
      }
    };
    const wake = () => {
      if (!raf) raf = requestAnimationFrame(loop);
    };

    const onMove = (e: PointerEvent) => {
      const r = root.getBoundingClientRect();
      tx = e.clientX - r.left;
      ty = e.clientY - r.top;
      if (!seeded || frozen) {
        // первая точка и reduce: без подлёта из угла — снимок появляется на месте
        x = tx;
        y = ty;
        seeded = true;
        place();
        return;
      }
      wake();
    };
    const onOver = (e: PointerEvent) => {
      const row = (e.target as HTMLElement).closest<HTMLElement>("[data-row]");
      setActive(row ? Number(row.dataset.row) : null);
    };
    const onLeave = () => {
      setActive(null);
      seeded = false;
      cancelAnimationFrame(raf);
      raf = 0;
    };

    root.addEventListener("pointermove", onMove);
    root.addEventListener("pointerover", onOver);
    root.addEventListener("pointerleave", onLeave);
    return () => {
      cancelAnimationFrame(raf);
      root.removeEventListener("pointermove", onMove);
      root.removeEventListener("pointerover", onOver);
      root.removeEventListener("pointerleave", onLeave);
    };
  }, []);

  return (
    <section
      className="bgi"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ textAlign: "start", ["--bgi-scrim" as string]: scrim }}
    >
      <style href="motiva-blog-index" precedence="medium">{css}</style>

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

      <div className="bgi-in">
        {eyebrow && <p className="bgi-eyebrow" data-slot="eyebrow">{eyebrow}</p>}
        {title && <h2 className="bgi-h" data-slot="title">{title}</h2>}

        {/* обёртка, а не сам список: слой превью — не элемент списка (валидность ul) */}
        <div className="bgi-wrap" ref={list}>
          <ul className="bgi-list">
            {items.map((it, i) => (
              <li key={it.title}>
                <a className="bgi-row" href="#" data-row={i}>
                  <h3 className="bgi-title" data-slot="items" data-idx={i} data-field="title">{it.title}</h3>
                  <span className="bgi-meta">
                    <span className="bgi-tag" data-slot="items" data-idx={i} data-field="tag">{it.tag}</span>
                    <span data-slot="items" data-idx={i} data-field="date">{it.date}</span>
                  </span>
                </a>
              </li>
            ))}
          </ul>
          <div ref={peek} className={`bgi-peek${active != null ? " is-on" : ""}`} aria-hidden="true">
            {active != null && items[active] && <img src={items[active].image} alt="" loading="lazy" />}
          </div>
        </div>
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — паттерн hover image list (Cuberto и др.), код не заимствованv1.0.0