Exploring Accessible Inline Spoilers
Tags: a11y, html, css, javascript
Published: 2024-01-28 15:56:02

## 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>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details). We can use it like this:

{% spoiler Movie spoiler %} Gandalf is Harry Potter’s father. {% endspoiler %}

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.

```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…

```css
.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:

```css
.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](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_WCAG/Keyboard#interactive_elements_must_be_focusable) in the first place, which is simple enough using [`tabindex`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex):

```html
<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](https://www.reddit.com/r/help/comments/e0pobn/how_do_i_make_text_hidden_spoiler_text/), 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](https://meta.stackexchange.com/questions/72877/whats-the-exact-syntax-for-spoiler-markup) 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](https://meta.discourse.org/t/spoiler-blur-not-compatible-with-screen-readers/219001), spoiler text gets blurred and visible on click – but it’s not accessible. Co-founder Sam Saffron [came up with a solution](https://codepen.io/abcretrograde/pen/MGzqLr) 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](https://www.w3.org/WAI/WCAG22/Techniques/failures/F87).

[Mastodon](https://joinmastodon.org) 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](https://github.com/openui/open-ui/issues/772#issuecomment-1858638230) circulating, the most sophisticated in my opinion being described in [an article by ndesmic](https://dev.to/ndesmic/how-to-make-a-spoiler-component-with-web-components-3hp9): Their implementation is [web components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components)-based and uses an [ARIA live region](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions) 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](https://jsfiddle.net/kaikubasta/2pom0zxw/) 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:

```html
<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:

```html
<p>I never thought that
  <button aria-label="Spoiler">
    <span data-text="Gandalf is Harry Potter's father"></span>
  </button>.
</p>
```

```css
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](https://dev.to/matuzo/comment/2cfpo), `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:

```html
<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](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/switch_role) and [`aria-pressed`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/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`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label) will get read by screen readers when focussing the spoiler in its hidden state.
* The [`data` attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes) are used to set the [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title) with JavaScript. It provides additional context for mouse users and screen readers.

```css
[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](https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/blur), 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.

```javascript
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` 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](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements) text gets removed.

---

## See it in action

{% embed https://jsfiddle.net/kaikubasta/szx4h7bj/171/ %}

---

## 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](https://web.archive.org/web/20220711111558/https://discourse.wicg.io/t/standardized-spoiler-tag/5814).

---

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