DEV Community

Cover image for Enhance CSS view transitions with Velvette
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

Enhance CSS view transitions with Velvette

Written by David Omotayo✏️

Page transitions are pivotal in shaping user experience in modern web design and applications. Tools like CSS transitions and the Web Animations API help create visual cues for navigation and indicate navigation flow.

Effective page transitions also help reduce cognitive load by helping users maintain context and perceive faster loading times. However, implementing these from scratch can be quite complex due to the CSS and JavaScript boilerplate code required, managing the state of elements, and ensuring accessibility when both states are present in the DOM.

The CSS View Transitions API tackles most of these challenges, but can be difficult to work with for its own reasons — for example, the fact that it’s a novel API with diverse usage. This is where tools like Velvette come into play.

Velvette is a library that simplifies view transition implementations and helps mitigate these challenges. In this article, we'll introduce Velvette, explore its features, and explain how to integrate it into existing projects.

A quick primer on CSS View Transitions

The CSS View Transitions API introduces a way to smoothly change the DOM while simultaneously animating the interpolation between two unrelated states without any overlap between them.

The underlying logic that makes this work is that the browser captures an element and takes two snapshots: one of the old state before the change and another of the new state after. To make this work, you need two parts.

First, you need the view-transition-name property assigned to the element’s selector in your stylesheet:

.element{
  view-timeline-name: transition-name;
}
Enter fullscreen mode Exit fullscreen mode

Second, you need the method that updates the DOM wrapped in the document.startViewTransition() function:

document.startViewTransition(() =>
  updateTheDOMSomehow();
);
Enter fullscreen mode Exit fullscreen mode

This declaration instructs the browser to capture the snapshots, stack them unto each other, and create a transition using a fade animation.

The challenges of the CSS View Transitions API

Developers who have worked with the View Transitions API since its release have likely encountered one or more of the following challenges:

  • Unique name generation: CSS View Transitions requires each element to have a unique name shared between its old and new states. Defining these names for multiple elements can become tedious quickly
  • Scoped transitions: View Transitions affects the entire document, which can lead to a lot of pointless captures on a page with multiple transitions
  • Navigation handling: Implementing transitions based on specific navigation patterns can be difficult and require significant boilerplate JavaScript code

Now, let’s see how Velvette can mitigate these challenges.

Introducing Velvette

Velvette is a utility library developed to make working with view transitions easier. It tackles issues like redundant boilerplates and monotonous generation of unique names, allowing developers to focus on crafting smooth animations.

The library offers a declarative way to manage transition behavior in your application. You can define transitions for isolated elements — elements that operate independently — or in response to navigation events. These declarations are then seamlessly integrated with the View Transitions API.

Velvette's key features include:

  • Adding temporary classes: Velvette dynamically adds temporary classes to the document element during the transition process. These classes serve as markers for capturing the different states of the transition. For example, when transitioning from a list view to a details view, Velvette might add a class like morph during the transition
  • Constructing styles: While the transition is animating, Velvette constructs additional styles. These styles define how the elements should appear during the transition. For instance, if you’re fading out a list view and fading in a details view, Velvette will assign necessary classes that handle opacity, animation timing changes, and other visual adjustments
  • Assigning view-transition-name properties: The view-transition-name property is crucial for specifying which elements participate in the transition. Velvette generates and sets these properties based on predefined rules. This ensures that the correct elements are animated during the transition

In the upcoming sections, we’ll see more about how Velvette works and how to get started with it in your next project.

Velvette building blocks

Velvette provides two key functions: a Velvette constructor and a startViewTransition method. These functions offer simplified methods for extending view transitions in response to a DOM update, catering to specific requirements.

The startViewTransition method

The startViewTransition method is ideally used for integrating straightforward transition animations, like sorting animations, to one or multiple elements on a page. It eliminates the need to manually declare transition names and avoids unnecessary captures.

The method accepts an object containing configuration options as its arguments:

startViewTransition({
  update: () => {...},
  captures: {...},
  classes: ...,
});
Enter fullscreen mode Exit fullscreen mode

