webdevpuneet.com

Rough.js Sketchy Annotation Highlight — Canvas-Over-Text Snippet

Rough.js Sketchy Annotation Highlight · Animations · Plain HTML, CSS & JS · Live preview

CategoryAnimations

Copy & paste

Source Code

Requires
<div class="rah-stage">
  <div class="rah-toolbar">
    <span class="rah-tag">Rough.js · canvas overlay</span>
    <button class="rah-btn is-on" id="rahToggle">✓ Highlights on</button>
  </div>
  <article class="rah-article">
    <div class="rah-canvas-wrap">
      <h2 class="rah-heading" data-annotate="circle">Why this matters</h2>
      <canvas id="rahCanvas"></canvas>
      <p class="rah-body">
        Real text always sits on top — the canvas overlay never rasterizes a single letter. Instead, two annotations are drawn
        <span class="rah-mark" data-annotate="highlight">directly behind this exact phrase</span>
        using each element's own <code>getBoundingClientRect()</code>, so the sketchy marks track the live layout of selectable, screen-reader-friendly text rather than a picture of it.
      </p>
      <p class="rah-body">
        Toggle the highlights off and the yellow hachure fill and the circled heading both disappear instantly — because they're redrawn on a transparent canvas positioned absolutely over the real content, not baked into it.
      </p>
    </div>
  </article>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Never rasterizes text
Text stays real, selectable, and screen-reader-accessible; only decorative marks are drawn on canvas.
z-index layered overlay
An absolutely-positioned transparent canvas sits behind text via a lower z-index, not on top of it.
getBoundingClientRect positioning
Annotation coordinates are computed live from each target element's actual rendered position.
Viewport-to-wrapper coordinate conversion
Rect offsets are subtracted so canvas-relative and viewport-relative coordinates line up correctly.
Pointer-events passthrough
pointer-events: none on the canvas lets clicks and text selection reach the real content underneath.
Resize-safe redraw
A resize listener re-measures and redraws so reflowed text never leaves a stale, misaligned mark.
Hachure highlighter fill
The highlight uses a yellow hachure fill with no stroke, mimicking a real highlighter pen.
Ellipse-based circling
A heading annotation uses rc.ellipse() sized larger than the text to read as "circled," not outlined.

About this UI Snippet

Rough.js Sketchy Annotation Highlight — Sketching Over Real Text, Never Rasterizing It

Screenshot of the Rough.js Sketchy Annotation Highlight snippet rendered live

A common but wrong way to build a "hand-marked-up document" look is to render the whole page to an image and draw on top of that image — which immediately kills text selection, screen readers, find-in-page, and SEO. This snippet does the opposite: the text stays completely real, semantic HTML, and a transparent <canvas> is layered on top purely for decoration, never touching the text itself.

The layering: canvas behind, text in front

css #rahCanvas { position: absolute; top:0; left:0; width:100%; height:100%; z-index:1; pointer-events:none; } .rah-heading, .rah-body { position: relative; z-index: 2; }

The canvas is absolutely positioned to fill its wrapper and sits at z-index: 1. The real heading and paragraph text are given position: relative (so z-index applies) and z-index: 2, placing them visually *above* the canvas. Rough.js draws a solid yellow hachure fill and a red circle onto the canvas, but because the text sits on a higher stacking layer, it renders on top of those marks rather than being covered by them — exactly like a real highlighter marked on paper *underneath* a sheet of already-printed text would show through. pointer-events: none on the canvas ensures clicks, text selection, and cursor interaction all pass straight through to the real text beneath it; the canvas is purely visual.

Finding where to draw: getBoundingClientRect()

Rough.js has no idea where your text is — it only knows pixel coordinates on the canvas. To sketch a highlight *behind a specific phrase*, this snippet reads that phrase's real, current rendered position:

js var markRect = mark.getBoundingClientRect(); rc.rectangle(markRect.left - wrapRect.left - 3, markRect.top - wrapRect.top - 2, markRect.width + 6, markRect.height + 4, {...});

getBoundingClientRect() returns the element's position relative to the *viewport*, not the canvas. Since the canvas is positioned relative to .rah-canvas-wrap, every coordinate has to be converted into that same coordinate space by subtracting the wrapper's own getBoundingClientRect() offset — markRect.left - wrapRect.left. Skip that subtraction and the highlight would be drawn using absolute viewport coordinates, landing wildly off-position anywhere the wrapper isn't flush with the page's top-left corner. The small -3/+6 padding adjustments pad the sketch slightly larger than the exact text box, which is what makes a highlighter mark look drawn *around* text rather than clipped exactly to its font metrics.

The circle uses an ellipse, not a literal circle primitive

