Retro Grid
Ретро-сетка: клетчатая плоскость уходит к горизонту и едет на камеру. Одна CSS-перспектива вместо WebGL, бесшовный цикл — сдвиг ровно на клетку.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| backdrop? | string | false | BACKDROP | Подложка (база под сеткой): CSS-значение `background` или `false`. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она. |
| hue? | number | 300 | Тон линий сетки, градусы oklch (0–360). |
| angle? | number | 64 | Наклон плоскости, градусы (50–78: меньше — площе, больше — «пол под ногами»). |
| cell? | number | 48 | Шаг сетки, px. |
| speed? | number | 1.6 | Скорость: секунд на одну клетку. |
| children? | ReactNode | — | Содержимое поверх. В витрине — подпись, в секции обычно пусто. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/retro-grid.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.
Экспорт
Код
/* размерный контейнер: плоскость меряется в РОДИТЕЛЕ (cqw/cqh), не во вьюпорте —
контракт слоя BUILDER 3.5 */
.rg { container-type: size; }
/* камера: линия горизонта на 35% высоты; растворение у горизонта — маской,
а не заливкой поверх, чтобы фон секции просвечивал */
.rg-cam {
position: absolute; inset: 0;
perspective: 200px;
perspective-origin: 50% 35%;
mask-image: linear-gradient(180deg, transparent 32%, #000 46%);
-webkit-mask-image: linear-gradient(180deg, transparent 32%, #000 46%);
}
.rg-plane {
position: absolute; left: 50%; top: 35%;
width: 300cqw; height: 180cqh;
transform: translateX(-50%) rotateX(var(--rg-angle));
transform-origin: top center;
}
/* едет ВНУТРЕННИЙ бегунок трансформом (композитор), а не background-position
(перекраска каждый кадр); запас сверху в одну клетку закрывает исходное положение */
.rg-run {
position: absolute; inset: calc(-1 * var(--rg-cell)) 0 0 0;
background:
repeating-linear-gradient(180deg, var(--rg-line) 0 1px, transparent 1px var(--rg-cell)),
repeating-linear-gradient(90deg, var(--rg-line) 0 1px, transparent 1px var(--rg-cell));
animation: rg-move var(--rg-speed) linear infinite;
}
@keyframes rg-move { to { transform: translate3d(0, var(--rg-cell), 0); } }
@media (prefers-reduced-motion: reduce) { .rg-run { animation: none; } }
const css = `/* размерный контейнер: плоскость меряется в РОДИТЕЛЕ (cqw/cqh), не во вьюпорте —
контракт слоя BUILDER 3.5 */
.rg { container-type: size; }
/* камера: линия горизонта на 35% высоты; растворение у горизонта — маской,
а не заливкой поверх, чтобы фон секции просвечивал */
.rg-cam {
position: absolute; inset: 0;
perspective: 200px;
perspective-origin: 50% 35%;
mask-image: linear-gradient(180deg, transparent 32%, #000 46%);
-webkit-mask-image: linear-gradient(180deg, transparent 32%, #000 46%);
}
.rg-plane {
position: absolute; left: 50%; top: 35%;
width: 300cqw; height: 180cqh;
transform: translateX(-50%) rotateX(var(--rg-angle));
transform-origin: top center;
}
/* едет ВНУТРЕННИЙ бегунок трансформом (композитор), а не background-position
(перекраска каждый кадр); запас сверху в одну клетку закрывает исходное положение */
.rg-run {
position: absolute; inset: calc(-1 * var(--rg-cell)) 0 0 0;
background:
repeating-linear-gradient(180deg, var(--rg-line) 0 1px, transparent 1px var(--rg-cell)),
repeating-linear-gradient(90deg, var(--rg-line) 0 1px, transparent 1px var(--rg-cell));
animation: rg-move var(--rg-speed) linear infinite;
}
@keyframes rg-move { to { transform: translate3d(0, var(--rg-cell), 0); } }
@media (prefers-reduced-motion: reduce) { .rg-run { animation: none; } }
`;
import type { CSSProperties, ReactNode } from "react";
/** Подложка по умолчанию — как в витрине. */
const BACKDROP = "linear-gradient(180deg, oklch(0.16 0.02 285), oklch(0.13 0.02 285))";
export interface RetroGridProps {
/**
* Подложка (база под сеткой): CSS-значение `background` или `false`.
* `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она.
*/
backdrop?: string | false;
/** Тон линий сетки, градусы oklch (0–360). */
hue?: number;
/** Наклон плоскости, градусы (50–78: меньше — площе, больше — «пол под ногами»). */
angle?: number;
/** Шаг сетки, px. */
cell?: number;
/** Скорость: секунд на одну клетку. */
speed?: number;
/** Содержимое поверх. В витрине — подпись, в секции обычно пусто. */
children?: ReactNode;
}
/**
* Ретро-сетка: плоскость с клеткой уходит к горизонту и едет на камеру.
* Вся «трёхмерность» — одна CSS-перспектива, движение — сдвиг РОВНО на клетку
* (кадр в конце цикла совпадает с первым, стыка не существует). Чистый CSS, 0 КБ.
* Reduced-motion / data-shot — сетка стоит, картинка остаётся.
*/
export function RetroGrid({
backdrop = BACKDROP,
hue = 300,
angle = 64,
cell = 48,
speed = 1.6,
children,
}: RetroGridProps = {}) {
const line = `oklch(0.65 0.14 ${hue} / 0.55)`;
return (
<div
className="rg"
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
...(backdrop === false ? null : { background: backdrop }),
}}
>
<style href="motiva-retro-grid" precedence="medium">{css}</style>
<div
className="rg-cam"
style={
{
"--rg-angle": `${angle}deg`,
"--rg-cell": `${cell}px`,
"--rg-speed": `${speed}s`,
"--rg-line": line,
} as CSSProperties
}
>
<div className="rg-plane">
<div className="rg-run" />
</div>
</div>
{children && (
<div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>{children}</div>
)}
</div>
);
}