Spoilers!

2025-11-27

As part of my writeup of the CHCon 2025 Badge Challenge, I made some simple and stylish components for hiding and showing spoilers. It only needs a few lines of Javascript and CSS:

Spoilers!

As revealed by The Empire Strikes Back, Darth Vader is Luke's father and Leia is his sister

Individual spoilers

Spoilers on websites are normally obfuscated until you press on them, or hover over them. Each spoiler works independently, so viewing one spoiler doesn't automatically expose you to other spoilers. In some cases, spoilers can have their own nested spoilers.

So far, everything is achieved with pure CSS:

.spoiler {
  filter: blur(10px) grayscale(100%); /* blur works well, grayscale further obfuscates images */
  /* these 'transition' properties affect the 'hover' → 'default' transition */
  transition-timing-function: ease-in;
  transition-delay: initial; /* no delay, blur immediately */
  transition-duration: 200ms; /* relatively quick transition */
}

.spoiler:hover {
  filter: initial; /* no blur, full colour */
  /* these 'transition' properties affect the 'default' → 'hover' transition */
  transition-timing-function: ease-in;
  transition-delay: 200ms; /* delay a bit, to reduce the risk of accidental reveals */
  transition-duration: 500ms; /* reveal takes a bit longer than blur */
}

Spoiler groups

To expose multiple spoilers at once and keep them visible, they can be grouped into a "spoiler group", where a parent container hosts multiple child spoilers.

Press anywhere on this paragraph to show or hide spoilers. There are several spoilers in this block, including the following table:
Room Where you left your glasses
Bedroom 2x in top drawer
Kitchen 1x behind fruit bowl

In this example, a simple event listener on the quote block toggles a reveal CSS class:

<div class="spoiler-group" onclick="classList.toggle('reveal')">
  ...
</div>

We can add a .spoiler-group.reveal selector to the previous rules to represent the relationship:

/* ... */
.spoiler:hover,
.spoiler-group.reveal .spoiler {
  filter: initial;
  transition-timing-function: ease-in;
  transition-delay: 200ms;
  transition-duration: 500ms; 
}

Spoiler Toggles

For the last piece of the puzzle, we need a clear, visible, and accessible way to toggle spoiler visibility inside a spoiler group. We can use a checkbox component inside the group to toggle the spoiler group's reveal CSS class, and dress it up to look like a two-state button with a Material Icons icon:

<label class="spoiler-control">
  <input type="checkbox"/> <span class="spoiler-icon"></span>
</label>

We use CSS to hide the checkbox, and toggle the icon based on the checkbox state.

/* minimise the checkbox while keeping it accessible */
.spoiler-control input {
  opacity: 0; width: 0; height: 0; margin: 0;
}

.spoiler-control .spoiler-icon::after {
  cursor: pointer;
  color: #376380;
  font-style: normal;
  font-family: 'Material Icons';
  vertical-align: bottom;
  content: "\e8f5" /* visibility off */
}

.spoiler-control input:checked + .spoiler-icon::after {
  content: "\e8f4" /* visibility on */
}

Putting it all together

Given the the spoiler toggle is nested inside the spoiler group, we can use the use the checkbox's change event handler to walk the DOM and toggling the reveal class on the nearest spoiler group. Here's a similar example, with the spoiler toggle instead:

Section with multiple spoilers

Press the spoiler toggle to show or hide spoilers. There are several spoilers in this block, including the following table:

Room Where you left your glasses
Bedroom 2x in top drawer
Kitchen 1x behind fruit bowl

This time, the spoiler control handles updating the reveal class on the spoiler group:

<label class="spoiler-control">
  <input type="checkbox" onchange="closest('.spoiler-group').classList.toggle('reveal')"/>
  <span class="spoiler-icon"></span>
</label>

If you have feedback or questions about this article, let's catch up via Mastodon or email.