Applying CSS transitions to Hugo blog posts

Using a simple JavaScript hack and a bit of CSS styling

I wanted to include some on-scroll content transitions to blog posts on my Hugo-based website, which I was just finished porting over from the initial Wordpress + LayTheme design. Turns out it’s fairly easy using JavaScript’s IntersectionObserver API and some Hugo magic.

CSS transitions

Consider this (slightly modified) “Multiple animated properties example” from Mozilla Developer Docs, showing how CSS transitions work:

A div element:

<div class="box">Sample</div>

styled with this CSS:

.box {
  color: white;
  border-style: solid;
  border-width: 1px;
  display: block;
  width: 100px;
  height: 100px;
  background-color: #0000ff;
  transition:
    width 2s,
    height 2s,
    background-color 2s,
    rotate 2s,
    color 2s;
}

.box:hover,
.box:active {
  color: black;
  background-color: #ffcccc;
  width: 200px;
  height: 200px;
  rotate: 180deg;
}

will result in a box that transforms as soon as you hover over it or depress it (for use on smartphones):

Simple?

Simple. Two classes, one for the “default” state and another one to define the transition to be applied.

You could now use Markdown Attributes to apply those CSS classes to any of your Markdown content in Hugo and be done.

IntersectionObserver API

However, the particular transition I am after is an ease-in transform, which looks like this:

In case you got distracted: the photo slides in slightly from the bottom when scrolling. To make that happen we need to kick the transition off as soon as the photo starts showing on the screen — or, technically speaking, showing in viewport. To get that working, I can leverage IntersectionObserver API, as explained on Stack Overflow:

const inViewport = (entries, observer) => {
  entries.forEach(entry => {
    entry.target.classList.toggle("is-inViewport", entry.isIntersecting);
  });
};

const Obs = new IntersectionObserver(inViewport);
const obsOptions = {}; //See: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#Intersection_observer_options

// Attach observer to every [data-inviewport] element:
document.querySelectorAll('[data-inviewport]').forEach(el => {
  Obs.observe(el, obsOptions);
});

The code is pretty self-explanatory (reading from the bottom up):

  • For each element matching [data-inviewport] selector, have it observed by the Obs (which is an object of IntersectionObserver class).
  • Whenever an observed element intersects with browser’s viewport, Obs invokes inViewport callback function.
  • inViewport, in turn, toggles the is-inViewport class on observed element.

Since the elements intersect both when they appear in the viewport and disappear from it, the is-inViewport class effectively tells us whether or not any portion of an observed element is visible on the screen or not. Neat.

You could now define some classes for different transformations, e.g.

[data-inviewport="scale-in"] {
  transition: 2s;
  transform: scale(0.1);
}
[data-inviewport="scale-in"].is-inViewport { 
  transform: scale(1);
}

and add a data-inviewport="scale-in" attribute to every HTML element you wanted to apply the scale-in effect to. Unfortunately, in Hugo, this typically means you’d have to modify the theme itself, e.g. its shortcodes for <<figure>> or <<highlight>>, and ideally you want to avoid that.

The alternative is to use a different selector in querySelectorAll, one that would capture elements by their other classes previously applied or by their type. For example: querySelectorAll('.highlight, .post-content img') would get is-inViewport class toggled on:

  • any element that already has a .highlight class assigned, i.e. is a code block;
  • and on any <img> element that is a descendant of any element with .post-content class, such that only images added as posts content have that transition applied — otherwise the selector would capture all of the images on the website, including your logo, etc.

CSS styling to accompany that could look like this:

.highlight,
.post-content img {
  transition: 2s;
  transform: scale(0.1);
}

.highlight.is-inViewport,
img.is-inViewport {
  transform: scale(1);
}

Notice that the second selector does not use .post-content class restriction, given that .is-inViewport already limits it to the elements of our interest.

To finish this off, you’d save the CSS above into assets/css/extended folder (under any file name, Hugo will include it automatically) plus the JavaScript piece above to e.g. static/js/viewportobserve.js. For the latter, you also need a corresponding <script> tag added to the HTML’s body to have the script actually loaded together with your website:

<script src="{{ "js/assets/js/viewportobserve.js" | absURL }}"></script>

This is typically added to layouts/partials/extend_footer.html or a similar partial file — there’s no rule, as it’s merely a convention adopted by Hugo theme authors.

Templating perfection

The last step of improvement is to avoid having to modify the .js file whenever you want to change the element selector. Preferably, we’d want it as a parameter in hugo.toml (or hugo.yml) config file:

[params]
observeViewportSelectors = ".highlight, .post-content img'"

For that we need to convert the JavaScript code to incorporate Hugo templates and its Go functions:

const inViewport = (entries, observer) => {
  entries.forEach(entry => {
    entry.target.classList.toggle("is-inViewport", entry.isIntersecting);
  });
};

const Obs = new IntersectionObserver(inViewport);
const obsOptions = {};

{{ if .Site.Params.observeViewportSelectors }}
const selector = "{{ .Site.Params.observeViewportSelectors }}";
// Attach observer to every element requested:
document.querySelectorAll(selector).forEach(el => {
    Obs.observe(el, obsOptions);
});
{{ end }}

Lastly, we change the script inclusion directive to have Hugo pre-process the now templated .js file using ExecuteAsTemplate function:

{{- $viewportobserve := resources.Get "js/viewportobserve.js" | resources.ExecuteAsTemplate "viewportobserve_processed.js" . | resources.Minify }}
<script crossorigin="anonymous" src="{{ $viewportobserve.RelPermalink }}"></script>

A notable difference here is that the script file now needs to be placed inside assets/js/, since it no longer is just a static file, but instead a resource we retrieve using resources.Get for further processing.

Done. Now you can observe any elements getting in/out of the viewport and apply any changes to them using CSS styles.

Discussions around the web

No comments yet