This article mainly introduces how to implement routing transitions for the personal card page.
Introduction#
In 2019, I wrote a pretty cool personal card page. At that time, I was keen on using various transition effects and, of course, tried many new CSS features, such as using CSS variables to achieve multiple theme colors (it seems like it was my first time using flex layout).
However, I clearly had not yet mastered the art of front-end layout 🤯. Many page elements rendered correctly in the browsers at that time, but now they are somewhat broken, and many details were not handled well. Therefore, I prepared to remake this card page.
Nonetheless, there are still quite a few clever ideas in it. For example, to prevent the card from being cut off due to scrolling, I added a shadow using a pseudo-element to the container. The implementation is simple, but the effect is quite good.
Now the remake is basically complete (there are still a few pages left unwritten, but that's not a big deal). You can take a look at the effect at im.daidr.me.
It is not difficult to see that the overall page style is very similar to before, but it indeed matches my imagination of "cool."
Tech Stack#
Vue3 + WindiCSS + SCSS + Nuxt
This card page actually started being written in December last year. Initially, I didn't implement SSR, but recently I tried migrating to Nuxt. The routing effects and compatibility were quite challenging, but that's not the focus of this article, so I won't elaborate on it~
The article page was just added a few weeks ago. Currently, I am using an old WordPress blog as a CMS, but it's a bit too heavy—I've added Redis SWR caching to the article page to barely ensure smooth access. Recently, I've been using xLog based on the Crossbell chain, which feels quite good. I hope to integrate the article page with xLog after I understand it better. 😗
Analysis#
Routing Structure#
First, let's look at the routing structure (which relates to how the pages will transition later).
Currently, this card page has five sets of pages: /me
(alias /
), /friends
, /projects
, /blog/:slug
, and /404
. Their structure is as follows:
├── `/`
│ ├── `/me`
│ └── `/friends`
├── `/projects`
├── `/blog/:slug`
└── `/404`
Since the containers for the /me
and /friends
pages are the same size, the flipping effect is not very suitable, so a different transition effect is used when switching between them.
(From today's perspective, this implementation is not suitable, as maintenance and customization would be quite difficult. Nuxt has built-in routing transition configuration options, so there's no need to rely on sub-routes to achieve this.)
Container Positioning#
There are actually very few methods to fix a container in the center of the page. I used fixed absolute positioning. First, I set top: 50%; left: 50%;
, but at this point, the element is not in the center of the page (its top-left corner is at the center of the page).
So I need to set transform: translate(-50%, -50%);
to offset the element to the left/up.
Flipping Transition#
Now let's talk about how the flipping transition in the image is implemented (considering only the flipping element itself).
The following code should be familiar to Vue developers, as it allows vue-router to apply transition effects when switching pages.
<router-view v-slot="{ Component }">
<transition name="fade">
<component :is="Component" />
</transition>
</router-view>
In fact, the Transition component can not only pass the name but also control each stage of the transition in detail using JavaScript. The logic related to the transition effects is encapsulated in RouterTransition
:
<!-- App.vue -->
<RouterView v-slot="{ Component }">
<RouterTransition>
<component :is="Component" />
</RouterTransition>
</RouterView>
<!-- RouterView.vue -->
<Transition
ref="SlotRef" :css="false"
@before-enter="onBeforeEnter" @enter="onEnter" @after-enter="onAfterEnter"
@before-leave="onBeforeLeave" @leave="onLeave"
>
<slot />
</Transition>
Analysis#
Using JavaScript to control the transition means we need to know the size and position of the elements before and after the transition. Getting the elements is straightforward, but there is a problem: the elements that need to apply the transition are not necessarily the root elements of the page.
For example, on the /projects
page, only the top menu bar applies the transition. Therefore, we need a way to identify these elements. My method is to add the class name transition-page-wrapper
to the elements that need transitions.
I wrote a utility function that takes the root element of the page and returns the elements that need transitions.
const getTransitionContainer = (el) => {
const containerClass = 'transition-page-wrapper';
// If el is not an element, return it directly
if (!el || !el.classList) {
return el;
}
// If el is the target element, return it directly
if (el.classList.contains(containerClass)) {
return el;
}
// Otherwise, traverse all child elements of el to find the target element
for (let i = 0; i < el.children.length; i++) {
const child = el.children[i];
if (child.classList && child.classList.contains(containerClass)) {
return child;
} else {
const _child = getTransitionContainer(child);
if (_child != child) {
return _child;
}
}
}
return el;
}
Before Transition#
From now on, the page element before the route switch will be referred to as
fromEl
, and the page element after the route switch will be referred to astoEl
.
First, let's tackle the before-leave
event. In this function, we need to record the position and size information of fromEl
. To ensure a smooth transition, I also plan to record the border-radius
property.
const fromWrapperStyle = {
x: 0,
y: 0,
w: 0, // Width
h: 0, // Height
br: 0, // border-radius property
t: "", // transform property
};
The values of xywh
can be obtained using the getBoundingClientRect
method. The b
(border-radius) and t
(transform) are CSS style properties. The element's style property can only retrieve its inline styles. To obtain all accurate CSS styles computed by the browser, we need to use the getComputedStyle
method. I encapsulated this into a utility function called writeCfgObj
for later use:
const writeCfgObj = (el, cfgObj) => {
const elRect = el.getBoundingClientRect();
cfgObj.x = elRect.x;
cfgObj.y = elRect.y;
cfgObj.w = elRect.width;
cfgObj.h = elRect.height;
const _style = getComputedStyle(el);
cfgObj.br = parseFloat(_style.borderRadius.replace("px", ""));
cfgObj.t = _style.transform;
}
The before-leave
event of the transition component has a parameter that will pass the element that will disappear during the transition (i.e., fromEl
).
const onBeforeLeave = (fromEl) => {
// Get the actual element that needs to transition based on the root element
let _fromWrapper = getTransitionContainer(fromEl);
// Use the previously written utility method to store the element's position/size/partial styles
writeCfgObj(_fromWrapper, fromWrapperStyle);
}
With the position/size of fromEl
, the next step is to get the position and size of toEl
, which can be obtained through the before-enter
event.
It is important to note that, unlike before-leave
, at this point, toEl
has not actually been inserted into the DOM tree (if it were already inserted, there would be no transition). Therefore, we cannot directly obtain the position and size of the element; we need some extra steps.
const onBeforeEnter = (toEl) => {
// Clone toEl
let toWrapper = toEl.cloneNode(true);
// Disable transitions to prevent unnecessary issues with opacity later
toWrapper.style.transitionDuration = '0s'
// Set opacity to 0
toWrapper.style.opacity = 0;
// Insert into body
document.body.appendChild(toWrapper);
// Get the transition element inside the cloned container
let _toWrapper = getTransitionContainer(toWrapper);
writeCfgObj(_toWrapper, toWrapperStyle);
// Remove it
toWrapper.remove();
}
Essentially, we clone
toEl
, insert it into the DOM to get its position, and then immediately delete it. Since we set the opacity to 0, the element is invisible, so the user is unlikely to notice.
The implementation of
TransitionGroup
is quite similar.
Transition in Progress!#
Now that we have the properties of toEl
and fromEl
, the transition can begin! The transition will mainly use the transform
property.
But hold on 😜, before starting the transition, we need to calculate the position and size differences between toEl
and fromEl
, so that we can conveniently apply translate
and scale
transformations to the elements.
It is important to note that we are using the
transform
property to apply transformations to the elements, while the elements themselves may already have offsets. During the transition, we will override this, so be sure to consider the element's original offsets when calculating.
const calcDelta = (prevCfg, nextCfg, nextMatrix3dStr) => {
const matrix3d = nextMatrix3dStr.replace(/matrix3d\(|\)/g, "").split(",").map((v) => parseFloat(v));
// Convert to translate
const nextTranslateX = matrix3d[12];
const nextTranslateY = matrix3d[13];
// Calculate scale
const scaleX = prevCfg.w / nextCfg.w;
const scaleY = prevCfg.h / nextCfg.h;
// Calculate delta
let deltaX = prevCfg.x - prevCfg.x + nextTranslateX;
let deltaY = prevCfg.y - prevCfg.y + nextTranslateY;
// Since scaling is applied, we need to adjust the delta based on the scale
deltaX -= (1 - scaleX) * nextCfg.w / 2;
deltaY -= (1 - scaleY) * nextCfg.h / 2;
return {
deltaX,
deltaY,
scaleX,
scaleY,
};
}
You might feel a bit confused looking at the code above. What is matrix3d
? When did it come up?
Remember when we used getComputedStyle
to retrieve the element's transform
property? The browser returns the computed styles. What we get is not a string like translate(-50%, -50%)
, but a transformation matrix represented by the matrix3d
function. To get the element's offsets, we only need the 13th and 14th parameters a4
and b4
.
scaleX/Y
– The scaling ratio calculated based on the sizes of toEl
and fromEl
.
deltaX/Y
– The distances calculated based on the positions and offsets of toEl
and fromEl
. Since scaling is applied, we also need to adjust this delta based on the scaling ratio.
Next, we can officially handle the departure of toEl
, using the leave
event of the transition component.
const onLeave = (el, done) => {
// Get the element that should transition
el = getTransitionContainer(el);
// Force a transition effect
el.style.transitionProperty = 'all';
el.style.transitionDuration = '1300ms';
// Let the browser catch up ε=ε=ε=┏(゜ロ゜;)┛
requestAnimationFrame(() => {
const d = calcDelta(toWrapperStyle, fromWrapperStyle, fromWrapperStyle.t);
// Since I used WindiCSS, I adopted the method of overriding CSS variables here
// You could also directly use transform
// Flip
// Here, we perform a flip along the x-axis and z-axis, which effectively flips the container
el.style.setProperty("--tw-rotate-x", "180deg");
el.style.setProperty("--tw-rotate-z", "-180deg");
// Move the container (fromEl) to the new position (toEl's position)
el.style.setProperty("--tw-translate-x", `${d.deltaX}px`);
el.style.setProperty("--tw-translate-y", `${d.deltaY}px`);
el.style.setProperty("--tw-scale-x", `${d.scaleX}`);
el.style.setProperty("--tw-scale-y", `${d.scaleY}`);
// Change the container's border radius
const scale = (d.scaleX + d.scaleY) / 2;
el.style.borderRadius = toWrapperStyle.br / scale + "px";
// Fade out
el.style.opacity = "0";
})
// Listen for the transition end event. After the transform transition is complete, inform the transition component that the transition has ended
let _event = null;
el.addEventListener('transitionend', _event = (ev) => {
if (ev.target === el && ev.propertyName === 'transform') {
el.removeEventListener('transitionend', _event);
done();
}
})
}
After calling the
done()
callback function passed into the leave event,fromEl
will be removed by the transition component, and we don't need to delete it ourselves.
Now, fromEl
has completed the transition and has been cleared. The last thing to do is to display toEl
, which is just the opposite of fromEl
. If fromEl
rotates 180°, then toEl
rotates -180°.
const onEnter = (el, done) => {
el.style.transitionDuration = '0s'
const d = calcDelta(fromWrapperStyle, toWrapperStyle, toWrapperStyle.t);
el.style.setProperty("--tw-rotate-x", "-180deg");
el.style.setProperty("--tw-rotate-z", "-180deg");
el.style.setProperty("--tw-translate-x", `${d.deltaX}px`);
el.style.setProperty("--tw-translate-y", `${d.deltaY}px`);
el.style.setProperty("--tw-scale-x", `${d.scaleX}`);
el.style.setProperty("--tw-scale-y", `${d.scaleY}`);
el.style.opacity = "0";
const scale = (d.scaleX + d.scaleY) / 2;
el.style.borderRadius = fromWrapperStyle.br / scale + "px";
document.body.offsetHeight;
requestAnimationFrame(() => {
el.style.transitionProperty = 'all';
el.style.transitionDuration = '1300ms';
// Reset all properties
el.style.borderRadius = "";
el.style.opacity = "";
el.style.setProperty("--tw-rotate-x", "");
el.style.setProperty("--tw-rotate-z", "");
el.style.setProperty("--tw-translate-x", "");
el.style.setProperty("--tw-translate-y", "");
el.style.setProperty("--tw-scale-x", "");
el.style.setProperty("--tw-scale-y", "");
})
let _event = null;
el.addEventListener('transitionend', _event = (ev) => {
if (ev.target === el && ev.propertyName === 'transform') {
el.removeEventListener('transitionend', _event);
done();
}
})
}
This onEnter
function looks quite similar to the previous onLeave
, but upon closer inspection, they are quite different 🤯. This is because the principles behind them are different.
The onLeave
event is used to handle fromEl
, which will be deleted after the transition is complete. Therefore, we don't care if it retains any messy inline styles. So, we first give fromEl
a transition property, then assign it an offset to gradually transition to the new element's position.
The onEnter
event is used to handle toEl
, which will remain on the page after the transition is complete. We cannot write a bunch of inline styles just because of the transition; if we do, we should at least remove them after the transition is complete.
Thus, the logic here is: first disable the transition, then use inline transform
to place toEl
in the position of fromEl
. At this point, we enable the transition and remove the previously set transform property, and toEl
will transition back! Moreover, after the transition is complete, the transform
property will not remain on the element, which is great!
After Transition#
We set the transition
property for toEl
, so we need the after-enter
event to "clean up."
const onAfterEnter = (el) => {
el = getTransitionContainer(el);
el.style.transitionProperty = '';
el.style.transitionDuration = '';
}
Now, the entire flipping transition is complete.
You may notice that in some cases, another type of transition occurs (when loading exceeds 100ms, it first transitions to loading).
This animation is implemented through route guards, and the principle is quite similar, except that the toEl
/fromEl
are replaced with a loading element. However, with the dual support of Workbox and Nuxt Prefetch, this animation has become less significant.