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

Glitch Image

Цифровой глитч картинки по ховеру — медиа-родня glitch-text: две копии кадра как «разъехавшиеся каналы» (фильтры + screen), полосы режет clip-path, дискретность даёт steps(1), редкая судорога всего кадра. Ноль скриптов.

csshovermorphbasicreduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
alt?string""
ratio?string"4 / 3"Соотношение сторон рамки (формат «4 / 3»).
shift?number8Амплитуда сдвига полос, px.
speed?number0.9Длительность цикла судороги, сек.
always?booleanfalseГлитчить постоянно, а не только под курсором.

Установка

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

Код

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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0