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

Scratch Card

Скретч-карта: серебристый слой стирается указателем как монеткой (canvas + destination-out), доля стёртого считается по сетке альфа-канала, после порога слой уходит целиком. Под reduced-motion и в скриншотах карта отдаётся уже стёртой — контент не прячем.

cssdragrevealintermediate~3 КБreduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
prize?string"−30 % на всё"Что под слоем: главная строка.
note?string"промокод SCRATCH · до воскресенья"Строка под призом.
cover?string"сотрите монеткой"Подпись на самом слое.
brush?number26Радиус «монетки», px.
threshold?number0.45Доля стёртого, после которой слой уходит сам (0—1).

Установка

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

Код

"use client";

import { useEffect, useRef, useState } from "react";
const css = `.scr {
  position: relative;
  display: inline-block;
  border-radius: var(--ds-radius-lg, 18px);
  overflow: hidden;
  /* стираем пальцем — страница не должна скроллиться под жестом */
  touch-action: none;
  user-select: none;
}

.scr-under {
  display: grid;
  place-items: center;
  gap: 4px;
  padding: clamp(22px, 5vw, 40px) clamp(28px, 6vw, 56px);
  text-align: center;
  background:
    radial-gradient(120% 140% at 20% 0%,
      oklch(from var(--ds-accent, oklch(0.72 0.16 300)) l c h / 0.28), transparent 60%),
    var(--ds-surface, oklch(0.17 0.02 273));
  border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
  border-radius: inherit;
  color: var(--ds-fg, oklch(0.95 0.01 265));
  font-family: var(--ds-font, system-ui, sans-serif);
}
.scr-prize {
  font-size: clamp(1.6rem, 4vw, 2.4rem);
  font-weight: 800;
  letter-spacing: var(--ds-tracking-tight, -0.03em);
  color: var(--ds-accent, oklch(0.72 0.16 300));
}
.scr-note {
  margin: 0;
  font-family: var(--ds-font-mono, ui-monospace, monospace);
  font-size: var(--ds-text-xs, 0.74rem);
  color: var(--ds-muted, oklch(0.72 0.02 265));
}

.scr canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  cursor: grab;
  /* добит порог — слой уходит целиком, дотирать углы не заставляем */
  transition: opacity 0.6s ease;
}
.scr[data-done] canvas {
  opacity: 0;
  pointer-events: none;
}
`;

export interface ScratchCardProps {
  /** Что под слоем: главная строка. */
  prize?: string;
  /** Строка под призом. */
  note?: string;
  /** Подпись на самом слое. */
  cover?: string;
  /** Радиус «монетки», px. */
  brush?: number;
  /** Доля стёртого, после которой слой уходит сам (0—1). */
  threshold?: number;
}

/**
 * СКРЕТЧ-КАРТА: серебристый слой стирается указателем, как монеткой, —
 * промо-классика (промокоды, «что под обёрткой», пасхалки 404-х).
 * Стирание — canvas с `globalCompositeOperation: destination-out`; доля
 * стёртого считается ПО СЕТКЕ АЛЬФА-КАНАЛА раз в несколько мазков, а не на
 * каждый пиксель, и после порога слой уходит целиком — дотирать углы
 * пользователя не заставляем.
 *
 * `touch-action: none` — жест стирания не должен скроллить страницу.
 * Под data-shot и reduced-motion карта отдаётся УЖЕ СТЁРТОЙ: сам приём —
 * пользовательский ввод, а не анимация, но прятать контент от того, кто
 * ввода не совершит, — нечестно.
 */
