Exploring Accessible Inline Spoilers

kaikubasta

Kai Kubasta

Posted on January 28, 2024

Exploring Accessible Inline Spoilers

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:

Movie spoiler

Gandalf is Harry Potter’s father.

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

…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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
span::before {
  content: attr(data-text);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
  • The switch role and aria-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 the title 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;
}
Enter fullscreen mode Exit fullscreen mode
  • 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 = '';
  }
});
Enter fullscreen mode Exit fullscreen mode
  • The value of aria-pressed and title 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.
  • 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

  1. Creating accessible inline spoilers is not trivial, but certainly possible.
  2. It’s better to use <details> instead of inaccessible inline spoilers when in doubt.
  3. There should be a standardized <spoiler> tag.

Please let me know what you think. I’m eager to see others’ approaches!

💖 💪 🙅 🚩
kaikubasta
Kai Kubasta

Posted on January 28, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related