Create Native Scratch Card (Angular)

Wissem Chiha
Untienots
Published in
6 min readAug 5, 2024

OBJECTIVE

The main objective is to simulate a Scratch Card in the browser with canvas using native javascript functions.
It’s ideal for creating engaging experiences like scratch-off cards for promotions, games, or revealing hidden messages.

WHY ?

Typically, developers needs to install a third party library like scratchcard-js to implement this behavior, While this approach works, it introduces three significant problems:

  • It can reduce your application performance specifically with Angular framework.
  • Customization options are limited.
  • You cannot align it with your angular project version

Advantages of Feature

  1. Intended to work on all major browsers (desktop & mobile).
  2. Does not include any other third-party libraries.
  3. Easy to customize (add some custom behaviors).
  4. Easy to implement.
  5. Providing reusable components.
  6. Minimize third-party libraries needs.
  7. Can work with any other framework, library such as React

Prerequisites

To get started, you need to install the following libraries:

  1. Node JS
  2. Angular CLI

Step 1: Project Setup

First of all, we need to create a new angular project:

ng new my-scratch-card
cd my-scratch-card
ng serve

Now we have our project installed, and served on the browser (http://localhost:4200).

Step 2: Implement Scratch Card Component

Let’s create the scratch card component

ng g c ScratchCard

Now, you have a new directory called scratch-card, inside this directory, you need to implement all files that our component needs.

scratch-card.models.ts

export interface ScratchCardConfig {
element: HTMLElement;
width: number;
height: number;
mask: string;
maskType: MaskType;
zoneRadius: number;
percentToFinish: number;
canvasClass: string;
callback: () => void;
}

export type MaskType = 'color' | 'image';

scratch-card.utils.ts

loadImage function is responsible to load canvas scratch image if the maskType equal to image

mousePosition delivers (x, y) mouse positions after handling the mouse events, we need it to scratch

getOffset delivers left and top offset of an element

export const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const image = new Image();
// Work only if the server response headers contains [Access-Control-Allow-Origin: *]
image.crossOrigin = 'Anonymous';
image.onload = () => {
resolve(image);
};
image.src = src;
image.onerror = () => {
const error = new Error(`Image <<${src}>> is not loaded.`);
reject(error);
};
});
};

export const mousePosition = (
event: TouchEvent,
element: HTMLElement
): number[] => {
const zone: { top: number; left: number } = getOffset(element);

const posX = event.touches[0].clientX - zone.left;
const posY = event.touches[0].clientY - zone.top;

return [posX, posY];
};

export const getOffset = (
element: HTMLElement
): {
left: number;
top: number;
} => {
const offset = {
left: 0,
top: 0,
};

const clientRect = element.getBoundingClientRect();

while (element) {
offset.top += element.offsetTop;
offset.left += element.offsetLeft;
element = <HTMLElement>element.offsetParent;
}

// Calculate the delta between offset values and clientRect values
const deltaLeft = offset.left - clientRect.left;
const deltaTop = offset.top - clientRect.top;

return {
left:
deltaLeft < 0
? offset.left + Math.abs(deltaLeft)
: offset.left - Math.abs(deltaLeft),
top:
deltaTop < 0
? offset.top + Math.abs(deltaTop)
: offset.top - Math.abs(deltaTop),
};
};

scratch-card.class.ts

This class contains all necessary methods that we need to construct, fill and create the canvas

init function launch all required events (mouse events, touch events)

generateCanvas function generate the canvas and append it to the right element

fillCanvas function : fill the canvas with color or image depends on the maskType

scratch function is responsible to scratch the canvas on the right x and y positions

updatePercent function is responsible to update the scratched area percent after every scratch made

clearCanvas function it clear all the canvas, technically it’s called when the scratch percent reach the percent sent on the config (property percentToFinish)

import { ScratchCardConfig } from './models';
import { loadImage, mousePosition } from './utils';