Here is a breakdown of the argument object:

  • update: This is a callback function that defines how the DOM will be updated during the transition. For example, if you have a separate function named updateTheDOM that handles DOM manipulation, you would pass that function as the update argument
  • captures: This object allows you to define elements to be captured for the transition. It uses a key-value structure. Keys are selectors for the elements you want to capture (e.g., class names, IDs), and the values define how to generate unique view-transition-name properties (often using element IDs or other unique identifiers)
  • classes: This is an optional array of CSS class names that will be temporarily added to the document element during the transition. Adding these classes can be useful for applying specific styles during the animation

The Velvette constructor

The Velvette constructor is designed for creating complex view transition animations across page navigation. A typical example is a smooth image transition — like expanding or shrinking — when a user navigates between a list and a detail page.

Similar to startViewTransition, the constructor accepts a config object with various options as its argument:

const velvette = new Velvette({
  routes: {
      details: ""..."
      list: "..."
  },
  rules: [{
      with: ["list", "details"], class: ...
  }, ],
  captures: {
    ...
  }
});
Enter fullscreen mode Exit fullscreen mode

Here is a breakdown of the config options:

  • routes: This object defines named routes for different views in your application. It uses key-value pairs, where the keys are route names and the values can be URLs that uniquely identify the view
  • rules: This is an array of rules that match specific navigation patterns. Each rule defines which navigations trigger a view transition and specifies the class and parameter to associate with the transition
  • captures: Similar to the startViewTransition method, this option allows you to define elements to capture during navigation transitions. This provides more granular control over the elements involved in the animation

Installing and setting up Velvette

Velvette is built as an add-on, which means we can add it to existing projects by simply including the following script tag into the index.html file:

><script src="https://www.unpkg.com/velvette@0.1.10-pre/dist/browser/velvette.js"></script>
Enter fullscreen mode Exit fullscreen mode

We can also add it with npm using the following command:

>npm install velvette
Enter fullscreen mode Exit fullscreen mode

Integrating Velvette with existing projects

Once Velvette is integrated into your project, you can start using the library by importing the startViewTransition or Velvette constructor in the needed components or pages:

import {Velvette, startViewTransition} from "velvette";
Enter fullscreen mode Exit fullscreen mode

Alternatively, if you've included Velvette using a CDN link, you can simply call the Velvette constructor like so:

const velvette = new Velvette({...});
Enter fullscreen mode Exit fullscreen mode

This is possible because the CDN link automatically injects a global Velvette class directly onto your window object, which can be accessible across the document.

List item animation

Now that you’ve successfully added Velvette to your project, you can replace every vanilla View Transitions implementation in your project with Velvette's.

For example, let’s say you have a to-do application with a base View Transitions implementation like in the following example:

document.addEventListener("DOMContentLoaded", () => {
const items = document.querySelectorAll(".item");
  items.forEach((item, index) => {
    item.id = `item-${index}`;
    item.addEventListener("click", (e) => {
     document.startViewTransition(() => moveItem(e));
    });
  });
});

const moveItem = (e) => {
  const item = e.target;
  var targetBoxId = item.closest(".box").id === "box1" ? "box2" : "box1";
  var targetList = document.getElementById(targetBoxId).querySelector("ul");

  item.parentNode.removeChild(item);
  targetList.appendChild(item);
};
Enter fullscreen mode Exit fullscreen mode

This base implementation would look like so:

See the Pen Todo list transition by david omotayo (@david4473) on CodePen.

In such a case, we can replace the document.startViewTransition() declaration:

document.startViewTransition(() => moveItem(e)); 
Enter fullscreen mode Exit fullscreen mode

With a Velvette declaration as follows:

>Velvette.startViewTransition({
    update: () => moveItem(e)
});
Enter fullscreen mode Exit fullscreen mode

This will invoke Velvette, call the moveItem() function on every item click, and apply the default fade animation to each item on the list when they are removed or appended to either the Tasks or Completed Tasks parent elements.

However, for each item to animate smoothly, it needs a unique view-transition-name value.

Let's suppose we assign a transition name only to the first item on the list :

#item-0{
  view-transition-name: item;
}
Enter fullscreen mode Exit fullscreen mode

