DEV Community

ndesmic
ndesmic

Posted on

How to make a spoiler component with Web Components

I ran into an interesting post on the WICG (the official place for nobodies to propose web features) about the need for a built-in spoiler element. This to me seems like a great low-hanging feature as it's pretty prevalent across the web, and specifically on message board type applications. For example here's two example from Xenforo, a popular message board system:

Toggle Spoiler:
Image description

Inline Spoiler
Image description

The first behaves very similar to a summary/details element (but at least in Xenoforo it's a button and div). The second is an inline spoiler that basically obscures the text and reveals it when you click (this is specifically what the post is talking about). I've also seen things like black text with black highlights that changes when you select the text.

The post explains the limits of summary/details but it's basically as follows:

  • It's collapsed for a reason, not just to save space
  • Visual treatment is different

This seems like a plausible argument but I think we should actually explore this a little.

Simple HTML implementations

The toggle spoiler is a canonical summary/details:

<details>
  <summary>Citizen Kane Spoilers Ahead!</summary>
  Rosebud is the sled
</details>
Enter fullscreen mode Exit fullscreen mode

Toggle Spoiler

The inline spoiler can be text with an :active style:

<style>
.spoiler {
  background: black;
  display: inline-block;
}
.spoiler:active {
  background: transparent;
}
</style>
<p class="spoiler">Rosebud is the sled<p>
Enter fullscreen mode Exit fullscreen mode

Inline spoiler

Doesn't look like much without context...

Note that just a same color background is usually enough for a "highlight" spoiler but this works on click. It doesn't toggle though, for that we'd probably need a hidden checkbox and some label hackery which I think would be very confusing for screen readers. There's also the question of keyboard accessibility. Summary/Details has that baked in so you can focus and activate normally. The inline spoiler doesn't. To add that we can hack it a bit.

<style>
.spoiler {
  background: black;
  display: inline-block;
}
.spoiler:active,
.spoiler:focus {
  background: transparent;
}
</style>
<p tabindex="0" class="spoiler">Rosebud is the sled<p>
Enter fullscreen mode Exit fullscreen mode

By giving it a tabindex it becomes focusable and we use the focus state to style it. Still, this isn't a toggle, and it might be that you don't want to show it just on tab as the user might just navigate that way. Again I think this points back to using a checkbox.

There's also a question of how to style this. The background and foreground need to stay in sync for it to be obscured. What if we had emoji or some other multicolored element? This won't work. We could get more creative:

<style>
.spoiler {
  filter: blur(10px);
  display: inline-block;
}
.spoiler:active,
.spoiler:focus {
  filter: none;
}
</style>
<p tabindex="0" class="spoiler">Rosebud is the sled<p>
Enter fullscreen mode Exit fullscreen mode

Image description

Using a blur filter this will also obscure colored elements. It's a cool effect that might be useful but might not be enough. We might really want that thick black marker redaction. We can do that using some clever use of brightness:

.spoiler {
  filter: brightness(0%);
  background: white;
  display: inline-block;
}
.spoiler:active,
.spoiler:focus {
  filter: none;
  background: transparent;
}
Enter fullscreen mode Exit fullscreen mode

So we can get the desired visual effect with just CSS as long as we aren't asked to toggle it. Let's see what it takes to get there:

<style>
.spoiler-4 {
  filter: brightness(0%);
  background: white;
}
.spoiler-4 input[type="checkbox"]{
  display: none;
}
.spoiler-4:has(input:checked){
  filter: none;
  background: white;
}
</style>
<label class="spoiler-4"><input type="checkbox" /> Rosebud is the sled</p>
Enter fullscreen mode Exit fullscreen mode

I'm used CSS has which is a pretty new feature at the time of writing. It's not you can't do this another way to hit older browsers but it's more complicated so I'm taking a shortcut.

Or so I thought. Safari (the browser that actually has :has right now) has a bug where tabbing to a checkbox doesn't actually focus it. Gotta work on the basics I guess. So let's reformulate this:

<style>
.spoiler-4 {
  filter: brightness(0%);
  background: white;
}
input[type="checkbox"]#spoiler-4{
  clip: rect(0 0 0 0);
  height: 0px;
  width: 0px;
  padding: 0px;
  margin: 0px;
}
input:checked + .spoiler-4{
  filter: none;
  background: white;
}
</style>
<input type="checkbox" id="spoiler-4" />
<label class="spoiler-4" tabindex="0" for="spoiler-4"> Rosebud is the sled </p>
Enter fullscreen mode Exit fullscreen mode

This almost works at the expense of bad markup. It's still visually messed up as the focus ring gets the filter applied to it so it's not really clear when you focused on it.

Screen Readers

It's also interesting to ask what screen readers do. And for virtually all of them it will immediately spoil the content because it's just visually hidden but still part of the body (and tabindex group). The 4th version as expected gives the user no real clue what's going on as it's a random checkbox. We can give it an aria-label so it at least tells the user what it's for but either way the hidden content is still accessible to screen readers whether they checked or not.

Custom Element

So this is starting to get complex. I think the next step is to create a custom element that encapsulates all this stuff. Let's start with boilerplate:

