戴兜

戴兜的小屋

Coding the world.
github
bilibili
twitter

Create a cool personal business card page✨✨

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).

image

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.

image

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.

image

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).

image

So I need to set transform: translate(-50%, -50%); to offset the element to the left/up.

image

Flipping Transition#

Now let's talk about how the flipping transition in the image is implemented (considering only the flipping element itself).

GIF 2023-4-11 0-04-35 (2).gif

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 as toEl.

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).

GIF 2023-4-11 0-09-40 (1).gif

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.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.