Scratch Card
Скретч-карта: серебристый слой стирается указателем как монеткой (canvas + destination-out), доля стёртого считается по сетке альфа-канала, после порога слой уходит целиком. Под reduced-motion и в скриншотах карта отдаётся уже стёртой — контент не прячем.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| prize? | string | "−30 % на всё" | Что под слоем: главная строка. |
| note? | string | "промокод SCRATCH · до воскресенья" | Строка под призом. |
| cover? | string | "сотрите монеткой" | Подпись на самом слое. |
| brush? | number | 26 | Радиус «монетки», px. |
| threshold? | number | 0.45 | Доля стёртого, после которой слой уходит сам (0—1). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/scratch-card.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.
Экспорт
Код
"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>
);
}