The letter A styled as Alchemists logo. lchemists
Published October 1, 2024 Updated June 17, 2026
Cover
htmx View Transitions

View Transitions, if not aware, provide CSS and HTML support for animating state transitions between elements within the same, or different, documents. This allows web applications to use smooth transitions normally only seen with native mobile apps. We’ll use the following stack to animate a deck of slides for presentation purposes:

The assumption is that you are familiar with these languages/libraries but, if not, the code should be quick to intuit and adapt for your own needs. Here’s a demo showing silky smooth next and previous slide transitions as used for all Talks on this site:

View Transitions are a nice solution for this use case so let’s learn how this is implemented.

Setup

Here’s the code we’ll be working with so you can reference them at any time:

CSS
@keyframes slideOutToLeft {
  from {
    transform: translateX(0);
  }

  to {
    transform: translateX(-100%);
  }
}

@keyframes slideInFromRight {
  from {
    transform: translateX(100%);
  }

  to {
    transform: translateX(0);
  }
}

@keyframes slideOutToRight {
  from {
    transform: translateX(0);
  }

  to {
    transform: translateX(100%);
  }
}

@keyframes slideInFromLeft {
  from {
    transform: translateX(-100%);
  }

  to {
    transform: translateX(0);
  }
}

@media (prefers-reduced-motion: no-preference) {
  ::view-transition-group(slide-left),
  ::view-transition-group(slide-right) {
    animation-duration: 0.8s;
    border-radius: 0.5rem;
    overflow: hidden;
  }

  ::view-transition-new(slide-left) {
    animation-name: slideInFromRight;
  }

  ::view-transition-old(slide-left) {
    animation-name: slideOutToLeft;
  }

  ::view-transition-new(slide-right) {
    animation-name: slideInFromLeft;
  }

  ::view-transition-old(slide-right) {
    animation-name: slideOutToRight;
  }
}

@media (display-mode: fullscreen) {
  ::view-transition-group(navigation) {
    z-index: -10;
  }
}

.slide {
  border-radius: 0.5rem;
  box-shadow:
    0.7px 0.7px 0.7px rgba(0, 0, 0, 0.022),
    1.7px 1.7px 1.7px rgba(0, 0, 0, 0.031),
    3.5px 3.5px 3.5px rgba(0, 0, 0, 0.039),
    7.3px 7.3px 7.3px rgba(0, 0, 0, 0.048),
    20px 20px 20px rgba(0, 0, 0, 0.07)
  ;
  height: auto;
  width: 100%;
}

.slide-left {
  view-transition-name: slide-left;
}

.slide-right {
  view-transition-name: slide-right;
}
HTML
<section class="body"
         hx-select-oob="#slide, #actions"
         hx-swap="transition:true">

  <img id="slide"
       class="slide"
       src="/images/talks/ruby_function_composition/001.jpeg"
       width="1920"
       height="1080">

  <div id="actions" class="actions">
    <a href="/talks/ruby_function_composition/020.html"
       class="link"
       hx-get="/talks/ruby_function_composition/020.html"
       hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
       data-direction="backward">
         Previous
    </a>

    <a href="/talks/ruby_function_composition/002.html"
       class="link"
       hx-get="/talks/ruby_function_composition/002.html"
       hx-trigger="click, keyup[key=='ArrowRight'] from:body"
       data-direction="forward">
         Next
    </a>
  </div>
</section>
JavaScript
(function(global) {
  "use strict";

  document.addEventListener("htmx:beforeTransition", function(event) {
    const slide = htmx.find("#slide");
    const data = event.detail.elt.dataset;

    htmx.removeClass(slide, "slide-left");
    htmx.removeClass(slide, "slide-right");

    if (data.direction === "forward") {
      htmx.addClass(slide, "slide-left");
    } else if (data.direction === "backward") {
      htmx.addClass(slide, "slide-right");
    };
  });
})(typeof window !== "undefined" ? window : global);

Now we can detail how each file works by starting with the stylesheets (CSS).

CSS

The CSS isn’t complicated, but has some nuance, so this has been broken down into sub-sections so you can understand how the styles are being applied.

Keyframes

View Transitions are built upon CSS Animations which means we can use @keyframes to define how our slides should be animated:

@keyframes slideOutToLeft {
  from {
    transform: translateX(0);
  }

  to {
    transform: translateX(-100%);
  }
}

@keyframes slideInFromRight {
  from {
    transform: translateX(100%);
  }

  to {
    transform: translateX(0);
  }
}

@keyframes slideOutToRight {
  from {
    transform: translateX(0);
  }

  to {
    transform: translateX(100%);
  }
}

@keyframes slideInFromLeft {
  from {
    transform: translateX(-100%);
  }

  to {
    transform: translateX(0);
  }
}