export class ScratchCard {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;

private document: Document;
private element: HTMLElement;
private config: ScratchCardConfig;

private readonly defaultConfig = {
width: 250,
height: 120,
zoneRadius: 24,
mask: '#ffce00',
maskType: 'color',
percentToFinish: 50,
canvasClass: 'canvas',
};

private isDragging = false;
private callBackHandled = false;

constructor(document: Document, cnf: ScratchCardConfig) {
this.config = {
...this.defaultConfig,
...cnf,
};

this.document = document;
this.element = cnf.element;

this.callBackHandled = false;

this.generateCanvas();

this.context = this.canvas.getContext('2d', {
willReadFrequently: true,
}) as CanvasRenderingContext2D;

this.fillCanvas();
}

init(): void {
this.addEventListeners();
}

private generateCanvas(): void {
// create canvas element
this.canvas = this.document.createElement('canvas');
this.canvas.classList.add(this.config.canvasClass);
this.canvas.width = this.config.width;
this.canvas.height = this.config.height;

// Add canvas into container
this.element.appendChild(this.canvas);
}

private fillCanvas(): void {
switch (this.config.maskType) {
case 'color':
this.context.fillStyle = this.config.mask;
this.context.fillRect(0, 0, this.config.width, this.config.height);
break;

case 'image':
loadImage(this.config.mask).then((src: HTMLImageElement) => {
this.context.drawImage(
src,
0,
0,
this.canvas.width,
this.canvas.height
);
});
break;

default:
console.error('mask type must be of type image or color');
break;
}
}

private addEventListeners(): void {
// Desktop Events
this.canvas.addEventListener('mousedown', (event) => {
this.isDragging = true;
this.scratch(event.offsetX, event.offsetY);
});

this.canvas.addEventListener('mousemove', (event) => {
if (this.isDragging) {
this.scratch(event.offsetX, event.offsetY);
}
});

this.canvas.addEventListener('mouseup', () => {
this.isDragging = false;
});

this.canvas.addEventListener('mouseleave', () => {
this.isDragging = false;
});

// Mobile Events
this.canvas.addEventListener('touchstart', (event) => {
this.isDragging = true;
const position = mousePosition(event, this.element);
this.scratch(position[0], position[1]);
});

this.canvas.addEventListener('touchmove', (event) => {
if (this.isDragging) {
const position = mousePosition(event, this.element);
this.scratch(position[0], position[1]);
}
});

this.canvas.addEventListener('touchend', () => {
this.isDragging = false;
});
}

private scratch(x: number, y: number): void {
this.context.globalCompositeOperation = 'destination-out';
this.context.beginPath();
this.context.arc(x, y, this.config.zoneRadius, 0, 2 * Math.PI);
this.context.fill();

// Get Scratch Percent
const percent = this.updatePercent();

if (percent >= this.config.percentToFinish && !this.callBackHandled) {
this.clearCanvas();
this.config.callback();
this.callBackHandled = true;
}
}

private updatePercent(): number {
let counter = 0; // number of pixels cleared
const imageData = this.context.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
);

// loop data image drop every 4 items [r, g, b, a, ...]
// Red: image.data[0]
// Green: image.data[1]
// Blue: image.data[2]
// Alpha: image.data[3]
for (let i = 0; i < imageData.data.length; i += 4) {
// Increment the counter only if the pixel is completely cleared
if (
imageData.data[i] === 0 &&
imageData.data[i + 1] === 0 &&
imageData.data[i + 2] === 0 &&
imageData.data[i + 3] === 0
) {
counter++;
}
}

return counter >= 1
? (counter / (this.canvas.width * this.canvas.height)) * 100
: 0;
}

private clearCanvas(): void {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}

scratch-card.component.html

<div class="scratch-container">
<div class="reward">You WON</div>
<div
#card
class="scratch-card"
[ngStyle]="{ display: displayCanvas ? 'block' : 'none' }"></div>
</div>

scratch-card.component.scss

.scratch-container {
width: 250px;
height: 120px;
overflow: hidden;
position: relative;
border-radius: 10px;
border: 2px solid black;
display: flex;
align-items: center;
justify-content: center;

.scratch-card {
top: 0;
left: 0;
right: 0;
bottom: 0;
cursor: grabbing;
position: absolute;
}
}

scratch-card.component.ts

import { DOCUMENT, NgStyle } from '@angular/common';
import {
OnInit,
inject,
NgZone,
Component,
ViewChild,
ElementRef,
} from '@angular/core';
import { ScratchCard } from './scratch-card.class';
import { ScratchCardConfig } from './scratch-card.models';

@Component({
standalone: true,
imports: [NgStyle],
selector: 'app-scratch-card',
styleUrl: './scratch-card.component.scss',
templateUrl: './scratch-card.component.html',
})
export class ScratchCardComponent implements OnInit {
@ViewChild('card', { static: true }) card: ElementRef<HTMLDivElement>;

public displayCanvas: boolean = true;

private readonly ngZone: NgZone = inject(NgZone);
private readonly document: Document = inject(DOCUMENT);

ngOnInit(): void {
const scConfig: ScratchCardConfig = {
width: 250,
height: 120,
zoneRadius: 24,
percentToFinish: 75,
canvasClass: 'canvas',

element: this.card.nativeElement,

mask: '#FFCD00',
maskType: 'color',

callback: () => {
// do some stuff here after finish scratching
},
};

const scratchCard = new ScratchCard(this.document, scConfig);
this.ngZone.runOutsideAngular(() => scratchCard.init());
}
}

Step 3: Integrate Scratch Card Component

app.component.ts

import { Component } from '@angular/core';
import { ScratchCardComponent } from './scratch-card/scratch-card.component';

@Component({
standalone: true,
selector: 'app-root',
template: `
<app-scratch-card />
`,
styles: `:host {
display: flex;
min-height: 100vh;
align-items:center;
justify-content: center;
}`,
imports: [ScratchCardComponent],
})
export class App {}

The solution is available at

https://stackblitz.com/edit/native-scratch-card

--

--