Skip to content
Daniel Shaw ยท WordPress & WooCommerce Developer Wellington, New Zealand

๐Ÿ‘‹ Not available for new client work right now!

An interactive X-ray effect for SVG illustration

I’m a fan of the interactive illustration present on sites like robbowen.digital and cassie.codes, specifically the self-portraits. Recently, I began an ongoing makeover for this site, including a refresh of my own little selfie icon.

I originally imagined I’d set up something similar to the user-animated illustrations in the above links. Instead, I chose to continue the half-assed visual theme from my previous version of this site: surface skin versus underlying structure, the axis of activity for a front-end developer.

Here’s the approach I took to create an illustrated selfie with interactive x-ray effect, radiation-free!

Surface and structure

I feel some regret I’d already resolved the selfie icon before thinking about a bone layer. Life drawing teachers everywhere collectively sigh. Let’s pretend I purposely took the cartoon-first approach of the Korean artist, Hyungkoo Lee:

Selfie illustration
The soft & fleshy surface
Skull illustration
The crunchy interior

These sit in a single SVG element where, by default, the bone layer will visually overlap the skin layer. A mask element can help manage when the bone layer should be revealed. Here’s some fairly standard SVG markup at this stage:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 500"> <g class="surface"> <path class="skin" .../> </g> <g class="structure"> <path class="bone" .../> </g> </svg>

Creating and referencing an SVG mask

First up, here’s what the basic mask markup looks like:

<defs> <clipPath id="mask"> <circle cx="0" cy="0" r="20%"> </clipPath> </defs>

It’s composed of two SVG elements: a <circle> and <clipPath>. A circle is simple and makes visual sense for this project but any complex shape can be used as a mask. The <clipPath> element assigns the circle as the mask shape for the bone layer.

A <circle> is a vector shape, one of three graphical object types allowable within an SVG: vector, bitmap, and text. Wrapping the entire mask definition in a <defs> element provides control over how it will be displayed, rather than automatically being rendered as a visual element.

The mask is applied to the bone layer by wrapping the relevant <path> elements with a <g>, with the mask referenced by ID:

<g clip-path="url(#mask)" class="structure"> <path class="bone" .../> </g>

The final structure of the prepped SVG will look like this:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 500"> <defs> <clipPath id="mask"> <circle class="mask-shape" cx="0" cy="0" r="20%"/> </clipPath> </defs> <g class="surface"> <path class="skin" .../> </g> <g clip-path="url(#mask)" class="structure"> <path class="bone" .../> </g> </svg>
Skull overlaying selfie illustration
Skin and bone layers, no clip-path
X-ray effect
Clip-path applied to bone layer

Controlling the X-ray effect

The mask is driven by user interaction, so it’s time to leverage some browser-native API goodness with a small amount of JS. Here’s what needs to happen:

  • the screen coordinates of the input device will be captured
  • these coordinates will be translated to the SVG’s coordinate system
  • the mask position will be updated as the input device moves

Set up helpful declarations

const selfie = document.querySelector( '.surface' ); const mask = document.getElementById( '.mask-shape' ); const coords = selfie.createSVGPoint();

createSVGPoint() sets up an object with properties describing a point in an SVG’s coordinate system. This will be helpful when translating the screen coordinate context to the SVG’s own system.

Capture and manage input coordinates

document.addEventListener( 'mousemove', e => { setCoords( getCoords( e, selfie ) ); }, false ); let getCoords = (e, svg) => { coords.x = e.clientX; coords.y = e.clientY; return coords.matrixTransform( svg.getScreenCTM().inverse() ); }

Ultimately, getCoords returns a set of 2D coordinates that will visually sync the input device position with the SVG’s own coordinate system (i.e. the mask will align perfectly to the mouse pointer location). For conciseness, the getCoords function does some double-handling here:

  • The user’s screen coordinates are assigned as property values of the coords object. coords, per the declaration above, is an SVGPoint() object. Properties of this object relate directly to the SVG’s own coordinate system.
  • In general, the return handles mapping from one coordinate system—the screen—to another: the SVG’s own coordinate system. This part is handled with the getScreenCTM(), which translates to the screen context. Chaining inverse() here ensures the mapping occurs in reverse, translating to the document context. A matrixTransform() applied to the coords object finally returns the desired coordinates.

If that doesn’t make a whole lot of sense, apologies! There was a fair bit of trial and error here for me, and I don’t have a good grasp on this matrix transformation stuff (the math behind this is beyond my feeble brain).

Update the mask position

All that needs to be done from here to set the mask location is to update the cx and cy attributes of the mask element with the freshly mapped coordinates:

let setCoords = coordinates => { scope.setAttribute( 'cx', coordinates.x ); scope.setAttribute( 'cy', coordinates.y ); }

The mask is now synced to the input device location, like this:

Putting it all together

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 500"> <defs> <clipPath id="mask"> <circle class="mask-shape" cx="0" cy="0" r="20%"/> </clipPath> </defs> <g class="surface"> <path... /> </g> <g clip-path="url(#mask)" class="structure"> <path... </g> </svg> <script> const selfie = document.querySelector( '.surface' ); const mask = document.querySelector( '.mask-shape' ); const coords = selfie.createSVGPoint(); document.addEventListener( 'mousemove', e => { setCoords( getCoords( e, selfie ) ); }, false ); let getCoords = ( e, svg ) => { coords.x = e.clientX; coords.y = e.clientY; return coords.matrixTransform( svg.getScreenCTM().inverse() ); } let setCoords = coordinates => { mask.setAttribute( 'cx', coordinates.x ); mask.setAttribute( 'cy', coordinates.y ); } </script>
π