Moving the loading screen jank to the compositor
The DEVILYXRD boot screen stuttered in its first version. The fix was not faster JavaScript, but leaving no work at all for the main thread: four real milestones, parts that move only with transform, and timers that step in when things go wrong.
01Why it stuttered
The boot screen runs at the moment the browser is busiest: HTML is being parsed, fonts are downloading, the JavaScript bundle is being evaluated and Vue is hydrating the prerendered page. In the first version, the progress bar and the counter were updated by a JavaScript loop running roughly every 16 milliseconds. While the main thread was setting up the app, the steps of that loop were delayed, and the animation moved in jerks exactly when it was on screen.
The screen itself lives in index.html as inline CSS and an inline script. That way it can paint before any JavaScript bundle or font arrives. The container has contain: strict, so nothing that changes inside it affects the layout of the rest of the page.
02Transform only
I kept the rule simple: everything that moves on the boot screen uses only transform, plus opacity when fading. These two properties can be animated on the browser's compositor layer, so they stay smooth even while the main thread is busy. JavaScript no longer drives the animation; it only writes the target value. A CSS transition does the motion in between.
When the .out class is added, the curtain slides up with translateY(-100%). Each letter of DEVILYXRD is a separate span; they start hidden below with translateY(112%) and rise into place when the .ready class arrives. Each letter's --i variable shifts its transition delay in 55 millisecond steps, so the word opens from left to right. On exit the same letters escape upward with -112%, this time in 22 millisecond steps. The bar changes its scaleX value, not its width.
function set(v) {
if (v <= shown) return
shown = v
bar.style.transform = 'scaleX(' + v / 100 + ')'
var d = [Math.floor(v / 100), Math.floor(v / 10) % 10, v % 10]
for (var i = 0; i < 3; i++)
cols[i].style.transform = 'translateY(' + (-1.6 * d[i]) + 'em)'
}The counter is an odometer. Each digit is a strip of numbers stacked on top of each other: only 0 and 1 for the hundreds, 0 to 9 for the tens and the ones. Each number is 1.6em tall, and so is the window of each digit. To show the right number it is enough to shift the strip up by -1.6 × the digit; CSS does the transition again. The set function ignores any smaller value, so the counter never goes backwards.
03Four real milestones
Instead of a fake timer, progress is tied to real events. One task after the script runs, the value becomes 12; I delay it with a setTimeout so that the first transition actually animates. On DOMContentLoaded it becomes 42. When document.fonts.load has loaded Syne 800 and IBM Plex Mono 500, it becomes 76 and the .ready class that starts the letter reveal is added. The last step is 100: after main.ts mounts the app it calls bootDone in boot.ts, which waits for document.fonts.ready and then triggers window.__bootDone.
Every milestone also has a safety net. Waiting for fonts takes at most 1.5 seconds; after that the letters open without the font. If the app never reports back, __bootDone is called anyway after 6 seconds. The screen stays for at least about 1.1 seconds so the letters can finish moving. After reaching 100 it waits another 420 milliseconds, so the bar can land and the hydrated page can paint once under the curtain.
04As the curtain lifts
The leave function adds the .out class, sets window.__bootLeft to true and dispatches an event named boot:leave. Once the transition is over, the element is removed from the DOM and the booting class on <html>, which locks scrolling, is removed. The bootReady promise in boot.ts waits for that event; on the server, on a page without a boot screen, or if the curtain has already lifted, it resolves at once. The HeroScene on the home page starts its animation with bootReady.then, so the hero opens as the curtain lifts, not behind it.
The script calls performance.mark at two points: boot:fonts when the fonts are ready and boot:leave when the curtain lifts. These marks show up on the timeline in the browser's performance panel, so you can see which step the boot was waiting on instead of guessing.
05For people who prefer less motion
If the reduce motion setting is on in the operating system, the screen behaves differently. Under prefers-reduced-motion, the letters, the bar and the counter sit in their final state without transitions. The curtain fades out in 240 milliseconds instead of sliding. The minimum time drops from 1.1 seconds to 200 milliseconds, the wait after 100 is dropped, and the element is removed after 300 milliseconds. The same check is made both in CSS as a media query and in the script with matchMedia, so the timings follow the preference too.
Smoothness usually comes not from making code faster, but from taking work off the main thread.
After this change, all the script does is write a few lines of style. However busy the main thread gets, when the next target value arrives the motion continues from where it was.