<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ink & Pixels — notes from building TattooTryOn]]></title><description><![CDATA[Ink & Pixels — notes from building TattooTryOn]]></description><link>https://tattootryon.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 31 Oct 2026 01:38:20 GMT</lastBuildDate><atom:link href="https://tattootryon.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to build a drag, pinch and rotate overlay editor on top of a photo]]></title><description><![CDATA[Pointer Events, DOMMatrix and a little trigonometry, from a small tattoo preview project]]></description><link>https://tattootryon.hashnode.dev/how-to-build-a-drag-pinch-and-rotate-overlay-editor-on-top-of-a-photo</link><guid isPermaLink="true">https://tattootryon.hashnode.dev/how-to-build-a-drag-pinch-and-rotate-overlay-editor-on-top-of-a-photo</guid><category><![CDATA[frontend]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[canvas]]></category><dc:creator><![CDATA[TattooTryOn]]></dc:creator><pubDate>Mon, 19 Oct 2026 03:45:30 GMT</pubDate><content:encoded><![CDATA[<p>The thing I needed sounds small. Show a photo, put a transparent PNG on top, and let people move, resize and rotate it with a mouse or two fingers.</p>
<p>It's the core of <a href="https://tattootryon.app">a tattoo preview tool</a> I just launched, where the user places the design on their own arm before anything else happens. Early analytics are tiny (13 visitors in the first days), but the average visit was nearly 11 minutes, and a big part of the app is this editor. So it seemed worth writing up how this kind of editor can be put together. It's mostly not hard. It's just a lot of small details.</p>
<h2>Use Pointer Events, not mouse plus touch</h2>
<p>Pointer Events cover mouse, pen and touch with one API, and every finger gets its own <code>pointerId</code>. That's exactly what you need for multi-touch, and you skip maintaining two sets of handlers.</p>
<p>Two bits of setup matter. Put <code>touch-action: none</code> on the stage element; otherwise, mobile browsers scroll and zoom the page while the user pinches. And call <code>setPointerCapture(e.pointerId)</code> on <code>pointerdown</code>, so moves keep arriving when a finger slides off the image.</p>
<p>Keep active pointers in a <code>Map</code> keyed by id. Remove them on <code>pointerup</code> and also on <code>pointercancel</code>, which fires when the OS takes over a touch. If you only handle <code>pointerup</code>, a stale entry can stay in the map and the next drag gets treated as a pinch.</p>
<h2>State is four numbers</h2>
<p><code>x</code> and <code>y</code> for the overlay's center in photo pixels, <code>scale</code>, and <code>angle</code> in radians. Store the center, not the top-left corner. Then rotation and zoom pivot around the middle, which is what people expect without thinking about it.</p>
<p><code>DOMMatrix</code> turns that into a CSS transform and can also invert itself, which you'll want for hit testing:</p>
<pre><code class="language-js">const toMatrix = ({ x, y, scale, angle }) =&gt;
  new DOMMatrix()
    .translateSelf(x, y)
    .rotateSelf(angle * 180 / Math.PI)
    .scaleSelf(scale);
</code></pre>
<p>Set <code>transform-origin: 0 0</code> on the overlay and offset it by half its size, or the center won't land on <code>x, y</code>.</p>
<p>I'd recommend not adding small deltas on every <code>pointermove</code>. Floating-point error accumulates. A cleaner approach is to snapshot the state and finger positions when a gesture starts and compute everything from that snapshot. When a finger is added or lifted, take a new snapshot.</p>
<h2>Two-finger math</h2>
<p>The line between the two fingers tells you everything:</p>
<pre><code class="language-js">function pinch(start, a, b) {
  const dist = Math.hypot(b.x - a.x, b.y - a.y);
  const ang = Math.atan2(b.y - a.y, b.x - a.x);
  return {
    x: start.x + ((a.x + b.x) / 2 - start.midX),
    y: start.y + ((a.y + b.y) / 2 - start.midY),
    scale: Math.max(0.05, start.scale * dist / start.dist),
    angle: start.angle + (ang - start.ang),
  };
}
</code></pre>
<p>The distance ratio is zoom, the change in angle is rotation, and the midpoint moving is a pan. All three apply at once, which is why it feels natural. One caveat that's true of this math in general: when the fingers are very close, small movements make the angle noisy. A small dead zone is the usual fix, though it can feel sticky if it's too big.</p>
<p>On desktop there's no second finger, so a corner handle can do the same job by using the overlay's center as the fixed point. My opinion: a visible handle beats wheel-plus-modifier shortcuts, because nobody has to be told it exists.</p>
<p>For hit testing a rotated overlay, map the pointer through the inverse matrix into the overlay's own coordinates and compare against half its width and height. A plain bounding box gets the corners wrong as soon as anything is rotated.</p>
<h2>Exporting so the screen size doesn't matter</h2>
<p>Whatever uses the placement afterwards shouldn't care how big the user's screen was. Export normalized values: center divided by the photo's natural size, scale relative to photo width, angle in radians. Then you can redraw the composite at any resolution.</p>
<p>If you redraw on a canvas, use the same order as the CSS transform: translate, rotate, scale. Mix those up and the exported image won't match what the user saw. Generally it's safest to have preview and export share a single drawing function.</p>
<p>I did something similar on <a href="https://tattootryon.app/tattoo-font-generator">a little lettering preview page</a>, which renders text in different fonts to a transparent PNG. Funnily enough, that page is what Google noticed first. The site's first search impressions, on Oct 19, were for "tattoo design font generator" and "tattoo text font generator", at an average position of 24.8. Not the editor I'm writing about here. Fine.</p>
<h2>Keeping it smooth</h2>
<p>Pointer events can fire faster than the screen refreshes, so store the latest state and apply it once per frame in <code>requestAnimationFrame</code>. Move the overlay with CSS transforms rather than redrawing a full-size phone photo on every move, which gets laggy fast. Show a downscaled copy of the photo while editing and keep the original for export. And put <code>will-change: transform</code> on the overlay only.</p>
<p>The rest is unglamorous. Handles need touch targets big enough for a thumb. You'll want to keep the overlay from being dragged completely off the photo, because someone will do it. That stuff takes longer than the trigonometry, which is a bit unfair but true.</p>
]]></content:encoded></item></channel></rss>