There's a typewriter effect in the hero section of the SkyPorch landing page. After I added that JavaScript animation I'd always worried about it with the accessibility - how could screen readers would read it? The words are consistently changed. Finally, I refactored that part yesterday. Here's the process.
(I'm using the silktide's chrome extension to test the accessibility.)
The code is written in React; here's the previous structure. I created a component for the typewriter content and its JavaScript animation.
// omit other parts
const WORDS = ['ship', 'love', 'live'];
return (
<h1>
We <TypewriterEffect words={words} /> software
</h1>
);
// omit the js animation
const TypewritterEffect = ({ words }) => {
const typewritterRef = useRef(null);
const cursorRef = useRef(null);
// omit the js animations
return (
<>
<em ref={typewriterRef} />
<noscript>{words[0]}</noscript>
{/* the cursor */}
<span ref={cursorRef} className="no-js-hidden">
_
</span>
{/*
I used to put here a button to control
the animation pause or play.
This is a highly simplified version of that.
*/}
<button>Pause the animation</button>
</>
);
};
In the JSX part, the em
element, which contains the words, is empty by default. I use GSAP to put the words into it dynamically. There's a noscript
tag for showing the content when JavaScript is disabled in the browser; the no-js-hidden
class hides the cursor when JavaScript is disabled.
So with this code, the screen readers read the content as "Heading 1 - we shi _ Pause the animation software". (I knew the button there was a bad practice for semantic HTML; I needed to fix that, too.) The screen readers sometimes read the animation part as "love" or "live" because of the changing words.
First Attempt and Failed
Added the aria-hidden
attribute.
In the beginning, I thought I could add an aria-hidden="true"
to that em
element and the button and the cursor, also add the correct content and visually hide it. aria-hidden="true"
makes the content inside that HTML element not visible or announced to users of assistive technology such as screen readers. Here's the code:
// omit others
return (
<>
<em aria-hidden="true" ref={typewriterRef} />
<span className="visually-hidden">{words.join(', ')}</span>
<noscript>{words[0]}</noscript>
{/* the cursor */}
<span aria-hidden="true" ref={cursorRef} className="no-js-hidden">
_
</span>
<button aria-hidden="true">Pause the animation</button>
</>
);
Info
visually-hidden
is a utility class that hides content visually, the same as thesr-only
class in Bootstrap or TailwindCSS. This is called accessible hiding.My
visually-hidden
class:
.visually-hidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
}
But this doesn't work. And NONE OF IT works. The screen readers still read this as "Heading 1 - we shi _ Pause the animation software".
I assume that because of the animation, the component re-renders for every letter, and the aria-hidden="true"
can't work. But how to fix it?
Second Attempt and Failed
Added the aria-hidden
attribute to the parent element.
Then I tried to add the aria-hidden="true"
on their parent - the h1
tag. Because this attribute can make the element and all the elements inside it invisible to the screen readers (assistive technologies). I removed all the Aria attributes in the TypewriterEffect
component, and that visually hidden span made the change to the h1
:
return (
<>
<h1 aria-hidden="true">
We <TypewriterEffect words={words} /> software
</h1>
<h1 className="visually-hidden">We {words.join(', ')} software.</h1>
</>
);
This still doesn't work! The animation messes up everything.
Third Attempt and Finally Succeed
Added aria-label
attribute instead of aria-hidden
.
After reading some articles, I finally found a way to solve it - instead of adding aria-hidden
, I added aria-label
to the h1
element.
return (
<>
<h1 aria-label={`We ${WORDS.join(', ')} software.`}>
We <TypewriterEffect words={words} /> software
</h1>
{/* and I pull out that button */}
<button aria-hidden="true">Pause the animation</button>
</>
);
The definition on MDN is "The aria-label
attribute defines a string value that labels an interactive element." The most common usage is when we use an svg icon for a button and to provide helpful information, such as with a button that only has a bird icon, provides a "link to Twitter account", etc. So, the screen readers wouldn't read the content inside that element but only read the content given to the aria-label
.
<button aria-label="link to Twitter account">
<svg>...</svg>
</button>
It needs to be noted that, first, the h1
heading isn't an "interactive element"; second, by using aria-label
, all the text content inside that element will be replaced by the value of aria-label
, so it should be used when only necessary.
And this is a "when only necessary" situation. We don't need to hide the content from the visual user by the need to hide it from screen readers because it's "unstable".
That's it. This is how I fix the accessibility issue of the typewriter animation.