class WcSpoiler extends HTMLElement {
    #isShown = false;
    bind(element) {
        this.render = this.render.bind(element);
        this.cacheDom = this.cacheDom.bind(element);
        this.attachEvents = this.attachEvents.bind(element);
    }

    connectedCallback() {
        this.render();
        this.cacheDom();
        this.attachEvents();
    }

    render() {
        this.attachShadow({ mode: "open" });
        this.shadowRoot.innerHTML = `
            <style>
            :host { position: relative; display: inline-block; }
            #label-button {
                width: 100%;
                height: 100%;
                position: absolute;
                top: 0px;
                left: 0px;
                background-color: black;
                border: none;
            }
            #button-text{
                clip: rect(0 0 0 0);
                height: 0px;
                width: 0px;
                padding: 0px;
                margin: 0px;
                overflow: hidden;
            }
            </style>
            <slot></slot>
            <button id="label-button"><div id="button-text">Toggle to read spoiler</div></button>
            `;
    }
    cacheDom() {
        this.dom = {
            labelButton: this.shadowRoot.querySelector("#label-button"),
            buttonText: this.shadowRoot.querySelector("#button-text"),
            slot: this.shadowRoot.querySelector("slot")
        };
    }
    attachEvents() {
        this.dom.labelButton.addEventListener("click", () => {
   //We'll be filling this in
        });
    }
}

customElements.define("wc-spoiler", WcSpoiler);
Enter fullscreen mode Exit fullscreen mode

There's not much here, just a few elements: A slot to hold the inner content of the spoiler and a button to show it. If we just had this then the screen reader would read out the inner HTML of the wc-spoiler element. To fix this we can give the slot aria-hidden="true" and then when we press it gets aria-hidden="false"

-<slot></slot>
+<slot aria-hidden="true"></slot>
Enter fullscreen mode Exit fullscreen mode

I'll also add a private property to keep track of the state since it'll be used in a few places.

#isShown = false;
attachEvents() {
  this.dom.labelButton.addEventListener("click", () => {
    this.#isShown = !this.#isShown;
    this.dom.slot.ariaHidden = this.#isShown ? "false" : "true";
  });
}
Enter fullscreen mode Exit fullscreen mode

Now we also want to visually un-obscure the content. A good accessible way to do this is to combine the aria state and the CSS. In this case we have a toggle button which is a button with a pressed state.

-<button id="label-button"><div id="button-text">Toggle to read spoiler</div></button>
+<button id="label-button" aria-role="switch"><div id="button-text">Toggle to read spoiler</div></button>
Enter fullscreen mode Exit fullscreen mode

The role="switch" will let the screen reader know that's what we wanted. Now we can update the CSS:

#label-button[aria-pressed="true"] {
  background-color: transparent;
}
Enter fullscreen mode Exit fullscreen mode

We'll remove the redacted stripe when pressed. To press it we simply add that property on click:

attachEvents() {
    this.dom.labelButton.addEventListener("click", () => {
        this.#isShown = !this.#isShown;
        this.dom.slot.ariaHidden = this.#isShown ? "false" : "true";
        this.dom.labelButton.ariaPressed = this.#isShown ? "true" : "false";
    });
}
Enter fullscreen mode Exit fullscreen mode

This will toggle both the state so screen readers can properly communicate that to the user as well as show the spoiler text.

However while testing there's an issue. When you toggle the spoiler you don't get any feedback. It doesn't read the spoiler which is what I think should happen. I tried adding aria-live to the element and some sub-elements but that didn't seem to work, it would never read when changed, possibly because the aria-hidden change happens in the same tick, I'm not sure. Screen reader tech is finicky. So I opted to do a little more heavy handed approach.

We can use specific live areas to force the screen reader to programmatically announce things. First we create a live announce div:

<div id="live-area" aria-live="polite"></div>
Enter fullscreen mode Exit fullscreen mode

The polite is a good default, we don't specifically need to interrupt the user. Then when we toggle we'll write the textContent of the spoiler element to that tag:

attachEvents() {
    this.dom.labelButton.addEventListener("click", () => {
        this.#isShown = !this.#isShown;
        this.dom.slot.ariaHidden = this.#isShown ? "false" : "true";
        this.dom.labelButton.ariaPressed = this.#isShown ? "true" : "false";
        this.dom.liveArea.textContent = this.#isShown ? this.textContent : "";
    });
}
Enter fullscreen mode Exit fullscreen mode

This will cause the screen reader to read the spoiler after it finishes announcing the button state.

Once the content is unobscured with aria-hidden="false" the screen reader will read it normally. The one thing I'm less sure about is if tab selecting truly makes sense since inline spoilers go in content flow. It may not be very intuitive. But at least it seems to work both visually and for screen readers.

Conclusion

If you are doing your own spoilers <summary> and <detail> are the easiest way for toggle spoiles. For inline it's actually kinda hard. I spent a lot of time testing and I'm still not exactly sure if the experience is quite right. It would be nice if the platform included this element.

Code can be found here:

Top comments (2)

Collapse
 
kaikubasta profile image
Kai Kubasta

Hey there! 👋 Just FYI: I had a similar idea for an article and mentioned yours in mine.

Collapse
 
ndesmic profile image
ndesmic

Very cool to see some other people pick up the topic and find ways to solve it. Your article is very informative.