Blog Index
Журнальный список статей: крупные строки, соседи наведённой приглушаются, а за курсором плывёт снимок статьи. Вариант blog-grid для редакционного указателя вместо «товарных» карточек. Превью — по курсорному шаблону: обработчик запоминает координату и будит цикл, значения догоняют lerp'ом, цикл спит в покое; на таче превью не заводится вовсе.
Редактируемые поля
Секция «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Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}