Share

JavaScript spoiler with a twist (collapsing up or down)

I thought that I will eventually need to use spoilers in this blog to hide long and optional blocks of text. And at the same time I’m often unhappy with collapsible block implementations across the Internet.

So I decided to design my own spoiler with one useful feature: it can be collapsed not only from the top edge but also from the bottom edge — you will not lose the line you were reading and you don’t have to scroll all the way to the beginning of the spoiler.

Examples:

click to showhide

This spoiler can be expanded up and down. And your mouse is left right over the button you just pressed whenever it is possible (on top and bottom of a page there may be not enough stuff to keep it so, obviously).

click to showhide
click to showhide

This spoiler can be expanded in one direction only. Collapsing behaviour is still the same. The difference is just a few lines in CSS.

click to showhide

Keep reading to see how it is done.


Existing solutions

Basic jQuery spoiler block may look like this:

Usually, implementations you can find across the Internet are not far from this one in terms of behaviour and look.

This code has following issues:

  • exactly the issue that motivated me to write this post — it’s not possible to hide a wall of text after you reached its end;
  • generic phrase “click to show/hide” does look like lazy and incomplete implementation;
  • usually it’s harder to style things that alter their own structure in DOM tree;
  • interface elements like show/hide buttons better not to behave like text (being selectable for example).

Improvements

The first modification I have to do is to add the second spoiler button below the spoiler body, so it will be accessible right after reading.
Then I need a bit more complex JavaScript logic to control the expansion and contraction. There are few options available, depending on requirements.
Finally, some polishing is done, including proper show/hide labels and simplified version for those who find my main version somewhat confusing.

Fixed points

For the spoiler toggle actions there are two cases:

  • normal, when spoiler expanded down or collapsed up (content above spoiler should be static);
  • inverted, when spoiler expanded up or collapsed down (content below spoiler should be static).

In the first case there is nothing to do — collapsing or expanding spoiler is not affecting anything above itself.

In the second case we need to compensate movement. Amount of movement is determined by spoiler height change (delta between element size before and after).

We can think of it in terms of what points we keep fixed on screen. In normal case it’s an upper edge of a spoiler that remains in place, and in inverted case it’s a lower border of a spoiler that should not move.

Parameter that we need to control is the vertical scrolling on a page, and the window.scrollTo() functions can be used for that (there is also window.scrollBy() but it may be more tricky to use in some edge cases).

Location of the bottom edge of a spoiler on screen can be determined as (Spoiler_top + Spoiler_height − Screen_offset) and we need to keep that constant.

(Spoiler_top_0 + Spoiler_height_0 − Screen_offset_0) = (Spoiler_top_1 + Spoiler_height_1 − Screen_offset_1)

Spoiler_top is not affected by any changes (no content above the spoiler is changed) and can be ruled out of equation:

(Spoiler_height_0 − Screen_offset_0) = (Spoiler_height_1 − Screen_offset_1)

Now rearrange the terms:

(Screen_offset_1 − Screen_offset_0) = (Spoiler_height_1 − Spoiler_height_0)

In other words, screen offset delta is equal to spoiler height delta. Rearrange once more:

Screen_offset_1 = Screen_offset_0 + (Spoiler_height_1 − Spoiler_height_0)

We should scroll to offset defined as the initial offset plus spoiler height delta. (If spoiler is shrinking then delta is negative and we scroll up, following the bottom edge of the spoiler.)

No animation, no jQuery

We, basically, just toggle some class for the spoiler, measure the height change and apply it to the offset when needed.

Animation with jQuery

In jQuery, expand/collapse animation is done with slideToggle() function. There is a “progress” callback function that has to be used to run our scroll compensation code, and “complete” callback that we also should call to make sure that compensation is applied after the last frame of animation.

jQuery animations are enqueued by default, which makes them robust to pathological user input (even if user will be mashing buttons, animation will finish in predictable state).

Animation without jQuery

Here is my vanilla implementation for slide animation, using window.requestAnimationFrame(). It is not complete slideToggle() replacement though — only the features I needed to make it work. But still it might be more capable than other implementations you can find in the web.

This one is relatively long:

click to showhide
click to showhide

Notes about this implementation:

  • I’m not trying to guess if it is opening or closing action, so it’s more like just slideUp() + slideDown();
  • options object with duration, progress and complete parameters — I’m mimicking jQuery here;
  • getHeight() function to measure elements even if they are not displayed (spoiler is not displayed by default, obviously) — from this SO answer, but with a little modification to reduce the amount of traces left in styles;
  • no animation queue. That’s what makes jQuery solution very robust, but is tricky to do in vanilla JS. Probably better solution would be to detect that other animation is started and stop already running one. Other animation should run from where previous one left. But I have to stop somewhere.

One thing to keep in mind: this scrolling will interfere with user scrolling, so keep animations short (if at all present).

CSS animation?

CSS transitions and keyframes might be good alternatives to JavaScript in many cases. But in this particular case it’s important to synchronize expanding/collapsing with scrolling, so they are of no help here — css and js animation loops will not run perfectly synchronized and there will be some shaking on top of our animation. Even with JavaScript-only solution, a little bit of shaking can be seen below the spoiler in some cases, because of rounding to nearest pixel during animation.

Click to show or hide

The only thing left is a proper instruction for the user on how to operate spoiler block. I.e. showing correct text on buttons: “click this to show” or “click this to hide”. My approach is pure HTML and CSS:

click to <span class="show">show</span><span class="hide">hide</span>

.spoiler .show, .spoiler.expanded .hide {
    display: inline;
}
.spoiler .hide, .spoiler.expanded .show {
    display: none;
}
.spoiler-btn {
    cursor: pointer;
    user-select: none;
}

Another touch here is to set user-select property (caniuse) to none for buttons, since they are a part of UI and not text. Unfortunately, Chrome will still be putting them to clipboard when copied with surrounding text — it’s a know issue. (UPD: It was fixed in February, 2018.)

For icons, on my site I decided to use chevrons, showing the direction where spoiler will expand (and reverse to the direction where it will collapse). Alternative approach would be to switch chevron direction 180 degrees when expanded. Not sure which one is better, but at least it’s quite clear that turning the chevron 90 degrees is not a viable option — it’s better to show the direction of the action.

Version with no expanding upwards

For collapsed spoiler, we might opt out from expanding upwards — one UI element less on screen and probably less confusion for some people.

Just few lines added in CSS:

.spoiler.simple .spoiler-btn {
    width: 100%;
}
.spoiler.simple .spoiler-btn-bottom {
    display: none;
}
.spoiler.simple.expanded .spoiler-btn-bottom {
    display: block;
}

This version is closer to conventional design you can see anywhere, but the first one is something I like more, at least for my site.

Complete example

This example uses jQuery and includes HTML and CSS tweaks mentioned above. Changing it to no animation or vanilla JS slide animation should be easy with minimal changes — just compare examples above.

Scroll Anchoring in browsers

Chrome has this somewhat relevant feature. With it in mind, it’s possible to close long spoiler from below, without manual offset control and the page will not scroll too far — just until the moment when spoiler hides from screen. It works only when reflow is happening completely above the upper edge of screen. And it also not synced well with animated collapsing.

comments powered by Disqus