Animating DOM Rectangles Over Focused Elements In JavaScript
In my effort to catch up on the "Widely Available" web platform features, I know that I have to take what I've read and attempt to apply it in some sort of hands-on manner; otherwise I won't retain the information. But, what started out as an investigation of the :focus-visible pseudo-class in CSS became something mostly incoherent. As I was pondering visibility, I remembered a website I saw years ago in which a "focus ring" would zoom around the screen, jumping from one .activeElement to the next. For whatever reason, I wanted to see if I could build something similar.
Caution: to be clear, I think this "focus box zoomies" mechanic is a poor user experience (UX). If anything, it distracts the user with the animation. I'm in no way recommending this approach - I just wanted to see what building it might look like.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
In this demo, when a user focuses a link within a larger block of text, instead of showing the typical :focus or :focus-visible outline, I'm trying to recreate the outline using explicit DOM (Document Object Model) elements. This way, I can exert more control over their CSS properties. And, in this case, it's specifically for animating them from one element to the next.
My default :focus CSS looks something like this:
:where( * ) {
&:focus,
&:focus-visible {
animation-duration: 200ms ;
animation-fill-mode: forwards ;
animation-iteration-count: 1 ;
animation-name: outlineEnter ;
animation-timing-function: ease-out ;
outline-color: hotpink ;
outline-offset: 4px ;
outline-width: 2px ;
}
}
@keyframes outlineEnter {
from {
outline-offset: 8px ;
}
to {
outline-offset: 4px ;
}
}
This CSS has the browser render a hotpink outline around any focused element in the document. The outline has a small enter-animation (transitioning the outline offset from 8px to 4px, giving is a "honing in" vibe); but, the location of the outline is static.
For this demo, I'm deactivating this normal outline behavior in the confines of the .textBlock container:
.textBlock * {
&:focus,
&:focus-visible {
animation-name: none ;
outline: none ;
}
}
Instead, the .textBlock container will have a <mark> element that will house the "segments" that I use to outline the focused links (within the text block):
<p class="textBlock">
<mark hidden class="box">
<!--
I will scurry across the viewport and highlight focused areas within this
paragraph. Since focused text may wrap across line-breaks, I'm providing
several internal segments for compound highlighting rectangles.
-->
<span></span>
<span></span>
<span></span>
</mark>
<span data-repeat="10">
Lorem ipsum dolor <a href>sit amet</a> interrogo communis flumen.
<a href>Mille iuvenis</a>, umquam ante cohors, adhibeo citus fortis provincia.
.... truncated ....
</span>
</p>
In JavaScript, when you call .getBoundingClientRect(), it returns a DOMRect shape that fully encompasses the given element. For blocky elements, like Buttons, this is fine - a single rectangle can represent the shape boundary. But for inline elements, like text, this isn't a perfect match. When text wraps across line-breaks, its bounding box needs to be subdivided into smaller, line-specific segments.
This is what we get from the .getClientRects() method. Instead of a single bounding box, this method returns a collection of DOMRect shapes that, in aggregate, represent the discontiguous spans of text. In my snippet above, each of the <span> elements, within the <mark>, will be used to highlight a distinct sub-rectangle of the overall focus box.
In order to move these segments around in the viewport, I'm listening for the focusin event. Unlike the focus event, which doesn't bubble up the DOM tree, the focusin event can be accessed using "event delegation" (ie, listening for descendant events from the root of the document). When the focusin event occurs within my .textBlock container, I inspect the client rects and update the spans' CSS.
Here's the full code for this code kata:
<!doctype html>
<html lang="en">
<body>
<h1>
Animating DOM Rectangles Over Focused Elements In JavaScript
</h1>
<p>
Before demo <a href>text block</a> (to take focus from demo block and to contrast
the normal focus-outline behavior).
</p>
<p class="textBlock">
<mark hidden class="box">
<!--
I will scurry across the viewport and highlight focused areas within this
paragraph. Since focused text may wrap across line-breaks, I'm providing
several internal segments for compound highlighting rectangles.
-->
<span></span>
<span></span>
<span></span>
</mark>
<span data-repeat="10">
Lorem ipsum dolor <a href>sit amet</a> interrogo communis flumen.
<a href>Mille iuvenis</a>, umquam ante cohors, adhibeo citus fortis provincia.
Hic posco ego, quis frequens tenebrae. Aliquis turbo is epistula. <a href>Hic
experior quidam voluntas</a> nam aliquis vinculum. Noster puto paulo sum post
saevus natura. Diversus ventus, quasi cum for meus, scribo dexter prior
astrum. Idem condo qua ictus quoque nemo iter. Mortalis frater, denique in
puer, tollo nullus quattuor vestigium. <a href>Iste dico ipse voluntas an sui
vitium meus desino quisquis, qua gratus ira tu opto nemo praemium gens rideo
diversus error</a>.
</span>
</p>
<style type="text/css">
mark.box span {
background-color: transparent ;
border-radius: 3px ;
border: 1px solid tomato ;
box-shadow: inset 0 0 0 3px #ffff04aa ;
opacity: 0 ;
pointer-events: none ;
position: absolute ;
transition: all 100ms ease-out ;
z-index: 9999 ;
}
</style>
<script type="text/javascript">
var box = document.querySelector( "mark.box" );
var boxSegments = Array.from( box.children );
// When a new element is focused within the document, we want zoom the marker
// box(es) over to the focused element to emphasize focus.
document.addEventListener( "focusin", ( event ) => {
var target = event.target;
// If an element OUTSIDE of the demo text block was focused, hide the marker -
// we only want that to show the crazy boxy inside the demo block.
if ( ! target.closest( ".textBlock" ) ) {
box.hidden = true;
return;
}
// We're about to animate the box segments into place, mark the box as active
// (and visible) in the DOM.
box.hidden = false;
// Instead of using the `.getBoundingClientRect()`, which will give us the box
// that encompasses ALL areas of the focused text, we're going to use the
// `.getClientRects()` to get individual DOMRect readings for each part of the
// text if it wraps across lines.
// --
// Note: if the text doesn't wrap, the two get-rect methods are equivalent.
var rects = Array.from( target.getClientRects() );
var offsetBlock = window.scrollY;
var offsetInline = window.scrollX;
var padBlock = 1;
var padInline = 6;
boxSegments.forEach(
( segment, i ) => {
var rect = rects[ i ];
// In order to make the animations smooth, we want to position every
// segment even if they aren't going to be rendered. This way, when
// they do get rendered, it looks like everything originates from the
// same location within the viewport. To that end, if we don't have a
// matching DOMRect for this segment, just use the first one and mark
// the segment as translucent.
if ( rect ) {
segment.style.opacity = 1; // Show.
} else {
segment.style.opacity = 0; // Hide.
rect = rects[ 0 ];
}
segment.style.left = `${ rect.left - padInline + offsetInline }px`;
segment.style.top = `${ rect.top - padBlock + offsetBlock }px`;
segment.style.width = `${ rect.width + ( padInline * 2 ) }px`;
segment.style.height = `${ rect.height + ( padBlock * 2 ) }px`;
}
);
});
// When the window loses focus, hide the marker box.
window.addEventListener( "blur", ( event ) => {
box.hidden = true;
});
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// Flesh-out lots of text for the demo so we can force window scrolling.
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
var templateNode = document.querySelector( "[data-repeat]" );
var count = Number( templateNode.dataset.repeat );
for ( var i = 1 ; i <= count ; i++ ) {
templateNode.after( templateNode.cloneNode( true ) );
}
// Don't honor any anchor links. I want the `<a>` tags to have something to focus;
// but I don't want the clicks to actually navigate away from the page or offset.
document.addEventListener( "click", ( event ) => {
if ( event.target.closest( "a" ) ) {
event.preventDefault();
}
});
</script>
</body>
</html>
Now if we run this page and Tab from link to link, we get the following focus box zoooomies!
Note: I'm slowing down the animation duration for the GIF recording so that it shows up in the GIF framerate.
Again, I don't actually think this is a good experience; but, I think it was a good code kata. I got to use the hidden global attribute, the focusin event delegation, and the .getClientRects() method.
Want to use code from this post? Check out the license.
Reader Comments
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →