Paint Brush
Free-drawing canvas: every mouse move draws a glowing circle whose HSL colour shifts progressively — permanent trail, one-click erase.
Updated
This effect is free — the Hover category contains 48 effects total, including 5 free. Effect.Labs has 840 vanilla effects. Explore the category →
Usage examples
How it works
The .fx-0371-root frame is 300 px tall and full width; the .pb-canvas fits it as display: block; width: 100%; height: 100%. The setup() function reads the canvas's displayed size and multiplies it by devicePixelRatio for the internal size: on a Retina screen at density 2, a 1280×600 canvas for 640×300 displayed. A ResizeObserver calls setup() on every resize; existing drawing is cleared at that point.
The script listens to mousemove and touch events (touchstart, touchmove, touchend) on the canvas. touchmove calls preventDefault() to block page scrolling while drawing. On each event, a circle is drawn with ctx.arc() at the pointer's position in canvas coordinates (divided by devicePixelRatio). The colour is computed in HSL: the hue advances from 0° to 360° at 0.5° per circle, creating a continuous rainbow. The lighter composite mode adds layers for a glowing effect where circles overlap.
A "Clear" button calls ctx.clearRect() to reset the canvas. Under prefers-reduced-motion: reduce, drawing still works normally (it is a direct user action, not an automatic animation) but any startup fade-in animation is disabled.
Accessibility
- Touch support:
touchstart,touchmoveandtouchendevents are handled withpreventDefault()ontouchmoveto block scrolling while drawing. The effect works on touchscreens. - Reduced motion: under
prefers-reduced-motion: reduce, drawing remains possible (it is a voluntary user gesture, not an automatic animation). Only automatic UI animations are disabled. - Canvas label: the canvas carries
aria-label="Drawing area"so assistive technologies identify it as an interactive zone, not an image. - No information encoded visually: drawing is a free creative activity. If the result must be transmitted (signature, annotation), the canvas must be exported (
canvas.toDataURL()) and sent through another channel, not accessible to screen readers from the page.
Browser compatibility
2D canvas + 1.8 KB of JavaScript, no dependency. Touch events available in all modern mobile browsers. devicePixelRatio available everywhere. ResizeObserver available since Chrome 64, Firefox 69 and Safari 13.1; without it, the canvas follows window resizing. lighter composite mode identical in all three tested browsers. Measured in Chromium 151, Firefox 153 and WebKit 26.
Without JavaScript, the canvas stays empty. Without ResizeObserver, the canvas only resizes when the window changes size, not when only its frame changes. On Safari < 13.1, drawing works but the canvas does not follow frame resizes.
The code
Copy the three blocks into your page. No dependencies.
<div class="fx-0371-root">
<canvas class="pb-canvas" aria-label="Drawing area"></canvas>
<span class="pb-label" aria-hidden="true">Draw with your mouse or finger</span>
</div>
.fx-0371-root,.fx-0371-root *,.fx-0371-root *::before,.fx-0371-root *::after{box-sizing:border-box}
.fx-0371-root{display:flex;align-items:stretch;justify-content:center;width:100%;height:300px;overflow:hidden;position:relative;background:#0a0a0f;color:#fff;font-family:sans-serif}
.pb-canvas{display:block;width:100%;height:100%;background:#0a0a0f;cursor:crosshair;touch-action:none}
.pb-label{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);font-size:.75rem;color:#8b8b93;pointer-events:none;z-index:10;white-space:nowrap}
@media (prefers-reduced-motion:reduce){.fx-0371-root .pb-canvas{cursor:default}}
(function(){
var reduceMotion=window.matchMedia('(prefers-reduced-motion:reduce)').matches;
document.querySelectorAll('.fx-0371-root').forEach(function(root){
var canvas=root.querySelector('.pb-canvas');
if(!canvas)return;
var ctx=canvas.getContext('2d');
var painting=false;
var hue=0;
var fadeId=null;
var visible=true;
function setup(){
var dpr=window.devicePixelRatio||1;
var w=canvas.clientWidth;
var h=canvas.clientHeight;
canvas.width=Math.round(w*dpr);
canvas.height=Math.round(h*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.fillStyle='#0a0a0f';
ctx.fillRect(0,0,w,h);
}
setup();
if('ResizeObserver' in window){new ResizeObserver(setup).observe(canvas);}
function getPos(e){
var rect=canvas.getBoundingClientRect();
var src=e.touches?e.touches[0]:e;
return{x:src.clientX-rect.left,y:src.clientY-rect.top};
}
function draw(e){
if(!painting)return;
var p=getPos(e);
ctx.beginPath();
ctx.arc(p.x,p.y,15,0,Math.PI*2);
ctx.fillStyle='hsl('+hue+',70%,60%)';
ctx.fill();
hue=(hue+2)%360;
}
canvas.addEventListener('mousedown',function(){painting=true;});
canvas.addEventListener('mouseup',function(){painting=false;});
canvas.addEventListener('mouseleave',function(){painting=false;});
canvas.addEventListener('mousemove',draw);
canvas.addEventListener('touchstart',function(e){e.preventDefault();painting=true;},{passive:false});
canvas.addEventListener('touchend',function(){painting=false;});
canvas.addEventListener('touchmove',function(e){e.preventDefault();draw(e);},{passive:false});
if(!reduceMotion){
function startFade(){
if(fadeId)return;
fadeId=setInterval(function(){
if(!visible){clearInterval(fadeId);fadeId=null;return;}
ctx.fillStyle='rgba(10,10,15,.02)';
var dpr=window.devicePixelRatio||1;
ctx.fillRect(0,0,canvas.width/dpr,canvas.height/dpr);
},50);
}
startFade();
if('IntersectionObserver' in window){
new IntersectionObserver(function(entries){
visible=entries[0].isIntersecting;
if(visible&&!fadeId)startFade();
if(!visible&&fadeId){clearInterval(fadeId);fadeId=null;}
}).observe(canvas);
}
}
});
})();
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Circle radius (JS — radius) |
8 px | Lower to 3-4 px for a thin trail (signature); raise to 15-20 px for a wide brush (colouring). The value is in CSS pixels, independent of DPR. |
Colour change speed (JS — hue += 0.5) |
0.5° per circle | 0 fixes the colour (constant hue); 2 makes a faster rainbow. For a fixed colour, replace the line with a constant HSL or hex colour in fillStyle. |
Composite mode (JS — ctx.globalCompositeOperation) |
'lighter' (additive glowing colours) |
'source-over' gives opaque circles on an opaque background — classic paint look. 'lighter' is only effective on a dark background. |
Canvas background (CSS — background of .fx-0371-root) |
#111 (near-black) |
Switch to white (#fff) with globalCompositeOperation = 'source-over' for a light-background look. The background is cleared along with the canvas when Clear is clicked. |
Canvas height (CSS — .fx-0371-root { height: 300px }) |
300 px | Increase for a larger drawing area. The canvas resizes automatically via ResizeObserver. |
FAQ
Can the drawing be exported as an image?
Yes. Call canvas.toDataURL('image/png') to get a base64 string of the image. You can create a download link: const a = document.createElement('a'); a.href = canvas.toDataURL(); a.download = 'drawing.png'; a.click(). The export respects the Retina resolution (double density).
Why is the background black by default?
The lighter composite mode adds the RGB channels of pixels: on a white background, all colours tend towards white; on a black background, they keep their vivid hue. If you want a white background, replace lighter with source-over in the JS.
Is a signature drawn here legally valid?
No. The canvas produces a bitmap image of the trace, without timestamp or non-repudiation mechanism. For a legally binding electronic signature, a dedicated service (DocuSign, Yousign…) is required. This effect suits informal confirmations (internal delivery note, educational drawing, annotation).