You’ll notice, four keyframes are used because we need to animate the old (existing) slide out of frame while animating the new slide into frame. We need to do this when moving to the next or previous slide. If it helps, you can group these keyframes into pairs:

  • Next (forward): slideOutToLeft is used to move the old slide out of frame to the left and slideInFromRight is used to move the new slide into frame from the left. This creates the fluid animation of having the new slide push out the old slide to the left.

  • Previous (backward): slideOutToRight is used to move the old slide out of frame to the right and slideInFromLeft is used to move the new slide into frame from the right. This creates the fluid animation of having the new slide push out the old slide to the right.

The above pairings are used in the ::view-transition-new and ::view-transition-old pseudo elements because, once the keyframes are defined, you need to map them to the corresponding new and old transitions so the View Transitions know how to capture the old (before) state and transition to the new (after) state.

In addition to the new and old transitions, styles for the left and right groups are defined as well because the ::view-transition-group pseudo element takes precedence over both the ::view-transition-new and ::view-transition-old pseudo elements:

::view-transition-group(slide-left),
::view-transition-group(slide-right) {
  animation-duration: 0.8s;
  border-radius: 0.5rem;
  overflow: hidden;
}

The above informs View Transitions how long to animate the transitions, ensures the slide’s border radius is not lost during transition, and all overflow is hidden. Use of overflow: hidden is critical because it constrains your viewport so that, as the slides are pushed out of frame (either left or right), they disappear. Otherwise, you’d see both slides moving out of your viewport which would ruin the illusion.

Media Queries

In the previous section, we jumped ahead to talk about the ::view-transition-* pseudo elements, so let’s step back to discuss the Media Queries wrapping them.

The first is prefers-reduced-motion: no-preference and is necessary to ensure anyone sensitive to motion can disable view transitions. In this case, only View Transitions is used if no-preference is set. Otherwise, the feature is disabled.

The second media query is: display-mode: fullscreen. This might seem odd to discuss because explaining the Fullscreen API is out of scope for this article but is important to give you brief overview because this is an interesting niggle should you want to enable fullscreen viewing of your slides.

For simplicity, assume my site uses a sticky navigation header as follows:

.header {
  view-transition-name: navigation;
  z-index: 20;
}

The z-index ensures my sticky header remains atop site content while view-transition-name ensures my sticky header is part of the View Transitions painting process.

At this point, you might be thinking: "Wait, what!?! The z-index only changes when in fullscreen mode and we’re not in fullscreen mode yet." You’re correct but what’s interesting — and this is subtle — is any element without a view-transition-name is ignored in the painting process. In this case, we need the sticky header to be included in order to pick up the z-index so using view-transition-name: navigation makes this possible. If that doesn’t make sense, here’s what the screen painting process looks like when view-transition-name: none is used in the .header class instead:

The above is definitely awkward and not what we want. If we ensure the .header class has view-transition-name: navigation, then the z-index is honored and the screen painting properly overlays our sticky header during screen painting:

Great. We solved the first piece of the puzzle but what happens when we view our slides in fullscreen mode? That’s where our our display-mode: fullscreen media query kicks in by ensuring our z-index is -10. In other words, we hide the sticky header and, yes, this is the exact opposite of what we did above. To illustrate, here’s what fullscreen mode looks like without the media query:

Broken

The above is not what we want but, if we use the media query, we are able to view our slides in fullscreen mode without any additional DOM elements showing up:

Broken

Unfortunately, we don’t have time to explore fullscreen support further but being aware of how View Transitions work in these kinds of situations is useful. Should you want more, this W3C Issue is a good read.

Classes

The rest of the CSS only consists of the following classes:

  • .slide: Provides styling for the current slide with rounded corners, box shadow, and responsive width and height settings when viewing on smaller screens.

  • .slide-left: Provides a view transition modification for only sliding left by specifying the view-transition-name.

  • .slide-right: Provides a view transition modification for only sliding right by specifying the view-transition-name.

The sole purpose of the .slide-left and .slide-right classes are to dynamically be applied to the HTML .slide element via custom JavaScript code in order to control the direction of the view transition animation. More on this shortly.

htmx

As first shown above — and for quick reference — here’s the HTML fragment that uses a section element to encapsulate all htmx functionality:

<section class="body"
         hx-select-oob="#slide, #actions"
         hx-swap="transition:true">

  <img id="slide"
       class="slide"
       src="/images/talks/ruby_function_composition/001.jpeg"
       width="1920"
       height="1080">

  <div id="actions" class="actions">
    <a href="/talks/ruby_function_composition/020.html"
       class="link"
       hx-get="/talks/ruby_function_composition/020.html"
       hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
       data-direction="backward">
         Previous
    </a>

    <a href="/talks/ruby_function_composition/002.html"
       class="link"
       hx-get="/talks/ruby_function_composition/002.html"
       hx-trigger="click, keyup[key=='ArrowRight'] from:body"
       data-direction="forward">
         Next
    </a>
  </div>
