Glitch Image
Цифровой глитч картинки по ховеру — медиа-родня glitch-text: две копии кадра как «разъехавшиеся каналы» (фильтры + screen), полосы режет clip-path, дискретность даёт steps(1), редкая судорога всего кадра. Ноль скриптов.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| alt? | string | "" | |
| ratio? | string | "4 / 3" | Соотношение сторон рамки (формат «4 / 3»). |
| shift? | number | 8 | Амплитуда сдвига полос, px. |
| speed? | number | 0.9 | Длительность цикла судороги, сек. |
| always? | boolean | false | Глитчить постоянно, а не только под курсором. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/glitch-image.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.
Экспорт
Код
import type { CSSProperties } from "react";
const css = `.gli {
position: relative;
margin: 0;
overflow: clip;
border-radius: var(--ds-radius-lg, 20px);
/* изоляция: слои со смещением не должны светиться за скруглением */
isolation: isolate;
}
.gli img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* два слоя-копии: «разъехавшиеся каналы» цифрового сигнала. Полосы режет
clip-path, сдвиг — translate в противофазе, окраска — фильтры. Копии
декоративны и спрятаны от читалок в разметке (aria-hidden) */
.gli-r,
.gli-b {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.gli-r { filter: saturate(2.4) hue-rotate(-45deg); mix-blend-mode: screen; }
.gli-b { filter: saturate(2.4) hue-rotate(140deg); mix-blend-mode: screen; }
/* глитч живёт ТОЛЬКО под курсором (или с data-always): steps(1) — артефакты
обязаны быть дискретными, плавный глитч читается как поломка, а не приём */
@media not (prefers-reduced-motion: reduce) {
.gli:hover .gli-r,
.gli[data-always] .gli-r {
opacity: 0.75;
animation: gli-r calc(var(--gli-speed, 0.9) * 1s) steps(1, end) infinite;
}
.gli:hover .gli-b,
.gli[data-always] .gli-b {
opacity: 0.75;
animation: gli-b calc(var(--gli-speed, 0.9) * 1s) steps(1, end) infinite;
}
.gli:hover > img,
.gli[data-always] > img {
animation: gli-base calc(var(--gli-speed, 0.9) * 1.35s) steps(1, end) infinite;
}
}
@keyframes gli-r {
0% { clip-path: inset(12% 0 74% 0); translate: calc(var(--gli-shift, 8px) * 1) 0; }
14% { clip-path: inset(52% 0 30% 0); translate: calc(var(--gli-shift, 8px) * -0.8) 0; }
28% { clip-path: inset(80% 0 6% 0); translate: calc(var(--gli-shift, 8px) * 0.6) 0; }
42% { clip-path: inset(32% 0 58% 0); translate: calc(var(--gli-shift, 8px) * -1) 0; }
56% { clip-path: inset(64% 0 22% 0); translate: calc(var(--gli-shift, 8px) * 0.9) 0; }
70% { clip-path: inset(4% 0 86% 0); translate: calc(var(--gli-shift, 8px) * -0.5) 0; }
84% { clip-path: inset(44% 0 44% 0); translate: calc(var(--gli-shift, 8px) * 0.7) 0; }
}
@keyframes gli-b {
0% { clip-path: inset(68% 0 18% 0); translate: calc(var(--gli-shift, 8px) * -0.9) 0; }
16% { clip-path: inset(22% 0 66% 0); translate: calc(var(--gli-shift, 8px) * 0.7) 0; }
30% { clip-path: inset(48% 0 40% 0); translate: calc(var(--gli-shift, 8px) * -0.6) 0; }
46% { clip-path: inset(8% 0 80% 0); translate: calc(var(--gli-shift, 8px) * 1) 0; }
62% { clip-path: inset(76% 0 10% 0); translate: calc(var(--gli-shift, 8px) * -0.7) 0; }
78% { clip-path: inset(36% 0 52% 0); translate: calc(var(--gli-shift, 8px) * 0.8) 0; }
92% { clip-path: inset(58% 0 30% 0); translate: calc(var(--gli-shift, 8px) * -0.4) 0; }
}
/* редкая судорога всего кадра — сигнал «сорвался», без неё полосы слишком вежливы */
@keyframes gli-base {
0%, 88% { translate: 0 0; filter: none; }
90% { translate: calc(var(--gli-shift, 8px) * -0.4) 0; filter: saturate(1.6); }
94% { translate: calc(var(--gli-shift, 8px) * 0.3) 0; filter: none; }
}
html[data-shot] .gli .gli-r,
html[data-shot] .gli .gli-b,
html[data-shot] .gli > img {
animation: none;
}
`;
export interface GlitchImageProps {
/** Картинка; путь вида `https://motiva.pages.dev/img/<имя>.jpg`. */
src?: string;
alt?: string;
/** Соотношение сторон рамки (формат «4 / 3»). */
ratio?: string;
/** Амплитуда сдвига полос, px. */
shift?: number;
/** Длительность цикла судороги, сек. */
speed?: number;
/** Глитчить постоянно, а не только под курсором. */
always?: boolean;
}
/**
* Цифровой ГЛИТЧ картинки — медиа-родня `glitch-text`: две копии кадра
* («разъехавшиеся каналы» — фильтры вместо канальной магии, screen-блендинг),
* полосы режет clip-path, дискретность даёт `steps(1)` — плавный глитч
* читается как поломка монитора, а не как приём. Редкая судорога всего
* кадра докладывает, что «сигнал сорвался». Ноль скриптов.
*
* Разность с соседями: `image-distortion`/`image-ripple` гнут кадр ПЛАВНО
* шейдером (WebGL), здесь артефакты нарочно дискретные и дешёвые — CSS
* на двух копиях <img>. По умолчанию живёт только под курсором; `always` —
* для витрин, где ховера не будет.
*/
export function GlitchImage({
src = "https://motiva.pages.dev/img/1074-900x700.jpg",
alt = "",
ratio = "4 / 3",
shift = 8,
speed = 0.9,
always = false,
}: GlitchImageProps) {
return (
<figure
className="gli"
data-always={always ? "" : undefined}
style={{
aspectRatio: ratio,
["--gli-shift" as string]: `${shift}px`,
["--gli-speed" as string]: speed,
} as CSSProperties}
>
<style href="motiva-glitch-image" precedence="medium">{css}</style>
<img src={src} alt={alt} loading="lazy" decoding="async" />
<img className="gli-r" src={src} alt="" aria-hidden="true" loading="lazy" decoding="async" />
<img className="gli-b" src={src} alt="" aria-hidden="true" loading="lazy" decoding="async" />
</figure>
);
}