As expected, only the first item animates: Demo To Do List Showing User Clicking Items To Move From Tasks To Completed Columns, But With Animation Only Applying To First List Item To achieve the same effect for all the items, we'd traditionally need to assign a unique view-transition-name value to each one, which can be quite tedious. This is where Velvette's captures object comes in. Instead of manual assignment, you can leverage captures to dynamically map between item selectors and assign temporary view-transition-name values during the transition:

Velvette.startViewTransition({
        update: () => moveItem(e),
        captures: {
          "ul#list li[:id]": "$(id)",
        },
      });
Enter fullscreen mode Exit fullscreen mode

Here, we capture every child li element within the #list selector and use the element's id to generate a view-transition-name property.

This may seem a bit overwhelming, so let's break it down. Remember, an id is assigned to each item on the list:

const items = document.querySelectorAll(".item");
  items.forEach((item, index) => {
    item.id = `item-${index}`;
        ...
  });
Enter fullscreen mode Exit fullscreen mode

And their parent elements are assigned a list ID selector:

<div>
        <h2>Tasks</h2>
        <ul id="list">
                ...
        </ul>
      </div>
        <div>
        <h2>Completed Tasks</h2>
        <ul id="list">
                ...
        </ul>
      </div>
Enter fullscreen mode Exit fullscreen mode

The captures object looks for the ul element with the list class selectors in the code above, maps through its li child elements, grabs the ID we assigned in the previous code, and assigns it to their view-transition-name declarations:

captures: {
  "ul#list li[:id]": "$(id)",
},
Enter fullscreen mode Exit fullscreen mode

The view-transition-name declaration for each item on the list will look something like this:

>#item-0{
  view-transition-name: item-0;
}
#item-1{
  view-transition-name: item-1;
}
#item-2{
  view-transition-name: item-2;
}
...
Enter fullscreen mode Exit fullscreen mode

And the result: Updated To Do List With Items Correctly Animating When Moving From Column To Column As you can see, the animation now works correctly for every list item.

Navigation animation

A common use case for the View Transitions API is handling animations during page navigation, essentially transitioning between the outgoing and incoming pages. As mentioned before, a popular example involves animating the navigation between a list view and a details page: Demo Transition Effect When Navigating Between A List View And A Details Page Implementing this transition effect from scratch can be challenging. It typically involves triggering a view transition when navigating between the list and details pages.

One way to achieve this is by intercepting the navigation event and encapsulating the DOM update function — the function that modifies the page content — within the View Transitions API's startViewTransition method.

Here's an example:

async function init() {
  const data = await fetch("products.json");
  const results = await data.json();

  function render() {
    const title = document.getElementById("title");
    const product_list = document.querySelector("#product-list ul");
    product_list.innerHTML = "";

    for (const product of results) {
      const li = document.createElement("li");
      li.id = `product-${product.id}`;
      li.innerHTML = `
      <a href="?product=${product.id}">
      <img class="product-img" src="${product.image}" />
      <span class="title">${product.title}</span>
    </a>
      `;
      product_list.append(li);
    }

    const searchParams = new URL(location.href).searchParams;
    if (searchParams.has("product")) {
      const productId = +searchParams.get("product");
      const product = results.find((product) => product.id === productId);
      if (product) {
        const details = document.querySelector("#product-details");
        details.querySelector(".title").innerText = product.title;
        details.querySelector("img").src = `${product.image}`;
      }
    }

    if (searchParams.has("product")) {
      title.innerText = "Product Details";
    } else {
      title.innerText = "Product List";
    }

    document.documentElement.classList.toggle(
      "details",
      searchParams.has("product")
    );
  }

  render();

 navigation.addEventListener("navigate", (e) => {
    e.intercept({
      handler() {
        document.startViewTransition(() => {
          render();
        });
      },
    });
  });
}

init();
Enter fullscreen mode Exit fullscreen mode

In this code example, we used the Navigation API to intercept navigation between a list and details page and trigger a view transition that is applied to render() function: Using The Navigation Api To Intercept Navigation Between Pages And Trigger A View Transition You can find the complete code for this example in this GitHub repository.

Note that the Navigation API currently has limited browser support — it’s only available on Chromium-based browsers. To ensure good UX for a wider range of users, consider implementing fallback mechanisms for unsupported browsers.

This basic implementation provides a starting point, but achieving a more complex effect requires additional steps.