export function ScratchCard({
  prize = "−30 % на всё",
  note = "промокод SCRATCH · до воскресенья",
  cover = "сотрите монеткой",
  brush = 26,
  threshold = 0.45,
}: ScratchCardProps) {
  const host = useRef<HTMLDivElement>(null);
  const cnv = useRef<HTMLCanvasElement>(null);
  const [done, setDone] = useState(false);

  useEffect(() => {
    const el = host.current;
    const canvas = cnv.current;
    if (!el || !canvas) return;

    if (
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    ) {
      setDone(true);
      return;
    }

    const ctx = canvas.getContext("2d");
    if (!ctx) { setDone(true); return; }

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const r = el.getBoundingClientRect();
    const w = Math.max(1, Math.round(r.width));
    const h = Math.max(1, Math.round(r.height));
    canvas.width = Math.round(w * dpr);
    canvas.height = Math.round(h * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    // «фольга»: металлический градиент + лёгкая зернь штрихами
    const g = ctx.createLinearGradient(0, 0, w, h);
    g.addColorStop(0, "#b9bec9");
    g.addColorStop(0.45, "#e6e9ef");
    g.addColorStop(0.55, "#c6ccd8");
    g.addColorStop(1, "#9aa1af");
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, w, h);
    ctx.globalAlpha = 0.08;
    ctx.strokeStyle = "#5c6270";
    for (let i = -h; i < w; i += 7) {
      ctx.beginPath();
      ctx.moveTo(i, 0);
      ctx.lineTo(i + h, h);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;
    ctx.fillStyle = "#4a505c";
    ctx.font = `600 ${Math.max(13, Math.round(w / 22))}px ui-monospace, monospace`;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(cover, w / 2, h / 2);

    ctx.globalCompositeOperation = "destination-out";
    ctx.lineWidth = brush * 2;
    ctx.lineCap = "round";
    ctx.lineJoin = "round";

    let scratching = false;
    let lastX = 0;
    let lastY = 0;
    let strokes = 0;

    const ratioErased = () => {
      // сетка ~64×48 точек: читаем альфу выборкой — этого достаточно и дёшево
      const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
      const stepX = Math.max(1, Math.floor(canvas.width / 64));
      const stepY = Math.max(1, Math.floor(canvas.height / 48));
      let clear = 0;
      let total = 0;
      for (let y = 0; y < canvas.height; y += stepY) {
        for (let x = 0; x < canvas.width; x += stepX) {
          total++;
          if (data[(y * canvas.width + x) * 4 + 3]! < 16) clear++;
        }
      }
      return total ? clear / total : 0;
    };

    const pos = (e: PointerEvent) => {
      const b = canvas.getBoundingClientRect();
      return { x: e.clientX - b.left, y: e.clientY - b.top };
    };
    const down = (e: PointerEvent) => {
      scratching = true;
      canvas.setPointerCapture(e.pointerId);
      const p = pos(e);
      lastX = p.x;
      lastY = p.y;
      ctx.beginPath();
      ctx.arc(p.x, p.y, brush, 0, Math.PI * 2);
      ctx.fill();
    };
    const move = (e: PointerEvent) => {
      if (!scratching) return;
      const p = pos(e);
      ctx.beginPath();
      ctx.moveTo(lastX, lastY);
      ctx.lineTo(p.x, p.y);
      ctx.stroke();
      lastX = p.x;
      lastY = p.y;
      if (++strokes % 6 === 0 && ratioErased() > threshold) setDone(true);
    };
    const up = () => {
      if (!scratching) return;
      scratching = false;
      if (ratioErased() > threshold) setDone(true);
    };

    canvas.addEventListener("pointerdown", down);
    canvas.addEventListener("pointermove", move);
    canvas.addEventListener("pointerup", up);
    canvas.addEventListener("pointercancel", up);
    return () => {
      canvas.removeEventListener("pointerdown", down);
      canvas.removeEventListener("pointermove", move);
      canvas.removeEventListener("pointerup", up);
      canvas.removeEventListener("pointercancel", up);
    };
  }, [cover, brush, threshold]);

  return (
    <div ref={host} className="scr" data-done={done ? "" : undefined}>
      <style href="motiva-scratch-card" precedence="medium">{css}</style>
      <div className="scr-under">
        <div className="scr-prize">{prize}</div>
        {note && <p className="scr-note">{note}</p>}
      </div>
      <canvas ref={cnv} aria-hidden="true" />
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); приём «scratch card» — промо-классика, реализация свояv1.0.0