rc.ellipse(cx, cy, width, height, {...}) draws a sketched oval centered at (cx, cy) — Rough.js has no separate "circle" primitive; a circle is just an ellipse with equal width and height. Sizing it larger than the heading's own bounding box (+16/+12 padding) is what makes the annotation read as "circling" the heading rather than tracing its exact outline.

Why it redraws on toggle and resize, not just once

Both the enabled/disabled toggle and the window resize listener call the same drawAnnotations() function, which re-measures every element's getBoundingClientRect() from scratch before drawing. This matters because text can reflow — a resize could wrap the paragraph differently, moving the highlighted phrase to a new line entirely — and a canvas mark drawn once at page-load coordinates would silently drift out of alignment with the text it's supposed to be marking. Re-measuring on every redraw keeps the sketch locked to wherever the text actually is right now, not where it was when the page first loaded.

Reusing it

Add more data-annotate phrases and loop over document.querySelectorAll('[data-annotate="highlight"]') instead of hardcoding a single .rah-mark lookup, or add an underline annotation type using rc.line() beneath a word instead of a full rectangle. Pair this with the hand-drawn bar chart snippet to see the same rough.canvas() setup used for chart geometry instead of text markup.

Step by step

How to Use

  1. 1
    Add the Rough.js CDNInclude rough.umd.js from the CDN panel — a single script tag exposing the rough global.
  2. 2
    Paste HTML, CSS, and JSA text block renders with a highlighted phrase and a circled heading already sketched in.
  3. 3
    Toggle highlights off/onThe button flips a boolean and calls drawAnnotations() again — no text is ever touched.
  4. 4
    Select the highlighted textThe phrase is real, selectable HTML; the yellow mark sits purely behind it on the canvas.
  5. 5
    Resize the windowA resize listener re-measures element positions so marks never drift out of alignment.
  6. 6
    Add more annotationsQuery additional elements by a data attribute and draw a rectangle or ellipse behind each one.

Real-world uses

Common Use Cases

Interactive reading and study tools
Let readers toggle hand-drawn emphasis on key passages, with real selectable text under a transparent canvas overlay instead of an image of the page.
Marketing callouts
Draw attention to a key phrase with a sketched highlighter mark or circle, positioned live from each element's getBoundingClientRect.
Documentation emphasis
Circle a critical warning or highlight a requirement, while find-in-page, screen readers and SEO all keep working on the underlying text.
Review and annotation tools
Provide a foundation for letting users mark up content, with viewport coordinates converted to wrapper-relative positions for the canvas.
Canvas-over-DOM reference
Study the layering of an absolutely positioned canvas behind or above live text, and how annotations redraw when the layout changes.

Got questions?

Frequently Asked Questions

The canvas is positioned absolutely and given a lower z-index (1) than the real text elements, which have position: relative and z-index: 2. Both being stacking contexts on the same page, the browser paints the canvas first and the text second, so the yellow hachure fill visually sits behind the text rather than obscuring it — the same visual relationship as a highlighter mark showing through printed paper.

Without it, the canvas element — which fully overlaps the text as an absolutely-positioned layer — would intercept mouse clicks and text selection, since it sits in front in the DOM tree order even though it renders visually behind via z-index. pointer-events: none makes the canvas transparent to all pointer interaction, so clicks and selection pass straight through to the real text underneath.

getBoundingClientRect() always returns coordinates relative to the browser viewport, not relative to any parent element. Since the canvas is positioned relative to its wrapper (top: 0; left: 0 within .rah-canvas-wrap), a coordinate has to be converted into "distance from the wrapper's top-left corner" by subtracting the wrapper's own rect offset — otherwise the sketch would be drawn using raw viewport coordinates and land in the wrong place unless the wrapper happened to be at the page's exact top-left corner.

Text can reflow when the viewport changes size — a narrower window might wrap the highlighted phrase onto a different line entirely. Because the canvas coordinates were computed from getBoundingClientRect() at draw time, a stale draw would leave the highlight rectangle in its original position while the actual text moved elsewhere. Re-running drawAnnotations() on resize re-measures every target element fresh, so the marks always track the current layout.

Rough.js does not provide a separate circle primitive — a circle is simply an ellipse whose width and height happen to be equal. Using rc.ellipse(cx, cy, width, height, options) with independently controllable width and height also makes it trivial to fit an oval annotation around text of any aspect ratio, not just perfectly square regions.

Give each target phrase a shared attribute like data-annotate="highlight", then inside drawAnnotations() use document.querySelectorAll('[data-annotate="highlight"]') and loop over the results, computing and drawing a rectangle for each one's own getBoundingClientRect() the same way the single .rah-mark lookup does now.