For example, to morph thumbnails between the list and details pages, we would have to assign identical view-transition-name values to the corresponding thumbnails on both the details and list pages. However, this assignment needs to be done strategically:

  • It shouldn't happen simultaneously to avoid skipping the transition
  • It should be assigned to the specific item involved in the transition on the list

While the following code snippet might require some adjustments, it demonstrates the core concept:

.details-thumbnail {
  view-transition-name: morph;
}

 //Javascript

list-thumbnail.addEventListener(click, async () => {
  list-thumbnail.style.viewTransitionName = "morph";

  document.startViewTransition(() => {
    thumbnail.style.viewTransitionName = "";
    updateTheDOMSomehow();
  });
};
Enter fullscreen mode Exit fullscreen mode

The biggest drawbacks of depending on this method are the unnecessary complexity it introduces and the boilerplate code it adds to your project.

Velvette simplifies this process by offering a centralized configuration system. This configuration handles the heavy lifting behind the scenes, eliminating the need for manual implementation and saving you time and effort:

const velvette = new Velvette({
    routes: {
      details: "?product=:product_id",
      list: "?products",
    },
    rules: [
      {
        with: ["list", "details"],
        class: "morph",
      },
    ],
    captures: {
      ":root.vt-morph.vt-route-details #details-img": "morph-img",
      ":root.vt-morph.vt-route-list #product-$(product_id) img": "morph-img",
    },
  });
Enter fullscreen mode Exit fullscreen mode

This Velvette configuration is a replica of the navigation transition we tried to implement manually earlier using view transition.

In this configuration, we use the routes and rules properties to define which navigation triggers a view transition. In this case, any navigation between the list and details routes will initiate a view transition and add a morph class to the transition:

routes: {
      details: "?product=:product_id",
      list: "?products",
    },
    rules: [
      {
        with: ["list", "details"],
        class: "morph",
      },
    ],
        ...
Enter fullscreen mode Exit fullscreen mode

The captures property tackles the previously mentioned challenge of assigning unique view-transition-name properties during transitions:

captures: {
      ":root.vt-morph.vt-route-details #details-img": "morph-img",
      ":root.vt-morph.vt-route-list #product-$(product_id) img": "morph-img",
    },
Enter fullscreen mode Exit fullscreen mode

Here, we use a key-value pair of selectors and values to assign identical transition names, morph-img, to generate a view-transition-name for both the details page thumbnail and the clicked product item image.

The ":root.vt-morph.vt-route-details #details-img" selector is a combination of:

  • The transition class — vt-morph from the rules object
  • The route where we want to capture the morph transition — vt-route-details
  • The image's selector — #details-img

Note that the vt prefix is required for Velvette to recognize the selectors.

The second selector, ":root.vt-morph.vt-route-list #product-$(product_id) img", uses the same method to add the morph-img transition name to the selected product item during the morph transition. The only difference is that it applies only when in the list route and the ${product_id} expression will be replaced by the product item's ID, like so:

:root.vt-morph.vt-route-list  #product-1 img: ...,
Enter fullscreen mode Exit fullscreen mode

Finally, we can leverage Velvette to intercept navigation and apply the configurations defined above. To achieve this, we'll update the previous navigation declaration as follows:

navigation.addEventListener("navigate", (e) => {
    velvette.intercept(e, {
      handler() {
        render();
      },
    });
  });
Enter fullscreen mode Exit fullscreen mode

Here’s the result: Updated Navigation Declaration With Velvette Showing Smoother Transitions Between The Product Listing And Details Pages

Conclusion

In this article, we introduced Velvette and explored its building blocks and how they work to achieve smoother and more engaging transitions between views. We also explored how to integrate Velvette into existing projects without a total overhaul of your existing code.

While Velvette offers powerful transition capabilities, it's built on the View Transitions API, which currently has limited browser support, so consider implementing fallback mechanisms for browsers that don't support the API.

We've only scratched the surface of what you can achieve with Velvette in this article. If you're eager to learn more about the library, the Velvette documentation offers comprehensive examples that will assist you in getting started.


Is your frontend hogging your users' CPU?

As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.

LogRocket Signup

LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.

Modernize how you debug web and mobile apps — start monitoring for free.

Top comments (0)