Animating Focus with View Transitions
Animating Focus with View Transitions êŽë š
The big idea here is animating the focus ring around literally-focused elements on web pages. Like the :focus (or :focus-visible) styles, either the default or your own.
Iâm just going to go ahead and say this idea Iâm about to play with probably isnât a good idea. Itâs a bunch of probably-unnecessary motion. Nobody is asking for it.
Although I say that, and the WebAIM website, a site literally all about web accessibility, does it.
If you canât see the video, imagine blue focus outlines around things like navigation links and breadcrumb nav links that have a âflyingâ animation between them as you tab through the page.
Jared says:
To help ensure high accessibility for sighted keyboard users, weâve added some nifty keyboard focus indicators for links and form controls. A distinctive color change and slight transition draw visual attention to focused links. Additionally, scripting provides a focus âtraceâ or âflying focusâ to help the user follow the visual focus. Tab through the links on this page to see it in action.
Emphasis mine. They certainly know more about web accessibility than I do! I also note their implementation respects prefers-reduced-motion, which seems highly relevant.
Me, I just find all this a fun and interesting challenge, especially since there is a long line of people doing it over the years, and now there is new tech to add to the party.
I thought of this after recently reading Ben Nadelâs âAnimating DOM Rectangles Over Focused Elements In JavaScript.â Benâs implementation, along with several others I looked at, involves having a reference in JavaScript to the focused element, measuring its location and dimensions with things like getBoundingClientRect and/or getClientRects (in case the lines of text break) then animating/transitioning between the new numbers you get.
Thatâs fine, I suppose. But in my experience, measuring things in JavaScript isnât particularly performant, nor is animating values like top and left. Maybe we could use FLIP somehow? Maybe we could make the focused elements anchors then use AIM? That all sounds kind of fun, but what came to mind first for me was View Transitions.
Letâs Do View Transitions
View Transitions can do tweening, which should work nicely for us here. Short story, if an element has a unique view-transition-name on it, and you call startViewTransition() and mess around with the DOM (in our case, moving focus from one element to another), then that same element (or another) has that same view-transition-name, it will literally animate from one state to the next. Even âflyâ to the next position, which is exactly what weâre after.
So weâre doing JavaScript here. There is a thing called multi-page View Transitions, and they are great, but weâre not dealing with multiple pages; weâre just dealing with moving focus around a single page.
Our job is to do something that changes the DOM inside the View Transition, and it will just magically animate. I swear, itâs weird. So what weâll do is: we will manually move the focus ourselves, like with a .focus() call. Thatâs the DOM change, and itâs enough to animate from the old element with focus and focus styles to the new element with focus and focus styles.
Itâs surprising to me that this actually works.
What kinda sucks is that weâre hijacking the Tab key presses to do this:
document.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (!document.startViewTransition) return; // graceful fallback
const focusables = [...document.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)].filter(el => el.offsetParent !== null); // skip hidden
const i = focusables.indexOf(document.activeElement);
if (i === -1) return; // focus is somewhere weird, let browser handle it
e.preventDefault();
const dir = e.shiftKey ? -1 : 1;
const next = focusables[(i + dir + focusables.length) % focusables.length];
document.startViewTransition(() => next.focus());
});
Sucky, meaning the Tab key certainly isnât the only way elements receive focus, but thatâs all weâre dealing with here. In Benâs demo, heâs doing the work on a focusin event, which will work no matter how an element gets focus. But if I did that here, the DOM change has already happened, and itâs too late for a View Transition. Iâm sure there are ways to make this approach more comprehensive, itâs just too silly a project for me to dig that deep.
Here are things so far:
Itâs also kinda sucky weâre in charge of deciding what elements can be focusable. My list is excluding <summary>, for example, which is a foul. Iâm leaving it off on purpose to emphsize the suck.
Cross-Fade Weirdness
If we slow down our demo above, weâll see some extra funky behavior. We can do that like:
::view-transition-group(focus-ring) {
animation-duration: 5s;
animation-timing-function: cubic-bezier(.4, 0, .2, 1);
}
I donât exactly know how to fix that. There are all sorts of fancy View Transitions pseudo-elements to control parts of the animation, but I donât think any of them do quite what I want here. What Iâd like to see is only that pink rectangle moving around, nothing inside it. That pink rectangle is on the element itself. I donât think there is any CSS thing for âmake myself transparent but my outline still visibleâ. We could fake it, and weâll get to that next, but otherwise, I donât think there is a way.
Could we kinda âblack outâ the middle, though?
If we add a class to the document during the View Transition, we could âcoverâ the element with a background color while itâs transitioning.
.focus-transitioning :focus-visible::after {
content: "";
position: absolute;
inset: 0;
background: Canvas;
border-radius: inherit;
}
Nahhh, still be pretty gross. But here it is anyway:
Using a Child Element
I think the trick is going to be using a <span> (some meaningless child element) to behave like the :focus-visible styling would. So essentially we donât have any :focus-visible styling directly, we use the <span> to replicate it. This is because the <span> doesnât have any children that will move around. It can be an empty ring that flies around.
<button>
Actual Button Text
<span class="focus-ring" aria-hidden="true"></span>
</button>
But there is a funky trickâŠ
The actual <span> doesnât move. Every focusable element has itâs own <span> that behaves as a focus ring. Itâs just that different <span>s becomes visible when the focus changes.
a, button {
position: relative;
}
a:focus-visible,
button:focus-visible {
outline: none; /* the span is the ring now */
}
span.focus-ring {
position: absolute;
inset: -6px;
border: 2px solid deeppink;
border-radius: 6px;
pointer-events: none;
opacity: 0;
}
/* Visible + named only when the parent is focused */
:is(a, button):focus-visible > span.focus-ring {
opacity: 1;
view-transition-name: focus-ring;
}
So when one ring disappears and another appears, thatâs the DOM change that is relevant and that the View Transition will take care of tweening.
How about them apples?
Thatâs just the kind of look we were shooting for all along. Still donât love binding to the Tab key and all, but overall I think this is an interesting addition to the grand history of flying focus.
Two Little Bonus Things
1. Aspect Ratio During Transitons Is A Thing
If youâre ever transitioning elements that might change in aspect ratio, like we are definitely doing here as itâs totally random focused elements, then read Jakeâs View transitions: Handling aspect ratio changes. Essentially you fiddle with things like object-fit to ensure the changes in size between the two elements donât render too weird while tweening.
2. Honor Reduced Motion
Easy enough. Default cross-fades might be OK and not impart too much motion, but automatic movement might still happen, so in my opinion, best to just nuke the animation entirely.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
}
Other Prior Art