Introduction
Everybody loves to talk about narrative media like books or movies, especially on the internet. But nobody wants to get spoiled by a plot twist before actually consuming the respective medium. That’s why many online communities have a spoiler feature, which hides text passages until the user interacts with them. However, hiding text in an accessible way is sometimes not as trivial as you might think.
The situation
Let’s say we want to write about a film we recently saw, at the end of which it is revealed that Gandalf was actually Harry Potter’s father. (What a twist, huh?)
There is already a native HTML element for hiding/showing content: <details>
. We can use it like this:
Gandalf is Harry Potter’s father. Movie spoiler
Looks great. And it’s already accessible, too.
The problem
If you ever participated in an online discussion, you know that watching out for spoilers while structuring your post can be difficult and may even disrupt the flow of text. Sometimes you want to write away and mark spoilers afterwards. In this case, inline spoilers might be a better idea, and unfortunately there’s no native browser element for those.
So how to do this on websites? Join me in exploring accessible inline spoilers!
A first approach
Let's take the following sentence as an example:
I never thought that Gandalf was Harry Potter’s father.
Since the last part of this sentence spoils the end of our make-believe film, it should be in somehow marked in HTML.
<p>I never thought that <span class="spoiler">Gandalf was Harry Potter’s father</span>.</p>
Great! So if we want to hide and show the text inside the <span>
element, we could simply…
.spoiler {
background-color: black;
color: black;
}
.spoiler:hover {
color: white;
}
…right? Well, no. The problem here is that the :hover
selector does not work for sighted users who rely on their keyboard to navigate – they would never be able to see the text.
Your next guess might be the following:
.spoiler:hover,
.spoiler:focus {
color: white
}
Which is not a bad idea at all, :focus
does in fact work when navigating with a keyboard. But our <span>
element needs to get focusable in the first place, which is simple enough using tabindex
:
<span class="spoiler" tabindex="0">Gandalf was Harry Potter's father</span>
Now we have it: An inline spoiler that is perceivable by users with different input devices like mice and keyboards.
But perceivability and accessibility are different things. Think about the experience users with screen readers will have with this implementation: They navigate to the spoiler element, and it is read directly to them without any warning. We did not build a spoiler element, but a get instantly spoiled one.
Interim conclusion
We have to keep in mind that there are very different people with very different impairments out there. First of all, we should define our scope:
- Inline usability
- Sighted and visually impaired users
- Various input devices and assistive technology like screen readers
- Proper spoiler warning
How do others do it?
Before continuing, it is worth to take a step back and research how others have implemented inline spoilers.
On Reddit, there are two different technical implementations: When creating a new thread, you are able to mark your whole text as a spoiler. This will hide your post as a block behind a button. When writing answer posts into a thread, you will only be able to use inline spoilers, which aren’t accessible by keyboard. In both cases, the spoiler text can’t be hidden again without refreshing the site.
On Stack Exchange platforms, spoilers are rendered within <blockquote>
elements, which is questionable in terms of semantics – why should every spoiler be a quote at the same time? Their implementation is also not accessible by keyboard. Inline spoilers are missing.
In Discourse, spoiler text gets blurred and visible on click – but it’s not accessible. Co-founder Sam Saffron came up with a solution by actually using details
inline and some clever CSS hacks. However, getting it to work with valid HTML won’t be possible in my opinion. Using :before
pseudo-elements for non-decorative purposes also is not recommended.
Mastodon communities do not feature inline spoilers, but the possibility to write your own spoiler warning is a nice touch. Their toggle button implementation is also not ideal, using <details>
would have been a better idea.
There are some ideas including shadow DOM circulating, the most sophisticated in my opinion being described in an article by ndesmic: Their implementation is web components-based and uses an ARIA live region to make sure the previously hidden spoiler text will directly get read after clicking on the toggle.
Yet another setback?
Testing ndesmic’s implementation inside a paragraph with VoiceOver on Safari revealed a strange behaviour: If you focus an entire paragraph with the screen reader, the entire content is read aloud, including the spoiler. To further investigate this topic, I created another test case:
<p>I never thought that
<button aria-label="Spoiler">
<span aria-hidden="true">Gandalf is Harry Potter's father</span>
</button>.
</p>
Believe it or not, VoiceOver will read all visible text out when focussing the paragraph – despite using both aria-label
and aria-hidden
. Unfortunately, inline spoilers visually require the length of the actual text, so we can’t simply leave it out completely.
After some further experimentation, I came up with the following:
<p>I never thought that
<button aria-label="Spoiler">
<span data-text="Gandalf is Harry Potter's father"></span>
</button>.
</p>
span::before {
content: attr(data-text);
}
This is the only way I found which made it possible to use the actual text length while not getting read out automatically with the screen reader. Finally, something we can work with.
(Update: As Manuel pointed out, visibility: hidden
also works. I thought I had tested that too.)
A better approach
So let’s take away what we have learned so far and start over. This is how an accessible implementation could look like:
<p>I never thought that
<button
class="spoiler"
aria-role="switch"
aria-pressed="false"
aria-live="polite"
aria-label="Spoiler"
data-showText="Click to reveal text"
data-hideText="Click to hide text"
>
<span
class="spoiler__text"
data-text="Gandalf is Harry Potter's father">
</span>
</button>.
</p>
- The
switch
role andaria-pressed
represent the hidden and visible states of the spoiler. - As explained above,
aria-live
is used to directly read the text when revealing it. -
aria-label
will get read by screen readers when focussing the spoiler in its hidden state. - The
data
attributes are used to set thetitle
with JavaScript. It provides additional context for mouse users and screen readers.
[aria-pressed="false"] .spoiler__text {
background-color: currentColor;
}
[aria-pressed="false"] .spoiler__text::before {
content: attr(data-text);
}
[aria-pressed="true"] .spoiler__text {
text-decoration: underline;
text-decoration-style: dotted;
text-decoration-thickness: 2px;
text-decoration-color: gray;
}
- I contemplated using the blur filter, but blurry text may irritate users with a visual impairment. A solid background color is perfectly fine to hide the text.
- I added a dotted underline to mark revealed spoilers. If you use other elements using a similar style (e.g.
abbr
), consider adjusting one of those.
const label = $spoiler.getAttribute('aria-label');
const showText = $spoiler.dataset.showtext;
const hideText = $spoiler.dataset.hidetext;
const $text = $spoiler.querySelector('.spoiler__text');
const spoiler = $text.dataset.text;
$spoiler.title = showText;
$spoiler.addEventListener('click', () => {
if($spoiler.getAttribute('aria-pressed') === 'false') {
$spoiler.setAttribute('aria-pressed', 'true');
$spoiler.removeAttribute('aria-label');
$spoiler.title = hideText;
$text.innerText = spoiler;
} else {
$spoiler.setAttribute('aria-pressed', 'false');
$spoiler.setAttribute('aria-label', label);
$spoiler.title = showText;
$text.innerText = '';
}
});
- The value of
aria-pressed
andtitle
will switch every time you click. - The
aria-label
gets removed when the spoiler text is visible. - The text set in
data-text
will be put into the spoiler text node as soon as the pseudo-element text gets removed.
See it in action
Technical Disclaimers
- As you have probably already noticed, my implementation is not production-ready.
- Due to
innerText
, only plain text is currently supported. - It would be easier to work with a web component in a real scenario.
- Due to
- You may want to put research into screen reader support, since I only tested it with VoiceOver on Safari (macOS).
- Keep in mind that for people with sufficient vision, the spoiler length itself can involuntarily disclose information. In critical cases, using
<details>
like mentioned at the beginning of this article is the best solution.
Conclusion
- Creating accessible inline spoilers is not trivial, but certainly possible.
- It’s better to use
<details>
instead of inaccessible inline spoilers when in doubt. - There should be a standardized
<spoiler>
tag.
Please let me know what you think. I’m eager to see others’ approaches!
Top comments (2)
Thanks for making me aware of the VO bug. I didn't know about it. It was first reported in 2016.
Your final solution seems over-engineered. Have you explored solutions using
visibility: hidden
? Here's my version that seems to work as intended (test in VO with Safari 16.5.2., latest NVDA, and latest JAWS): Inline spoiler.Hey Manuel, I was pretty sure I tested
visibility: hidden
. 🤔 You're right that it works as well. Thanks for pointing this out!