27 / 36
All 36 in 36 Tailwind Stacked Cards
- 01Tailwind Scroll-Driven Scaling Card Stack
- 02Tailwind Swipe Card Deck Drag to Dismiss
- 03Tailwind Glassmorphism Testimonial Card Stack
- 04Tailwind Stacked Cards Expand Accordion on Click
- 05Tailwind Fan Out Photo Stack on Hover
- 06Tailwind 3D Perspective Tilt Card Stack
- 07Tailwind Multi Step Form Card Stack
- 08Tailwind Tabbed Card Stack Bring to Front
- 09Tailwind Click to Front Stacked Cards Cycle
- 10Tailwind Classic Card Deck Stack Shadow
- 11Tailwind Fan Spread Hand of Cards
- 12Tailwind Polaroid Photo Stack Scattered Rotate
- 13Tailwind Staircase Offset Stacked Cards Cascade
- 14Tailwind Cards Spread Out Horizontally on Hover
- 15Tailwind Stacked Cards Lift on Hover Sibling Push
- 16Tailwind Accordion Stacked Cards Slide Out Menu
- 17Tailwind Shuffle Cards Animation Loop Deck
- 18Tailwind File Folder Tab Stacked Cards
- 19Tailwind Scattered Photos Pinch to Centre on Hover
- 20Tailwind Mobile Swipe Card Stack Translate3d
- 21Tailwind Single Accordion Card Nested Layers
- 22Tailwind Isometric Tower Stacked Cards 3D Column
- 23Tailwind 3D Flip Card Stack Reveal Back
- 24Tailwind Card Stack Tilt on Hover Quadrant
- 25Tailwind Notification Stack Pile Dismiss
- 26Tailwind Receipt Roll Unroll Stacked Items
- 27Tailwind Scratch Card Reveal Canvas Mask
- 28Tailwind Stacked Cards Hover Reveal Spread
- 29Tailwind Scroll Activated Sticky Stacking Cards
- 30Tailwind Stacked Testimonial Slider Radio
- 31Tailwind Isometric Overlapping Stacked Cards
- 32Tailwind Bento Grid Stacked Cards Hover Fan
- 33Tailwind Perspective Card Deck Depth Tunnel
- 34Tailwind Peel Back Corner Card Reveal
- 35Tailwind Timeline Stacked Milestone Cards
- 36Tailwind Spiral Tower Rotating Stacked Cards
Tailwind Scratch Card Reveal Canvas Mask
A foil coating you scratch away with a finger. The interesting boundary here is that Tailwind styles the frame and the canvas owns the pixels: the coating, the brush and the erase are Canvas 2D with destination-out, while the layout, the prize card, the crosshair cursor and the data-[done=true]:opacity-0 fade are all utilities. Knowing which side of that line a problem is on is most of the work.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="stk-27 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk27-sans bg-stk27-bg" aria-label="Scratch card">
<div class="relative w-75">
<div class="relative flex h-45 flex-col items-center justify-center gap-1.5 rounded-[18px] bg-linear-[150deg,var(--color-stk27-p1),var(--color-stk27-p2)] text-stk27-ink shadow-[0_20px_44px_-22px_rgba(0,0,0,0.7)]">
<p class="m-0 font-stk27-mono text-xs font-medium tracking-[0.16em] uppercase opacity-80">You won</p>
<b class="text-[34px] font-black tracking-[0.05em]">LUCKY-40</b>
<p class="m-0 text-[13px] opacity-85">40% off your next pack</p>
</div>
<canvas class="absolute left-0 top-0 w-75 h-45 rounded-[18px] cursor-crosshair [touch-action:none] transition-opacity duration-500 data-[done=true]:opacity-0 data-[done=true]:pointer-events-none motion-reduce:duration-200" id="stk-27-canvas" width="300" height="180" role="img" aria-label="Scratch-off coating hiding a promo code. Use the Reveal button if you prefer not to scratch."></canvas>
<div class="mt-4 flex gap-2.5">
<button class="flex-1 rounded-[11px] border border-stk27-line bg-stk27-btn px-4 py-2.5 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk27-btnh focus-visible:outline-3 focus-visible:outline-stk27-ring focus-visible:outline-offset-2" id="stk-27-reveal" type="button">Reveal</button>
<button class="flex-1 rounded-[11px] border border-stk27-line bg-stk27-btn px-4 py-2.5 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk27-btnh focus-visible:outline-3 focus-visible:outline-stk27-ring focus-visible:outline-offset-2" id="stk-27-reset" type="button">Reset</button>
</div>
</div>
</section><section class="stk-27 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk27-sans bg-stk27-bg" aria-label="Scratch card">
<div class="relative w-75">
<div class="relative flex h-45 flex-col items-center justify-center gap-1.5 rounded-[18px] bg-linear-[150deg,var(--color-stk27-p1),var(--color-stk27-p2)] text-stk27-ink shadow-[0_20px_44px_-22px_rgba(0,0,0,0.7)]">
<p class="m-0 font-stk27-mono text-xs font-medium tracking-[0.16em] uppercase opacity-80">You won</p>
<b class="text-[34px] font-black tracking-[0.05em]">LUCKY-40</b>
<p class="m-0 text-[13px] opacity-85">40% off your next pack</p>
</div>
<canvas class="absolute left-0 top-0 w-75 h-45 rounded-[18px] cursor-crosshair [touch-action:none] transition-opacity duration-500 data-[done=true]:opacity-0 data-[done=true]:pointer-events-none motion-reduce:duration-200" id="stk-27-canvas" width="300" height="180" role="img" aria-label="Scratch-off coating hiding a promo code. Use the Reveal button if you prefer not to scratch."></canvas>
<div class="mt-4 flex gap-2.5">
<button class="flex-1 rounded-[11px] border border-stk27-line bg-stk27-btn px-4 py-2.5 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk27-btnh focus-visible:outline-3 focus-visible:outline-stk27-ring focus-visible:outline-offset-2" id="stk-27-reveal" type="button">Reveal</button>
<button class="flex-1 rounded-[11px] border border-stk27-line bg-stk27-btn px-4 py-2.5 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk27-btnh focus-visible:outline-3 focus-visible:outline-stk27-ring focus-visible:outline-offset-2" id="stk-27-reset" type="button">Reset</button>
</div>
</div>
</section>@import "tailwindcss";
@theme {
--color-stk27-bg: #141019;
--color-stk27-bg: oklch(0.19 0.020 300);
--color-stk27-p1: #e8b53f;
--color-stk27-p1: oklch(0.80 0.15 90);
--color-stk27-p2: #e08a3c;
--color-stk27-p2: oklch(0.70 0.16 55);
--color-stk27-ink: #3a1f00;
--color-stk27-ink: oklch(0.30 0.070 65);
--color-stk27-btn: #20232f;
--color-stk27-btn: oklch(0.26 0.016 275);
--color-stk27-btnh: #2b2f3e;
--color-stk27-btnh: oklch(0.32 0.018 275);
--color-stk27-line: #333a4e;
--color-stk27-line: oklch(0.36 0.020 275);
--color-stk27-ring: #e8a83f;
--color-stk27-ring: oklch(0.76 0.15 70);
--font-stk27-sans: 'Archivo', system-ui, sans-serif;
--font-stk27-mono: 'JetBrains Mono', ui-monospace, monospace;
}@import "tailwindcss";
@theme {
--color-stk27-bg: #141019;
--color-stk27-bg: oklch(0.19 0.020 300);
--color-stk27-p1: #e8b53f;
--color-stk27-p1: oklch(0.80 0.15 90);
--color-stk27-p2: #e08a3c;
--color-stk27-p2: oklch(0.70 0.16 55);
--color-stk27-ink: #3a1f00;
--color-stk27-ink: oklch(0.30 0.070 65);
--color-stk27-btn: #20232f;
--color-stk27-btn: oklch(0.26 0.016 275);
--color-stk27-btnh: #2b2f3e;
--color-stk27-btnh: oklch(0.32 0.018 275);
--color-stk27-line: #333a4e;
--color-stk27-line: oklch(0.36 0.020 275);
--color-stk27-ring: #e8a83f;
--color-stk27-ring: oklch(0.76 0.15 70);
--font-stk27-sans: 'Archivo', system-ui, sans-serif;
--font-stk27-mono: 'JetBrains Mono', ui-monospace, monospace;
}(() => {
const root = document.querySelector('.stk-27');
if (!root) return;
const canvas = root.querySelector('#stk-27-canvas');
const ctx = canvas.getContext('2d');
const W = 300, H = 180;
/* Sized to devicePixelRatio and laid out at CSS size by w-75 h-45 — without
this the scratched edge is visibly soft on any retina display. */
function coat() {
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
const g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, '#9aa0b4');
g.addColorStop(1, '#6b7186');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.font = 'bold 15px Archivo, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCRATCH HERE', W / 2, H / 2 + 5);
delete canvas.dataset.done;
}
/* destination-out erases pixels. Painting a colour over the foil looks
similar and breaks the moment the prize behind it changes. */
function scratch(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 18, 0, Math.PI * 2);
ctx.fill();
}
const pos = (e) => {
const r = canvas.getBoundingClientRect();
return [(e.clientX - r.left) * (W / r.width), (e.clientY - r.top) * (H / r.height)];
};
/* Sampled, not scanned: every 40th pixel is plenty to decide "half gone". */
function cleared() {
const d = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let clear = 0, seen = 0;
for (let i = 3; i < d.length; i += 4 * 40) { seen++; if (d[i] === 0) clear++; }
return seen ? clear / seen : 0;
}
let drawing = false;
canvas.addEventListener('pointerdown', (e) => {
drawing = true;
canvas.setPointerCapture(e.pointerId);
scratch(...pos(e));
});
canvas.addEventListener('pointermove', (e) => { if (drawing) scratch(...pos(e)); });
canvas.addEventListener('pointerup', () => {
drawing = false;
if (cleared() > 0.5) reveal();
});
const reveal = () => { canvas.dataset.done = 'true'; };
root.querySelector('#stk-27-reveal').addEventListener('click', reveal);
root.querySelector('#stk-27-reset').addEventListener('click', coat);
coat();
})();(() => {
const root = document.querySelector('.stk-27');
if (!root) return;
const canvas = root.querySelector('#stk-27-canvas');
const ctx = canvas.getContext('2d');
const W = 300, H = 180;
/* Sized to devicePixelRatio and laid out at CSS size by w-75 h-45 — without
this the scratched edge is visibly soft on any retina display. */
function coat() {
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
const g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, '#9aa0b4');
g.addColorStop(1, '#6b7186');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.font = 'bold 15px Archivo, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCRATCH HERE', W / 2, H / 2 + 5);
delete canvas.dataset.done;
}
/* destination-out erases pixels. Painting a colour over the foil looks
similar and breaks the moment the prize behind it changes. */
function scratch(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 18, 0, Math.PI * 2);
ctx.fill();
}
const pos = (e) => {
const r = canvas.getBoundingClientRect();
return [(e.clientX - r.left) * (W / r.width), (e.clientY - r.top) * (H / r.height)];
};
/* Sampled, not scanned: every 40th pixel is plenty to decide "half gone". */
function cleared() {
const d = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let clear = 0, seen = 0;
for (let i = 3; i < d.length; i += 4 * 40) { seen++; if (d[i] === 0) clear++; }
return seen ? clear / seen : 0;
}
let drawing = false;
canvas.addEventListener('pointerdown', (e) => {
drawing = true;
canvas.setPointerCapture(e.pointerId);
scratch(...pos(e));
});
canvas.addEventListener('pointermove', (e) => { if (drawing) scratch(...pos(e)); });
canvas.addEventListener('pointerup', () => {
drawing = false;
if (cleared() > 0.5) reveal();
});
const reveal = () => { canvas.dataset.done = 'true'; };
root.querySelector('#stk-27-reveal').addEventListener('click', reveal);
root.querySelector('#stk-27-reset').addEventListener('click', coat);
coat();
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-stk27-bg: #141019;
--color-stk27-bg: oklch(0.19 0.020 300);
--color-stk27-p1: #e8b53f;
--color-stk27-p1: oklch(0.80 0.15 90);
--color-stk27-p2: #e08a3c;
--color-stk27-p2: oklch(0.70 0.16 55);
--color-stk27-ink: #3a1f00;
--color-stk27-ink: oklch(0.30 0.070 65);
--color-stk27-btn: #20232f;
--color-stk27-btn: oklch(0.26 0.016 275);
--color-stk27-btnh: #2b2f3e;
--color-stk27-btnh: oklch(0.32 0.018 275);
--color-stk27-line: #333a4e;
--color-stk27-line: oklch(0.36 0.020 275);
--color-stk27-ring: #e8a83f;
--color-stk27-ring: oklch(0.76 0.15 70);
--font-stk27-sans: 'Archivo', system-ui, sans-serif;
--font-stk27-mono: 'JetBrains Mono', ui-monospace, monospace;
}@import "tailwindcss";
@theme {
--color-stk27-bg: #141019;
--color-stk27-bg: oklch(0.19 0.020 300);
--color-stk27-p1: #e8b53f;
--color-stk27-p1: oklch(0.80 0.15 90);
--color-stk27-p2: #e08a3c;
--color-stk27-p2: oklch(0.70 0.16 55);
--color-stk27-ink: #3a1f00;
--color-stk27-ink: oklch(0.30 0.070 65);
--color-stk27-btn: #20232f;
--color-stk27-btn: oklch(0.26 0.016 275);
--color-stk27-btnh: #2b2f3e;
--color-stk27-btnh: oklch(0.32 0.018 275);
--color-stk27-line: #333a4e;
--color-stk27-line: oklch(0.36 0.020 275);
--color-stk27-ring: #e8a83f;
--color-stk27-ring: oklch(0.76 0.15 70);
--font-stk27-sans: 'Archivo', system-ui, sans-serif;
--font-stk27-mono: 'JetBrains Mono', ui-monospace, monospace;
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@75..125,400;75..125,700;75..125,900&family=JetBrains+Mono:wght@400;500&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@75..125,400;75..125,700;75..125,900&family=JetBrains+Mono:wght@400;500&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The webfonts load as
<link>tags insetup, not as an@importinside the stylesheet. Tailwind emits user@importrules after its own@layerblocks, and a late@importis invalid CSS — the browser drops it and the demo silently falls back to system fonts. - The port draws a clean seam: utilities own the frame, the prize, the cursor and the fade; Canvas 2D owns the coating and the erase. Neither side can do the other's job.
- The reveal state is
data-[done=true]:opacity-0rather than a class, so the finished appearance is described in the markup and the script only sets an attribute. [touch-action:none]andcursor-crosshairsit next to each other on the canvas, which is where anyone debugging the gesture will look.ctx.setTransform(dpr,0,0,dpr,0,0)replaces the CSS version's repeatedctx.scale()— safe to call again on reset, where a secondscale()would compound.- The threshold check samples every fortieth pixel instead of scanning the whole buffer, which keeps
pointerupoff the critical path. - The promo code is real text under the canvas from the first paint, so it is announced, selectable and copyable whether or not anyone scratches.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Scratch Card Reveal Canvas Mask
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-scratch-card-reveal-canvas-mask/
A foil coating you scratch away with a finger. The interesting boundary here is that Tailwind styles the frame and the canvas owns the pixels: the coating, the brush and the erase are Canvas 2D with destination-out, while the layout, the prize card, the crosshair cursor and the data-[done=true]:opacity-0 fade are all utilities. Knowing which side of that line a problem is on is most of the work.
## HTML
```html
<section class="stk-27 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk27-sans bg-stk27-bg" aria-label="Scratch card">
<div class="relative w-75">
<div class="relative flex h-45 flex-col items-center justify-center gap-1.5 rounded-[18px] bg-linear-[150deg,var(--color-stk27-p1),var(--color-stk27-p2)] text-stk27-ink shadow-[0_20px_44px_-22px_rgba(0,0,0,0.7)]">
<p class="m-0 font-stk27-mono text-xs font-medium tracking-[0.16em] uppercase opacity-80">You won</p>
<b class="text-[34px] font-black tracking-[0.05em]">LUCKY-40</b>
<p class="m-0 text-[13px] opacity-85">40% off your next pack</p>
</div>
<canvas class="absolute left-0 top-0 w-75 h-45 rounded-[18px] cursor-crosshair [touch-action:none] transition-opacity duration-500 data-[done=true]:opacity-0 data-[done=true]:pointer-events-none motion-reduce:duration-200" id="stk-27-canvas" width="300" height="180" role="img" aria-label="Scratch-off coating hiding a promo code. Use the Reveal button if you prefer not to scratch."></canvas>
<div class="mt-4 flex gap-2.5">
<button class="flex-1 rounded-[11px] border border-stk27-line bg-stk27-btn px-4 py-2.5 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk27-btnh focus-visible:outline-3 focus-visible:outline-stk27-ring focus-visible:outline-offset-2" id="stk-27-reveal" type="button">Reveal</button>
<button class="flex-1 rounded-[11px] border border-stk27-line bg-stk27-btn px-4 py-2.5 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk27-btnh focus-visible:outline-3 focus-visible:outline-stk27-ring focus-visible:outline-offset-2" id="stk-27-reset" type="button">Reset</button>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-stk27-bg: #141019;
--color-stk27-bg: oklch(0.19 0.020 300);
--color-stk27-p1: #e8b53f;
--color-stk27-p1: oklch(0.80 0.15 90);
--color-stk27-p2: #e08a3c;
--color-stk27-p2: oklch(0.70 0.16 55);
--color-stk27-ink: #3a1f00;
--color-stk27-ink: oklch(0.30 0.070 65);
--color-stk27-btn: #20232f;
--color-stk27-btn: oklch(0.26 0.016 275);
--color-stk27-btnh: #2b2f3e;
--color-stk27-btnh: oklch(0.32 0.018 275);
--color-stk27-line: #333a4e;
--color-stk27-line: oklch(0.36 0.020 275);
--color-stk27-ring: #e8a83f;
--color-stk27-ring: oklch(0.76 0.15 70);
--font-stk27-sans: 'Archivo', system-ui, sans-serif;
--font-stk27-mono: 'JetBrains Mono', ui-monospace, monospace;
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.stk-27');
if (!root) return;
const canvas = root.querySelector('#stk-27-canvas');
const ctx = canvas.getContext('2d');
const W = 300, H = 180;
/* Sized to devicePixelRatio and laid out at CSS size by w-75 h-45 — without
this the scratched edge is visibly soft on any retina display. */
function coat() {
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
const g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, '#9aa0b4');
g.addColorStop(1, '#6b7186');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.font = 'bold 15px Archivo, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCRATCH HERE', W / 2, H / 2 + 5);
delete canvas.dataset.done;
}
/* destination-out erases pixels. Painting a colour over the foil looks
similar and breaks the moment the prize behind it changes. */
function scratch(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 18, 0, Math.PI * 2);
ctx.fill();
}
const pos = (e) => {
const r = canvas.getBoundingClientRect();
return [(e.clientX - r.left) * (W / r.width), (e.clientY - r.top) * (H / r.height)];
};
/* Sampled, not scanned: every 40th pixel is plenty to decide "half gone". */
function cleared() {
const d = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let clear = 0, seen = 0;
for (let i = 3; i < d.length; i += 4 * 40) { seen++; if (d[i] === 0) clear++; }
return seen ? clear / seen : 0;
}
let drawing = false;
canvas.addEventListener('pointerdown', (e) => {
drawing = true;
canvas.setPointerCapture(e.pointerId);
scratch(...pos(e));
});
canvas.addEventListener('pointermove', (e) => { if (drawing) scratch(...pos(e)); });
canvas.addEventListener('pointerup', () => {
drawing = false;
if (cleared() > 0.5) reveal();
});
const reveal = () => { canvas.dataset.done = 'true'; };
root.querySelector('#stk-27-reveal').addEventListener('click', reveal);
root.querySelector('#stk-27-reset').addEventListener('click', coat);
coat();
})();
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Scratch Card Reveal Canvas Mask
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-scratch-card-reveal-canvas-mask/
A foil coating you scratch away with a finger. The interesting boundary here is that Tailwind styles the frame and the canvas owns the pixels: the coating, the brush and the erase are Canvas 2D with destination-out, while the layout, the prize card, the crosshair cursor and the data-[done=true]:opacity-0 fade are all utilities. Knowing which side of that line a problem is on is most of the work.
## HTML
```html
<section class="stk-27 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk27-sans bg-stk27-bg" aria-label="Scratch card">
<div class="relative w-75">
<div class="relative flex h-45 flex-col items-center justify-center gap-1.5 rounded-[18px] bg-linear-[150deg,var(--color-stk27-p1),var(--color-stk27-p2)] text-stk27-ink shadow-[0_20px_44px_-22px_rgba(0,0,0,0.7)]">
<p class="m-0 font-stk27-mono text-xs font-medium tracking-[0.16em] uppercase opacity-80">You won</p>
<b class="text-[34px] font-black tracking-[0.05em]">LUCKY-40</b>
<p class="m-0 text-[13px] opacity-85">40% off your next pack</p>
</div>
<canvas class="absolute left-0 top-0 w-75 h-45 rounded-[18px] cursor-crosshair [touch-action:none] transition-opacity duration-500 data-[done=true]:opacity-0 data-[done=true]:pointer-events-none motion-reduce:duration-200" id="stk-27-canvas" width="300" height="180" role="img" aria-label="Scratch-off coating hiding a promo code. Use the Reveal button if you prefer not to scratch."></canvas>
<div class="mt-4 flex gap-2.5">
<button class="flex-1 rounded-[11px] border border-stk27-line bg-stk27-btn px-4 py-2.5 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk27-btnh focus-visible:outline-3 focus-visible:outline-stk27-ring focus-visible:outline-offset-2" id="stk-27-reveal" type="button">Reveal</button>
<button class="flex-1 rounded-[11px] border border-stk27-line bg-stk27-btn px-4 py-2.5 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk27-btnh focus-visible:outline-3 focus-visible:outline-stk27-ring focus-visible:outline-offset-2" id="stk-27-reset" type="button">Reset</button>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-stk27-bg: #141019;
--color-stk27-bg: oklch(0.19 0.020 300);
--color-stk27-p1: #e8b53f;
--color-stk27-p1: oklch(0.80 0.15 90);
--color-stk27-p2: #e08a3c;
--color-stk27-p2: oklch(0.70 0.16 55);
--color-stk27-ink: #3a1f00;
--color-stk27-ink: oklch(0.30 0.070 65);
--color-stk27-btn: #20232f;
--color-stk27-btn: oklch(0.26 0.016 275);
--color-stk27-btnh: #2b2f3e;
--color-stk27-btnh: oklch(0.32 0.018 275);
--color-stk27-line: #333a4e;
--color-stk27-line: oklch(0.36 0.020 275);
--color-stk27-ring: #e8a83f;
--color-stk27-ring: oklch(0.76 0.15 70);
--font-stk27-sans: 'Archivo', system-ui, sans-serif;
--font-stk27-mono: 'JetBrains Mono', ui-monospace, monospace;
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.stk-27');
if (!root) return;
const canvas = root.querySelector('#stk-27-canvas');
const ctx = canvas.getContext('2d');
const W = 300, H = 180;
/* Sized to devicePixelRatio and laid out at CSS size by w-75 h-45 — without
this the scratched edge is visibly soft on any retina display. */
function coat() {
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
const g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, '#9aa0b4');
g.addColorStop(1, '#6b7186');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.font = 'bold 15px Archivo, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('SCRATCH HERE', W / 2, H / 2 + 5);
delete canvas.dataset.done;
}
/* destination-out erases pixels. Painting a colour over the foil looks
similar and breaks the moment the prize behind it changes. */
function scratch(x, y) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 18, 0, Math.PI * 2);
ctx.fill();
}
const pos = (e) => {
const r = canvas.getBoundingClientRect();
return [(e.clientX - r.left) * (W / r.width), (e.clientY - r.top) * (H / r.height)];
};
/* Sampled, not scanned: every 40th pixel is plenty to decide "half gone". */
function cleared() {
const d = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let clear = 0, seen = 0;
for (let i = 3; i < d.length; i += 4 * 40) { seen++; if (d[i] === 0) clear++; }
return seen ? clear / seen : 0;
}
let drawing = false;
canvas.addEventListener('pointerdown', (e) => {
drawing = true;
canvas.setPointerCapture(e.pointerId);
scratch(...pos(e));
});
canvas.addEventListener('pointermove', (e) => { if (drawing) scratch(...pos(e)); });
canvas.addEventListener('pointerup', () => {
drawing = false;
if (cleared() > 0.5) reveal();
});
const reveal = () => { canvas.dataset.done = 'true'; };
root.querySelector('#stk-27-reveal').addEventListener('click', reveal);
root.querySelector('#stk-27-reset').addEventListener('click', coat);
coat();
})();
```How this works
Two layers, two technologies, one clean seam. The prize is ordinary markup styled with utilities. Over it sits a <canvas> painted with the foil. Dragging erases pixels:
ctx.globalCompositeOperation = 'destination-out';
ctx.arc(x, y, 18, 0, Math.PI * 2);
ctx.fill();Nothing about the erase can be a utility, and nothing about the frame needs to be canvas. Where the CSS version blurs the two, the utility version makes the seam obvious: if the problem is fuzzy edges it is canvas, if it is a rounded corner it is a class.
Device pixel ratio is not optional. The canvas is sized to W * dpr and scaled by dpr, then laid out at CSS size by w-75 h-45. Skip it and the scratch edge is soft on every phone and every retina display — the single most common canvas bug.
The gesture must not be the only way in. A drag is unavailable to keyboard users, hard for many motor-impaired users, and invisible to screen readers. Reveal clears the coating instantly; Reset repaints it; the code underneath is real text the whole time, so the content was never gated behind the gesture at all.
The threshold check samples, not scans. Reading every pixel's alpha on each pointerup is wasteful; the check steps through every fortieth pixel, which is plenty to decide whether half the foil is gone.
Make it yours
- Change the coating by editing the gradient and label in
coat(). - Set the auto-reveal threshold —
0.5means half the foil. - Adjust the brush with the
18radius inscratch(). - Swap the prize content freely; it is ordinary markup under the canvas.
- Recolour the prize through the two
bg-linear-[...]stops and--color-stk27-*.
Gotchas — read before shipping
- Scale the canvas by
devicePixelRatioand lay it out at CSS size, or the scratched edge is blurry on every modern display. - Use
destination-outto erase. Painting the background colour over the foil looks similar and breaks the moment the prize behind it changes. [touch-action:none]is required, or the page scrolls instead of the foil scratching.- Always provide a non-gesture reveal. Drag-only is an accessibility failure, not a stylistic choice.
- Sample the cleared area rather than scanning every pixel; a full
getImageDatascan perpointerupis measurable. - Reset must re-run the full coat routine including the
dprscale — resizing a canvas clears its transform.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Canvas 2D and Pointer Events are universal (Chrome 55+, Safari 13+, Firefox 59+), and the non-gesture Reveal path means the demo works even where pointer capture misbehaves.