DEV Community


Posted on • Updated on

Svelte Tutorials Learning Notes: Transitions

Note about this format: I found that writing “explainer” notes gave me a stronger, deeper understanding of whatever I was learning. On the other hand, I often felt stupid for having to slow down and spell things out; for not grokking everything immediately, magically, effortlessly 🙃.

In the spirit of the new(ish) year, I figured sharing my learning notes publicly might be beneficial: it would hold myself accountable to get ahead with my learning, it would hopefully send the message that learning in this manner is nothing to be embarassed about, and lastly, who knows my notes could help other folks!

Last thing: As these are personal notes rather than fully-fledged articles, I write from my perspective as a learner. Also, I’m still experimenting with how to balance brevity, practicality, and clarity when discussing code examples. For now, I’m taking the most simple way: code blocks truncated to relevant parts, code comments for short explanation, and bullet points underneath for longer ones. Let’s see how it goes!


I’ve been trying to get more practice with basic animation as it’s one of my weakest points. As I happen to be learning Svelte, I looked up how to implement it in Svelte. Turns out Svelte has several built-in modules for motion-related functionalities: svelte/motion, svelte/transition, and svelte/animate — which means that we don’t need to install third-party animation library for basic use cases! 👍🏽

Svelte has an excellent Tutorial section with live editable sandbox. In this note, I’m going through the tutorials on Transition, which consists of:

a) The transition directive
b) Adding parameters
c) In and out
d) Custom CSS transitions
e) Custom JS transitions
f) Transition events
g) Local transitions
h) Deferred transitions

Before we begin…

a) The transition directive

💻 Try it:

This is our very first introduction to Svelte transitions!

  • There are six transition functions we can import: fade, fly, slide, scale, draw and crossfade (see docs).
    • eg. import { fade } from 'svelte/transition';
  • We use it in our element with the transition directive, eg. <p transition:fade>.
  • In this example, the transition is activated conditionally from a checkbox input with bind:checked directive. The checkbox is tied to a state variable called visible, whose value comes from the checkbox checked state. If true (ie. if checked), fade the element in, and if false, fade it out.
    • Different events could be used to activate the fade function (eg. button click), but it does not work if not tied to any event.
    • It also does NOT run automatically when the component is mounted/initialized.
<!-- ❌ Does not work, don't copy -->
  import { fade } from 'svelte/transition';

<p transition:fade>
  Does not fade in and out
<!-- ✔️ Works -->
  import { fade } from 'svelte/transition';

  // Initiate variable, visible by default
  let visible = true; 

  <!-- Update checked state and "visible" variable. If checked is true, visible is true. -->
  <input type="checkbox" bind:checked={visible}> visible

{#if visible}
  <p transition:fade>
    Fades in and out

b) Adding parameters

💻 Try it:

  • This example uses a different function, fly, to demonstrate passing optional parameters to the function.
    • Each of the six functions takes different parameters, which are listed in the official API docs. All functions have two common parameters, delay and duration.
  • Without parameters (previous example) = <p transition:fly>
  • With parameters = <p transition:fly="{{ y: 200, duration: 2000 }}">
    • y: 200 means the element is animated from 200px under its supposed position, to its supposed position. If we changed it to y: -100, the element flies down from 100px above its supposed position.
  • There’s a note about transition being “reversible”: “if you toggle the checkbox while the transition is ongoing, it transitions from the current point, rather than the beginning or the end”.
    • To see this in action, I changed duration value to a much larger value (eg. 8000) and clicked the checkbox halfway through the transition. Confirmed!
    • It’s a nice touch to ensure smooth visual transition (no “jumping”) even if user triggers/toggles the transition states repeatedly.

c) In and out

