Noise / Grain
Плёночное зерно поверх любого фона: SVG feTurbulence как data-URI + steps()-джиттер — зерно «живёт» как на плёнке.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| children? | ReactNode | — | |
| opacity? | number | 0.25 | Непрозрачность зерна 0–1. |
| size? | number | 240 | Размер тайла зерна, px: меньше — мельче зерно. |
| blend? | "screen" | "overlay" | "soft-light" | "normal" | "screen" | Режим смешивания плёнки. |
| animated? | boolean | true | Живое зерно (лёгкий джиттер кадрами) или статичная плёнка. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/noise-grain.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.
Экспорт
Код
/* джиттер через transform (композитор), НЕ background-position — тот перекрашивает
весь полноэкранный слой на каждый шаг */
@keyframes ng-jitter {
0% { transform: translate(0, 0); } 25% { transform: translate(-70px, 40px); }
50% { transform: translate(40px, -90px); } 75% { transform: translate(-30px, -50px); }
100% { transform: translate(60px, 70px); }
}
.ng-film { animation: ng-jitter 0.7s steps(5) infinite; will-change: transform; }
@media (prefers-reduced-motion: reduce) { .ng-film { animation: none; } }
const css = `/* джиттер через transform (композитор), НЕ background-position — тот перекрашивает
весь полноэкранный слой на каждый шаг */
@keyframes ng-jitter {
0% { transform: translate(0, 0); } 25% { transform: translate(-70px, 40px); }
50% { transform: translate(40px, -90px); } 75% { transform: translate(-30px, -50px); }
100% { transform: translate(60px, 70px); }
}
.ng-film { animation: ng-jitter 0.7s steps(5) infinite; will-change: transform; }
@media (prefers-reduced-motion: reduce) { .ng-film { animation: none; } }
`;
import type { CSSProperties, ReactNode } from "react";
/** SVG-шум (feTurbulence) как data-URI — без внешних ассетов и canvas. */
const NOISE_URI =
"data:image/svg+xml," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter><rect width="240" height="240" filter="url(%23n)" opacity="0.85"/></svg>`,
);
export interface NoiseGrainProps {
children?: ReactNode;
/** Непрозрачность зерна 0–1. */
opacity?: number;
/** Размер тайла зерна, px: меньше — мельче зерно. */
size?: number;
/** Режим смешивания плёнки. */
blend?: "screen" | "overlay" | "soft-light" | "normal";
/** Живое зерно (лёгкий джиттер кадрами) или статичная плёнка. */
animated?: boolean;
}
/**
* Плёночное зерно поверх любого фона: SVG feTurbulence data-URI + steps()-джиттер позиции —
* зерно «живёт» как на плёнке. Чистый CSS. Reduced-motion/data-shot — зерно статично.
*/
export function NoiseGrain({ children, opacity = 0.25, size = 240, blend = "screen", animated = true }: NoiseGrainProps) {
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden" }}>
<style href="motiva-noise-grain" precedence="medium">{css}</style>
{children ?? (
<div
style={{
position: "absolute",
inset: 0,
background:
"radial-gradient(80% 90% at 30% 20%, oklch(0.45 0.12 300 / 0.7), transparent 60%), radial-gradient(70% 80% at 75% 75%, oklch(0.5 0.12 220 / 0.55), transparent 60%), oklch(0.16 0.02 270)",
display: "grid",
placeItems: "center",
color: "oklch(0.92 0.01 265)",
}}
>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: "1.8rem", fontWeight: 700, letterSpacing: "-0.02em" }}>Noise / Grain</div>
<div style={{ marginTop: 6, fontFamily: "ui-monospace, monospace", fontSize: "0.75rem", opacity: 0.6 }}>
плёночное зерно поверх градиента
</div>
</div>
</div>
)}
<div
className={animated ? "ng-film" : undefined}
aria-hidden
style={{
position: "absolute",
inset: "-120px",
backgroundImage: `url("${NOISE_URI}")`,
backgroundSize: `${size}px ${size}px`,
opacity,
pointerEvents: "none",
mixBlendMode: blend as CSSProperties["mixBlendMode"],
}}
/>
</div>
);
}