</section>

The same code can be used to render the first slide including all subsequent slides by using Ruby to dynamically build HTML fragments for all slides in the talk. This is trivial to implement because once you’ve exported from Keynote, for example, Ruby can iterate through each slide and generate the corresponding HTML fragment. Perfect for statically generated web sites like this one.

You’ll also notice that the previous and next links are for moving backwards and forwards through the slides while htmx is wired in via the hx-* attributes. There are a few key concepts to call attention to:

  • The section contains the slide, actions, and defines out of bounds swap behavior for them. This might seem counterintuitive because, normally, htmx will automatically swap the content of the incoming response for the current element which is the section element but this is not what we want. In this case, we have a response which updates both the slide and the corresponding actions. The slide needs the View Transitions while the actions don’t. This is why the out of bounds select (i.e. hx-select-oob="#slide, #actions") is used to select the slide from the HTTP GET response. In other words, we only swap in the elements that are changing.

  • The image uses slide as the ID and the class. The ID is for unique identification and targeting purposes while the class is for styling.

  • The actions contain the previous and next links. Once again, the ID is used for identification and the class for styling. This also leverages hx-trigger to add a keyboard shortcut so you can use the previous (←) and forward (→) links to navigate between slides. The data-direction attribute is used, by custom JavaScript code, to ensure the right animations are applied (more on this soon) when changing directions.

Now that you know how the CSS, HTML, and htmx code works, let’s put this all together with a tiny bit of JavaScript.

JavaScript

The greatest strength of htmx is you can do most of what I need without additional JavaScript. Unfortunately, in this case, we need a little extra glue tie all of this together as shown below:

document.addEventListener("htmx:beforeTransition", function(event) {
  const slide = htmx.find("#slide");
  const data = event.detail.elt.dataset;

  htmx.removeClass(slide, "slide-left");
  htmx.removeClass(slide, "slide-right");

  if (data.direction === "forward") {
    htmx.addClass(slide, "slide-left");
  } else if (data.direction === "backward") {
    htmx.addClass(slide, "slide-right");
  };
});

With the above, the Immediately Invoked Function Expression (IIFE) is removed, as shown originally, so we can focus on the core functionality.

First off, an event listener is added for the htmx beforeTransition event. This let’s us hook into htmx by applying custom functionality before the View Transitions execute. This is perfect for applying the classes necessary to controlling animation direction when moving next/forward or previous/backward. These are the same classes mentioned earlier which you can now see how they are used. If we read this code from top to bottom, here’s what’s happening:

  1. The htmx find function is used to acquire the current slide by unique ID.

  2. The slide’s data is stored for convenience and will either be forward or backward as a value.

  3. The slide-left or slide-right classes are removed (if any) as mentioned earlier in the CSS section. Again, these two classes change the slide’s view transition behavior based on whether you are moving forward or backward in the slides.

  4. Lastly, we check to see if we are moving forward or backward based on the data direction and apply the proper CSS class.

Th reason this tiny bit of custom JavaScript code is necessary is because we can’t know which direction the user wants to move through the slides until after the user has clicked the next or previous link. Only then do we know their intent as provided by the associated data-direction value. Then, with this knowledge, we apply the correct class (i.e. slide-left or slide-right) before the view transition fires in order to ensure the correct animation is applied for a smooth user experience.

Debugging

You can use Google Chrome, for debugging purposes, since you can quickly view issues with your transitions/animations via the following:

  1. Launch developer tools via OPTION + COMMAND + i.

  2. Search for animation via SHIFT + COMMAND + p and hitting ENTER once found.

  3. Within the Animations panel, you can slow them down to 10%, for example, and then toggle to your Elements tab to watch the view transitions code be applied (or start/stop the animation as desired).

💡 The ::view-transition pseudo element will appear in the DOM just below the html element. You can click on it to drill down into the tree to see all styles being applied.

Library

If you would rather have a library that does all of this for you, well, you’re in luck! Enter: htmx Slide. This library builds upon everything discussed above, powers the Talks on this site, and allows you to apply your own custom animations. Feel free to use in your own work as well. 🚀

Resources

Assuming you are comfortable with everything described in this article, understanding how View Transitions work will be your biggest learning curve so you might find these articles useful for learning more:

  • View Transitions: This is the Google Chrome Developer’s article on this subject — linked to multiple times already — but this should be your first stop.

  • View Transitions API And Delightful UI Animations Part 1 and Part 2 by Adrian Bece (Smashing Magazine): This delves deeper into view transitions along with more examples and use cases.

Conclusion

You’ve learned about View Transitions while learning how to incorporate htmx with only a tiny amount of JavaScript. This is also a great time to be improving your web application user experience as well. These transitions are so much more easier to implement than previously possible due to the sheer amount of custom JavaScript required which you no longer have to maintain.