💻 Try it:

  • In the previous two examples, the transition directive applies to a pair of transitions, eg. fade from 0 opacity to 1 (when entering the DOM / visible is set to true), and the other way around from 1 to 0.
  • In this part, we learn that we can define individual transition using in and out instead of transition. Therefore, we can run different functions, eg. fly when entering the DOM and fade when leaving: <p in:fly="{{ y: 200, duration: 2000 }}" out:fade>. Convenient!
  • This page also says that in contrast with transition directive, in and out transitions are not “reversible”. 🤔 Huh?
    • You can see the difference by running and comparing two code snippets below. (The <script> part and {#if} block are identical.)
  in:fly="{{ y: 100, duration: 5000 }}" 
  out:fly="{{ y: 100, duration: 5000 }}"
    Flies in and out 
  transition:fly="{{ y: 100, duration: 5000 }}" 
    Flies in and out 
  • Although the transition functions are identical in both codes (ie. fly), they behave differently. I deliberately set long duration so the transitions are more apparent.
    • With in and out: If you uncheck the checkbox and quickly check it in the middle of the transition (while the text element is halfway flying out/down), the text element starts the opposite transition (flying back in/up) from the bottom, NOT the middle. This creates a visual “jump”.
    • With transition: If you do the same thing, the text element starts the opposite transition (fly back up) right from its current position. If you check/uncheck repeatedly, it creates a smooth “bouncing” visual.
    • Thus we can conclude in and out are strictly for different transition types.

d) Custom CSS transitions

💻 Try it:

☕️ This part is rather long. Get yourself a beverage of your choice, if you want.

  • Svelte provides common transitions like fade and slide as built-in functions (details in part (a))—but what if we need something more complex? In this part, we learn to create a custom transition function.
  • You can see the function API in the tutorial.
    • It takes 2 arguments: the node object and passed parameters object
    • It return 5 properties:
      • delay
      • duration
      • easing
      • css
      • tick

Here I’m annotating the first example, the build-in fade function.

 * Example 1 of CSS transition function
 * @param node {Node} - The node we're applying transition to.
 * @param {object} - Parameters we can pass in this function.

function fade(node, {
  // Set default value for "delay" and "duration" parameters.
  delay = 0, // 0 ms before the transition begins
  duration = 400 // Transition lasts for 400 ms
}) {
  // Get the node object's opacity
  const o = +getComputedStyle(node).opacity;

  // Return a transition object with these properties
  return {
    // User-passed parameters for "delay" & "duration"

    // Generate CSS animation; in this case animate the opacity
    css: t => `opacity: ${t * o}`

Let’s have a closer look at what’s happening here.

  • First we define the function fade with two arguments:
    • node is the node we are applying transition to, eg. <div transition:fade>
    • An object containing parameters that user can pass when calling this function, eg. <div transition:fade="{{duration: 400}}">. Here we have two parameters, delay and duration.
      • It’s optional; you can omit the second argument, like so: function foo(node) { ... }.
      • If your function does not return delay and duration, the function won’t break; default values will be used.
      • Here we set our custom default values of 0 and 400 respectively.
  • Then we get our node’s CSS opacity value and save it to variable o. We use getComputedStyle, a vanilla JavaScript method (ie. not a Svelte thing). By default (and in this example), an element has an opacity of 1 .
  • What does the + sign before getComputedStyle do? TL;DR: “It forces the parser to treat the part following the + as an expression [rather than declaration]” (source).
  • Last, we return a transition object with these properties: delay, duration, and css . The first two are self-explanatory; now we’re taking a closer look at the css property.
  • css is a function that generates CSS animation. The function takes two arguments, t and (optional) u, where u === 1 - t.
    • At intro (eg. fade in), t value goes from 0 to 1. u goes the opposite way from 1 to 0.
    • At outro (eg. fade out), t value goes from 1 to 0. Vice versa with u.
  • Our example generates fade in animation like this: (and fade out animation that does the opposite way)
0% { opacity: 0 }
10% { opacity: 0.1 }
20% { opacity: 0.2 }
/* ... */
100% { opacity: 1 }
  • The opacity value is calculated from t * o in the css function. It’s quite straightforward: at 10% through the duration, t = 0.1, so we get 0.1 * 1 = 0.1.
    • What’s the point of multiplying with o though? If our node has an opacity of 0.5, this function can generate the appropriate keyframes, eg. opacity value of 0.1 * 0.5 = 0.05 at 10%.

Unfortunately this example does not return the easing and tick properties, so at this point I’m not sure how they work.

Next, let’s go through the second, more complex example! 🤞🏾

 * Example 2 of CSS transition function
 * @param node {Node} - The node we're applying transition to.
 * @param {object} - Parameters we can pass in this function.

function spin(node, { duration }) {
  return {
    css: t => {
      // Create easing that lasts through the transition (starting point = when transition starts, finish point = when transition ends).
      const eased = elasticOut(t);

      return `
        transform: scale(${eased}) rotate(${eased * 1080}deg);
        color: hsl(
          ${~~(t * 360)},
          ${Math.min(100, 1000 - 1000 * t)}%,
          ${Math.min(50, 500 - 500 * t)}%

What is happening here?

  • Like in the first example, we define our spin function and pass two arguments: node and object containing duration parameter (no default value here), which returns our transition object with two properties: duration and css.
  • Now let’s have a closer look at the css function.
    • First, we notice that we use another built-in function, elasticOut, imported from svelte/easing. We pass t into the function (see explanation on t in the first example) and save it in the eased variable. Learn more: read the docs on easing.
    • From the docs: “Easing functions specificy the rate of change over time and are useful when working with Svelte’s built-in transitions and animations […]“
    • In a nutshell, elasticOut is an easing variant that starts with a sharp “bounce” down and up, a less marked drop, then goes almost linear afterwards.

Graph showing the elasticOut easing function

Graph of the elasticOut function
  • Next, we see that we animate TWO properties: transform and color. These properties use eased value, which implements the elasticOut behaviour on these transitions.
    • The transform property has TWO functions as value: scale and rotate.
      • scale(${eased}) means the element increases sharply in size (ie. becomes very large), then decreases until it’s smaller than its final size, then another set of slight increase and decrease, then ends at its final size.
      • rotate is slightly harder for me to understand at first. Changing the rotate multiplier value from rotate(${eased * 1080}deg) to rotate(${eased * 90}deg) helps me observe and understand its behaviour. Like scale, the rotate value increases (ie. rotate clockwise) then decreases into negative (ie. rotate counter-clockwise), and so on.
        • Note that since the final value of eased is 1, if the multiplier value is not divisible by 360, eg. eased * 90, it ends at 90 degree then “jumps” back to 0 degree (as the animation is removed after the transition is finished). Therefore, to create a smooth animation, make sure the multiplier is 360 or its multiples (720, 1080, etc).
        • Rotating an element to 360 degrees = rotating it one full circle. It means, if the multiplier value in our function is 720, we spin the element twice as many as when the value is 360. Increase the multiplier (360, 720, 1080, 1440) to see how it works. [WARNING: If you are sensitive to quick flashy motion, increase the duration as well.]
    • For color, we use HSL, a CSS color format that takes three values for Hue, Saturation, and Luminosity. It’s a CSS function, not a Svelte-exclusive thing, so we can use this elsewhere. To learn more about what each value does, read CSS Tricks’ article on HSL.
      • Hue: ${~~(t * 360)}
        • The double tilde ~~ operator stumped me. I look it up and found the answers in these StackOverflow posts: this, this, and this. Basically it works like Math.floor, ie. convert floating-point numbers (or strings) to integer. Our hue value t * 360 goes from 0 to 360. The double tilde operator ensures the animation keyframes are 0, 1, 2, 3, …, 360 rather than 0, 0.0001, 0.0002, etc.
      • Saturation: ${Math.min(100, 1000 - 1000 * t)}%
        • Math.min is a function that returns the lowest-valued number passed to it. Saturation value should begin from 100% (because 1000 - 1000 * 0 = 1000, which is greater than 100), and decreases once t goes above 0.9 (eg. when t = 0.92, we have 1000 - 1000 * 0.92 = 80). For some reason I don’t see the visual result of saturation decreasing, though.
      • Luminosity: ${Math.min(50, 500 - 500 * t)}%
        • Similar to saturation, just with different value.

That’s it! Here we’ve learned how to create visually complex transitions by leveraging and combining various CSS properties and functions.

e) Custom JS transitions

💻 Try it:

This part literally starts with a warning to only use JavaScript transitions to create effects that can’t be achieved otherwise 😆. Indeed, using CSS to animate supported properties (eg. opacity, color, transform) is better for performance because CSS animations are “handled by the browser's compositor thread rather than the main thread responsible for painting and styling” (source).

In this example, we are creating a typewriter effect: each letter of the text element appears one by one on-screen. JS is needed for this transition because:

  • the duration depends on the text length (the longer the text, the longer it takes until the last character appear); and…
  • we have to render each letter individually to the DOM.
 * Example of JS transition function
 * @param node {Node} - The node we're applying transition to.
 * @param {object} - Parameters we can pass in this function.

function typewriter(node, { speed = 50 }) {
  // Check if our node contains text AND no nested child elements
  const valid = (
    node.childNodes.length === 1 && node.childNodes[0].nodeType === 3

  if (!valid) {
    throw new Error(`This transition only works on elements with a single text node child`);

  // Get node text content
  const text = node.textContent;
  // Get duration based on text length (longer text = longer duration it takes for each letter to appear one by one)
  const duration = text.length * speed;

  return {
    tick: t => {
      const i = ~~(text.length * t);
      node.textContent = text.slice(0, i);

Let’s go through the code.

  • We define our typewriter function and pass two arguments: node and object containing speed parameter with default value of 50.
  • The node element must pass these two conditions in order to be valid:
    • node.childNodes.length === 1 means our node must only contain one child node (see reference); and…
    • node.childNodes[0].nodeType === 3 means our child node must be text.
    • ✔️ Example: <p in:typewriter>Hello!</p>
    • If the node is not valid, we throw an error.
  • After ensuring our node is valid, we get the text content and save it to the text variable.
  • We get the duration by multiplying text length and speed parameter.
    • eg. If our element consists of 6 characters and the speed is 50; the transition duration is 6 * 50 = 300ms.
    • (Yes, larger speed value means the transition takes longer to complete 😬. Test it by changing speed value to eg. 500.)
  • We return our transition object with two properties: duration and tick. The former is self-explanatory, while the latter is something we haven’t seen in previous examples!
    • From the API docs: “If it’s possible to use css instead of tick, do so — CSS animations can run off the main thread, preventing jank on slower devices.”
  • In the previous tutorial, tick is defined as “a (t, u) => {...} function that has some effect on the node”. Huh? 🤔
    • We are familiar with t and the ~~ operator from the previous examples, though. Go back to the previous section if you’d like a refresher on what these do.
    • Say we want to animate the text “Hello!”, which consists of 6 characters. First we get i value by multiplying t and text.length. In the beginning, i is 0 * 6 = 0; and it increases until i is 1 * 6 = 6.
    • We use ~~ to make sure i is an integer—we want 0, 1, 2, 3, …, 6 instead of 0, 0.00001, 0.00002, etc.
    • Next, we generate the transition by rendering the sliced text values in node.textContent :
      • text.slice(0,0) —> ""
      • text.slice(0,1) —> "h"
      • text.slice(0,2) —> "he"
      • text.slice(0,3) —> "hel" (etc)
    • These are done within the duration of 300ms.

f) Transition events

💻 Try it:

Svelte provides four transition-related events that we can listen for:

  1. introstart
  2. outrostart
  3. introend
  4. outroend
  • The names are quite self-explanatory: the introstart event fires when the “in” transition starts (eg. when the element flies/fades/slides in), and so on.
  • We listen for these events using the on directive. You can run any expression/function in the directive parameters, like with eg. onclick event. (In the tutorial’s original example, we update the status value.)

Example of an element that listens for transition events.

  on:introstart="{() => console.log('Starting intro!')}"
  on:outrostart="{() => status = 'outro started'}"
  on:introend="{() => doSomething()}"
  on:outroend="{() => doSomethingElse()}"
  Hello world!

Don’t forget to define the corresponding variable and functions in the <script> part like so:

let status = 'waiting...';

function doSomething() {
  // do something...

function doSomethingElse() {
  // do something else...

I find this helpful as many web UI transitions involves multiple elements—a basic example is how we animate the heading title, then the subtitle, body text, and the image one after another.

g) Local transitions

💻 Try it:

  • Local transition is a transition that “only plays when the immediate parent block is added or removed”.
  • We learn a new syntax here: local is called “modifier” and added in the transition directive, separated with |.
    • Example: <div transition:slide|local>
    • With parameter: <div transition:slide|local="{{ duration: 300 }}">

Let’s look at the example: (the <script> part truncated)

  <!-- Toggles showItems value when checked (true) / unchecked (false). Same as previous examples. -->
  <input type="checkbox" bind:checked={showItems}> show list

  <!-- Renders a “slider” from 0 to 10, which saves user-selected value to i. -->
  <input type="range" bind:value={i} max=10>

<!-- Render list if showItems === true -->
{#if showItems}
  <!-- Loop through the first i items. (If i is 3, loop through the first three items.) -->
  {#each items.slice(0, i) as item}
    <!-- Add "slide" local transition -->
    <div transition:slide|local>
      <!-- Print string from the "items" array defined in line 6. -->
  • When we check the checkbox and the showItems value changes from true (ie. show list) to false (hide list) or vice versa, the slide transition is NOT run. The list (“one, two, three” etc) simply appears and appears without transition.
  • However, when we drag the slider left or right, increasing or decreasing the i value, the list item is animated using the slide transition (slide down when appearing, up when disappearing). It’s because {#each items.slice(0, i) as item} is the direct parent of <div transition:slide|local>!

I initially did not quite get what’s so special about local transitions compared to the default ones. I guess it boils down to:

  • Performance (no need to run transition effects if not necessary)
  • (Maybe?) Not tire out users with too much motion, unless it really communicates something relevant to the interaction/interface—which most likely come from its direct parent.
  • All in all, perhaps it’s about having a built-in helper to control when a particular transition occurs. When we don’t need to run it all the time, we can restrict it to its parent simply by adding |local. Niice!

h) Deferred transitions

💻 Try it:

This is the last part of the Transitions tutorial!

The example code seems long and super complex at first glance (or it does to me), but most of the its length can be attributed to the “to do” functionalities rather than the transition being discussed.

So: What is a deferred transition?

  • The tutorial page describes it as “the ability to defer transitions, so that they can be coordinated between multiple elements.”
  • “If a transition returns a function instead of a transition object, the function will be called in the next microtask. This allows multiple transitions to coordinate, making crossfade effects possible.”

Here’s the JS code of the deferred transition.

const [send, receive] = crossfade({
  // Sending/receiving transition duration (we can also define "delay" and "easing")
  duration: d => Math.sqrt(d * 200),

  // Optional fallback transition function if the crossfade pair lacks one part (missing "sending" OR "receiving" element)
  fallback(node, params) {
    const style = getComputedStyle(node);
    const transform = style.transform === 'none' ? '' : style.transform;
    return {
      duration: 600,
      easing: quintOut,
      css: t => `
        transform: ${transform} scale(${t});
        opacity: ${t}

Then we have two sets of arrays (first is unfinished todo items todos.filter(t => !t.done), second is finished todo items) that render the element below. The label element is identical for both finished and unfinished items, except the former has class="done" for styling.

  <!-- input field -->

Let’s break down the JS code:

  • We assign the crossfade function to a pair of variables called send and receive.
  • If you’re not familiar with the syntax const [send, receive] , it’s called “destructuring assignment”. This is a good article about it.
    • In case you’re curious: We can assign the crossfade function to a different variable name without destructuring if we want.
      • eg. Instead of const [send, receive], we can write const crossfadeArray = crossfade({ … });
      • Don’t forget crossfadeArray is, well, an array.
        • I tried and found that we CANNOT use crossfadeArray[0] in the directive like <label in:crossfadeArray[1]="{{key:}}" in:crossfadeArray[0]="{{key:}}">.
        • What we CAN do is assign the pair into a variable each, eg. const send = test[0]; and const receive = test[1];.
        • The variable names don’t even have to be send and receive; it can be anything—eg. foo and bar—as long as you call them correctly, eg. <label in:bar="{{key:}}" in:foo="{{key:}}">.
      • Now we can see why it’s cleaner to use the destructuring assignment like in the original example.
  • Back to crossfade! I still haven’t completely understood it, so I play around with the code (modify the durations to absurdly high values to see what changes), and… log send and receive to the console. 😬🤷🏽‍♀️
    • Both variables simply print function transition().
    • In previous examples, we’ve used transition functions after in and out directives, eg; in:fade, in:typewriter, in:anyCustomTransition. Only after I tried the above steps did I realise… this is just like that! The only difference being we don’t have the actual returned transition object yet until a particular item is marked done (ie. “sent out” from one section and “received in” another), because it is… deferred. 🤯 Yay!
      • What does this transition do though? As described in the tutorial page, it “transforms the element to its counterpart’s position and fades it out”, ie. it animates the transform and opacity CSS properties. 👌🏾
  • crossfade takes a single object as argument, which contains:
    • duration — the duration of the “send/receive” transitions (in this case: when an item in the unfinished todo list is checked and thus “sent” to the finished list OR vice versa).
      • Math.sqrt = get square root of d * 200 .
    • fallback — the function that runs when the “send/receive” pair is incomplete, ie. missing either “sending” or “receiving” element (in this case: adding a new item to the todo list and deleting an item from either list, respectively).
      • This is a regular transition function like the ones we encounter in previous examples—it takes two arguments: node and params; returns object containing duration, easing, css.
      • It’s optional—ie. does not cause error if removed. If removed, the “send/receive” transitions (moving items between unfinished and finished lists) run just fine; but the unpaired transitions (adding or deleting items) run without transition.
      • easing: quintOut is an easing style that you can see in the Easing Visualizer. We can replace it with any other easing styles.
  • 🙆🏽‍♀️ Wait a minute! We only use duration in this example—but what other properties can the crossfade object have?
    • The API docs does not state explicitly, but since crossfade is a transition object, let’s assume it can have all transition object’s properties: delay, duration, easing, css and tick.
    • The first three properties work as expected (see example below). I half-heartedly tried css but it did not seem to work. Did not try tick.

The const [send, receive] code block in the example can be replaced (and run without error) with this:

const [send, receive] = crossfade({
  // When we check/uncheck a list item, wait 1s before moving/animating it.
  delay: 1000,
  // The list item moves soooo slowly.
  duration: d => Math.sqrt(d * 4000),
  // The list item does a little jiggly move (don't forget to import { elasticOut } from 'svelte/easing' if you're trying this!).
  easing: elasticOut

  // No fallback function means adding and deleting items don't get animated.

From this part, I particularly really like this description:

Using motion can go a long way towards helping users understand what’s happening in your app.

Not all web pages need complex, stunning, artistic animations. But motion is also needed in “regular” UI for the reason described above. Its presence may be barely noticable (to most users), but its absence would distract or hinder users. Users always come first, and that sentence reminds me why I have to master at least the basics of UI motion as a front-end dev.


We’re done with Svelte official tutorials on Transitions! 🎉

  • Transition in Svelte is done by defining/importing a function and adding it to the transition OR in and out directive in the element you’d like to animate.
  • Common built-in transitions (fade, slide, etc), are provided out of the box in the svelte/transition module. They return regular CSS animations when run.
  • You may also create your own custom transitions, either based on CSS or JS (ie. working with DOM elements). Common easing styles are provided in the svelte/easing module to help you create or customize transitions.
  • Helpers/functionalities for more specific use cases geared towards web UI needs are also available: “local” and “deferred” transitions. These modules make it easier to work with motion in Svelte.
  • In addition to the tutorials, Svelte’s API docs page has all the information you may need!
  • I like that these tutorials are brief and practical, and the live sandbox is very helpful for me when I’m trying to understand how things work. I also learn various useful things in vanilla JS, CSS, and HTML along the way.

That’s it for now! Thanks for learning with me and... just keep moving.

Top comments (1)

voon_leo profile image
Leo Voon

Super helpful! Thanks