Magic Move is a feature for transition in Keynote, or it’s called Morph Transition in PowerPoint, and it automatically animates the transitions for objects between slides. It is quite impressive, very intuitive and effortless to use, and can be applied to various types of objects. Like you can paste a highlighted code block, or make another in a second slide, Magic Move will do the transition as granular to the tokens level for you.
The only annoying part of this process is that Keynote does not support code highlighting - so you need to highlight the code somewhere and paste them manually every single time. This is one of the reasons I made Slidev - to have first-class tooling for developers to make presentations easier. While moving to web technologies with Slidev opens up almost infinite possibilities, on the other hand, it also makes some nice cool features in Keynote and PowerPoint harder to achieve (you need to write quite some extra code) - for example, the Magic Move.
Browsers’ new View Transitions API makes morphing between elements a lot easier, only that it requires some manual work to assign keys to make the pairs. While it’s rather ok to do for a few big elements, doing such manually for every single token in a code block is basically unacceptable.
Roughly a year ago, Eduardo @posva and I came up with the idea of using Shiki combined with Vue’s <TransitionGroup>
to achieve a similar effect for code blocks:
Do you like Magic Move in Keynote for code?@antfu7 and I are cooking something 😎 pic.twitter.com/9JxjCzRA1S
— Eduardo.𝚟𝚞𝚎 (@posva) January 27, 2023
We managed to make the proof of concept work, as shown in the video. However, due to some hard edge cases, both of us were busy with other things, and we didn’t manage to make it a usable library at that time. It has come to our discussions from time to time but we didn’t come up with a good solution. Until one day, a few weeks ago while I was preparing my slides, my productive procrastination kicked in and I decided to give it another try to escape from my slides.
And luck me, I found a quite nice approach and made it happen:
The procrastination in preparing talks drove me to bring up the rework of the idea we had last year with @posva - animate Shiki tokens like Magic Move! 🪄
— Anthony Fu (@antfu7) February 22, 2024
Found a much more reliable approach that could finally come out as a library (soon)https://t.co/b5SgQtTw2s pic.twitter.com/s5LutlYmAK
Made a playground where you can try it out yourself, and you can find the source code at shikijs/shiki-magic-move.
So today here, let’s break it down and see how it works.
How it works #
To get started, we could see each word in the code with different colors as a token (they are <span>
elements in practice). Basically, we can consider them as a set of small objects that we want to animate individually.
To break done the animations of Magic Move, we see we are basically trying to find connections between two sets of objects and animate them from one to another. Different from 1-to-1 transitions, here we expect the code before and after to be different, which means we could categorize the type of transitions into 3 categories:
- Move: The token exists in both before and after, so we just need to move it to the new position.
- Enter: The token only exists in the after, we need to animate the element entering the scene.
- Leave: The token only exists in the before, we need to animate the element leaving the scene.
Here is a playground for you to inspect the type of each token, hover over to inspect:
In the playground above, we assign a key to each token. Move
tokens come with pairs, so we assign the same key to make the "connection".
When doing highlighting, Shiki turns the input code into an array of tokens. We can run Shiki twice for code before and after to get two collections of tokens. It should be fairly simple to find the Enter
and Leave
tokens by running two loops to compare the two collections. However, the challenge is to find good pairs of Move
tokens. If we only pair them by the content of each token, it would be the case that 1-to-many or many-to-1 might make the pair transition off.
I came up with the idea of using a text diff algorithm to find the chunks of the code that are matched between the two versions. I ended up using Google’s Diff Match Patch (I later refactored it into ESM as diff-match-patch-es
) to achieve this. With the diff result, we can now reliably find the pairs of the Move
tokens without worrying that tokens might travel to the wrong place.
With this core logic in place, we can generate the correct keys for each token for the connection. This made the rest of the task clear, we just needed to apply different transitions to different types of tokens. We could feed them into any key-based transition library, for example, Vue provides a built-in <TransitionGroup>
component that does the job perfectly, live example.
Transitions #
While Vue’s <TransitionGroup>
should get the transition done automatically, it’s actually a bit tricky to do in our specific case. The main reason is that the token elements in the code are <span>
that rely on browsers’ layout engine to calculate the position. During the transition, we want to make each token positioned absolutely to avoid messing up the layout. This means we need to record the position of each token with getBoundingClientRect()
before the transition starts, apply the absolute position to them during transitions, and then restore to the inline layout after the transition ends.
Ran into some limitations of <TransitionGroup>
and also with the wish to have this a framework-agnostic solution, I ended up writing a custom renderer to do that, referencing a lot of the code from <TransitionGroup>
.
Because we are relying on the browsers’ layout engine to calculate the position, we need to get the destination position of each token (the position of the final code) before the transition starts. I found this trick in Vue’s code to force layout reflow combined with temporary setting transition duration to 0 to get the new position immediately so we could start animating.
Then it comes to do the transitions for different types of tokens:
Enter Transition #
The enter transition is the most straightforward one. Because the token will stay at the destination position after the transition, we don’t need to do anything with the positioning. We usually just need to apply the opacity transition to make it appear. Here we add/remove classes for users to apply the transition with CSS.
Pseudo-code below:
for (const el of enterElements) {
el.classList.add('transition-enter')
el.classList.add('transition-enter-from')
}
// Replace the children of the container with
// elements from the new code
container.replaceChildren(...newChildren)
// Force layout reflow
forceReflow()
for (const el of enterElements) {
el.classList.remove('transition-enter-from')
el.classList.add('transition-enter-to')
}
// Here the transition starts
// from `.transition-enter-from` to `.transition-enter-to`
for (const el of enterElements) {
// Transition Finished
el.addEventListener('transitionend', () => {
el.classList.remove('transition-enter-to')
})
}
Leave Transition #
Since "Leave" tokens eventually disappear after the transition, we need to keep them in the DOM tree for a while for animations but we don’t want them to participate in the layout. We can apply position: absolute
to them and set the top
and left
to the original position to make them stay in place. Then we can apply the opacity transition to make them disappear.
Pseudo-code below:
for (const el of leaveElements) {
// Get the position of the token stored before
const pos = position.get(el)!
// Set absolute position
el.style.position = 'absolute'
el.style.top = `${pos.y}px`
el.style.left = `${pos.x}px`
el.classList.add('transition-leave')
el.classList.add('transition-leave-from')
}
// Replace the children of the container
// Same as the enter transition
container.replaceChildren(...newChildren)
forceReflow()
for (const el of enterElements) {
el.classList.remove('transition-leave-from')
el.classList.add('transition-leave-to')
}
for (const el of enterElements) {
el.addEventListener('transitionend', () => {
el.classList.remove('transition-leave-to')
})
}
Move Transition #
To animate "Move" tokens requires a bit more work. We use a technique called "FLIP" (First, Last, Invert, Play) to make the transition smooth. We need to record the position of each token before the transition starts, then apply the absolute position to them during the transition, and then restore it to the inline layout after the transition ends.
It’s a bit unintuitive to understand at first, but luckily David Khourshid made a great explanation at Animating Layouts with the FLIP Technique, definitely worth reading!
Pseudo-code below:
for (const el of moveElements) {
const newPos = el.getBoundingClientRect()
const oldPos = position.get(el)!
const dx = oldPos.x - newPos.x
const dy = oldPos.y - newPos.y
// Set duration to 0 to get the new position immediately
el.style.transitionDuration = '0ms'
el.style.transitionDelay = '0ms'
// Transform new elements to the old position
el.style.transform = `translate(${dx}px, ${dy}px)`
}
// Replace the children of the container
container.replaceChildren(...newChildren)
forceReflow()
for (const el of moveElements) {
// Remove transform overrides,
// so it will start animating back to the new position
el.classList.add('transition-move')
el.style.transform = ''
el.style.transitionDuration = ''
el.style.transitionDelay = ''
}
for (const el of moveElements) {
el.addEventListener('transitionend', () => {
el.classList.remove('transition-move')
})
}
Integrations #
Shiki Magic Move is open-sourced at shikijs/shiki-magic-move. The core logic is pretty lightweight and framework-agnostic, despite that the library is a bit low-level. Currently, I only have the bandwidth to make a Vue wrapper for it, and I am counting on you to contribute and add more first-class integrations for other frameworks as well as higher-level integrations.
If you are using Slidev, you can try it today to enhance your slides with Magic!
Hope you enjoy it, happy hacking!