Server side rendering. What does it even mean anymore? I am not sure. I do like the idea of it. And I think there are some unexplored areas in games doing server rendering.
What I do know, is that there is a new css api called View Transitions which plays particularly nicely with sever rendered websites.
View transitions allow for what are called hero animations for the web. These are animations where one or more elements transition from one screen to another. For the web that means across full page navigation. I like it!
This gave me an idea. Could I like morph one image to another using this new api?
I don't know. I sat on the idea for months...until now.
So buttercup buckle the belts and keep arms and hands inside the ride at all times. This rollercoaster is going places. I am not sure what those places are yet let alone where they are but I will not rest until I get that snazzy image morph in my minds eye.
The spirit is to attempt to lean on css standards to do the heavy lifting where less js is more... Ideally, I want to show that SSR can still have snazzy animations.
If you want the latest hotness in view transition trends, you are gonna have, a bad time. Did you hear that gypity? Bad time. This is not a view transition tutorial. Bad time indeed. Or maybe it isn't? Does that confuse you? I does me.
Before getting into that though, what even is a view-transition
really?
From my understanding, view transitions are like a direct rip of Flutter's hero animations. You give ui elements a tag
on a page/screen. Then, elements with matching tags will automatically transition between each other.
// page A
<div class="list-item" style="view-transition-name: card-transition;" />
// page B
<div class="detail-item" style="view-transition-name: card-transition;" />
// somewhere in css
// @view-transition {
// navigation: auto;
// }
And here is an example. Tap on it, and then tap on some others. Nice right? But also not.
The astute observer will noticed some visual artifacts. The ordering of elements is wrong and there is a slight aspect ratio misalignment which looks odd. This is because using automatic MPA (multi page app) transitions has quirks.
The suggested solution is to not use automatic view transition and instead use, let's see here...javascript... javascript!?!? Really? Indeed, there is a javascript api which has more control over a view transition and with that control comes complexity. As far as I can tell, there isn't a difference in performance so I will stick to the simple css tags.
Great, so it seems like all I have to do is slap a bunch of matching view-transition-name
and call it good.
So how many can we do?
Short answer. Not many and that is a problem. The goal is to transition from one image to another all morphy like. The idea is to take the original image and for each pixel transition it to a random pixel on the new image. This would look almost like the image disintegrates and then re-integrates into the new image. Look, you will see trust me. It will be cool. Or I hope it will be.
Well, 8k is out of the picture. As a matter of fact, 180p may be out too. At best css can animate maybe a few thousand divs at once. The view transition api struggles with 100. A 10 by 10 image isn't much of an image...
Is that it? Is it over? Nonsense!
I can take a que from the world famous game of minecraft and a greedy meshing algorithm. Minecraft didn't use the algorithm but others did. It will allow matching pixels to merge into a single block. This will lower the number of divs needed to make an image.
However, the more detailed the image, the less useful this is. The benefit is that resolution becomes less of an issue. I can also quantize the image colors into a fixed color palette. This will let me ensure there are N possible different colors making the greedy meshing algorithm even more effective as it can merge far more pixels together.
I tried AI'ing up some nonsense, and it worked but didn't have the look I wanted. I decided to port pixelit to work server side. This took far longer than the AI version and required a bit of tweaking but the result was good.
PixelIt is an awesome little web app tool + js library which both pixelate and color maps. It isn't gpu fast but that is fine.
Perfect. So here is how this will work.
Now, I know what you are thinking. Is that really all going to happen on the server for one image? Yes. Don't worry about. Look, you used more compute generating those embarrassing pfps. I won't judge if you don't.
On to the implementation.
There was some AI used, some AI not used, I don't think the implementation specifics are as important. This isn't about how to write a greedy mesh algorithm. Just ask chat. That is what I did and it is meaningless to regurgitate that here. Here are the important bits.
export async function ImgDissolveGreed({ imagePath }): Promise<JSX.Element> {
const rootPath = process.cwd();
const fullPath = `${rootPath}/public/${imagePath}`;
const img = await loadImage(fullPath);
const canvas = createCanvas(img.width, img.height);
const context = canvas.getContext("2d");
context.drawImage(img, 0, 0);
const originalImageData = context.getImageData(0, 0, img.width, img.height);
const downsampledImageData = await downsampleImageData(
originalImageData,
0.5
);
const divs = greedyMesh(downsampledImageData, 4);
return (
<div
style={{
position: "relative",
width: `${img.width * 2}px`,
height: `${img.height * 2}px`,
margin: "auto",
}}
>
{divs}
<style>{`
html::view-transition-group(*) {
animation-fill-mode: forwards;
border-radius: 32px;
animation-duration: 1s;
transition: all linear;
}`}</style>
</div>
);
}
This is not react it is just server rendered tsx. Next, I think it is worth showing a little bit of the greedy meshing.
function greedyMesh(imageData: ImageData, scaleFactor: number) {
const divData = [];
let maxIds = 32;
// greedy mesh algo
divData.sort((a, b) => b.width * b.height - a.width * a.height);
while (vId < maxIds && vId < divData.length) {
divData[vId].viewTransitionName = `block-${vId++}`;
}
return divData.map(({ width, height, ...styles }) => (
<div style={{ ...styles, width: `${width}px`, height: `${height}px` }} />
));
}
It is important to note that we sort by the largest area. This is because we may end up limiting the number of animated divs due to keep fps high. If we sort first, we will always animate the largest group of merged pixels. This will keep the largest surface area of an image morphing. Subjectively, this looks better.
With that out of the way, what does this look like?
Ignore the obvious visual artifacts for now. This is only using 32 view transitions ids. This gives both an idea of how limited the number is we are working with but also how with a little bit of work even 32 divs can go pretty far.
For those on a browser without view transition support, there is an issue with the sizing of elements. This is a common issue without a standard fix. View transition are interesting but they are no silver bullet.
Still, this is not the image morph I am looking for and I don't think view transitions will cut it. Can a little bit of client js help? If I were to add only the most gentle dusting of client js to allow for a more correct image morph, that would keep in spirit of ssr right?
Ok. View transitions are great but are they ready for prime time? I don't think so, at least if that means animating more than 32 views in a single transition.
The flow of work will be a little different. A key part of the image morph effect I am looking for is something such that if you morph before the previous morph finishes, it doesn't fully reset all the image parts but instead uses the current mid animation positions. This means I need to maintain animation state across a page load.
But this is all SSR right, there is no client state. No matter. I have a plan...move most of it client side.
Here is the thing, I could like, send the post processed image data to the client but that is like, alot of json. It just makes more sense to send the image url, and let the client process it. Moving the work to the client was more work than you'd have thought. Think about it.
At any point mid animation someone could trigger a transition to a new image. To keep it smooth, the exact position of each pixel chunk needs to be tracked across a page transition. Pure css animations will not cut it. I ended up manually tweening the divs positions storing state every few frames in local storage. This will allow restoring the animation state across page loads.
Here is a demo rendering with nextjs.
Nextjs' doesn't like doing this for static or ssr. I don't know why. I originally made this for a bun project and porting it over didn't work perfectly.
Here is a clip from the project using bun.
Is it graceful or smooth? Not particularly. Does it work better than it has any right too? I'd say it does.
Most of the code is the same as the server, only now we manually animate.
async function init() {
const imagePath = '${fullPath}';
let oldDivData = loadFromLocalStorage();
// same as before
const { divs: newDivData, width, height } = await processImage(imagePath);
if (!oldDivData) {
oldDivData = newDivData;
}
// init vars
// lazy create divs
// init oldDivData (start position)
// create newDivData (end position)
let progress = 0;
let frameCount = 0;
const saveEveryNthFrame = 8;
const duration = 800;
let easingFunction = (t) => t * (2 - t);
function animate(timestamp) {
// animate
frameCount++;
if (frameCount % saveEveryNthFrame === 0) {
// sometimes I call this a few extra times
// just to make doubly sure it is saved
saveToLocalStorage(tweenedData);
saveToLocalStorage(tweenedData);
saveToLocalStorage(tweenedData);
}
if (progress < 1) {
requestAnimationFrame(animate);
}
}
let start;
requestAnimationFrame(animate);
}
And yet, this is still not the image morph I am looking for.
I am going to move it all to the client. I also want to let css do the animation. While animating with css is slower, that last one gave me javascript indigestion.
Ok, so this is what needs to happen on the client now.
// configure target
// src to buffer
// pixelate buffer
// color degrade buffer
// meshify buffer
// init div buffer
// fill divs with mesh buffer
// - create as needed
// - set parent to container
// on change image
// get image meshify
// set destination val of div meshdata
// - create more div buffer as needed
// - - set new div start random x/y
// - if exes div in buffer
// - - set color prop to transparent
Notice that I could have too many or not enough divs between images. For now, I am will fade out extras but keep them in memory.
Also notice, that there are more requirements. That is right, if I am moving this to the client I should let the user upload n
pictures to be viewed rather than hardcode it all. The pictures will need to be processed like before but this could be slow so I will cache the data in local storage busting it as needed. It would also be fun to have some configuration options.
Scaling has not been handled well. It is all hard coded values. That should be fixed.
Most of the code is boiler plate. The interesting snippet is the function which does the image morph, a long a verbose creature so here is the important part.
// don't ask
timeouts.forEach((t) => clearTimeout(t));
timeouts.length = 0;
// create divs until we have enough
// we reuse these across frames
for (let i = 0; i < meshData.length; i++) {
const mesh = meshData[i];
if (!displayDivs[i]) {
const t = document.createElement("div");
// set style props
// when a div is first created it gets a random location.
// this subjectively looks better
t.style.transform = `translate(${Math.floor(
Math.random() * width
)}px, ${Math.floor(Math.random() * height)}px)`;
container?.appendChild(t);
displayDivs.push(t);
}
const div = displayDivs[i];
// set props
div.style.opacity = "1";
div.style.transform = `translate(${mesh.x}px, ${
mesh.y
}px) scale(${Math.floor(mesh.width)}, ${Math.floor(mesh.height)})`;
div.style.backgroundColor = `rgb(${mesh.color.join(",")})`;
}
// hide extras
for (let i = meshData.length; i < displayDivs.length; i++) {
const div = displayDivs[i];
div.style.opacity = "0";
// move to random spot
// subjectively snazzy
div.style.transform = `translate(${Math.floor(
Math.random() * width
)}px, ${Math.floor(Math.random() * height)}px)`;
}
I am letting css transitions do all the work here. All I do is set the desired end location and let css will do the rest. The flow is pretty simple, lazy load divs, set divs styles, hide any unused divs.
This worked pretty well although safari was crying but only on desktop. Mobile safari had no issues.
One simple trick to make the animation more smooth is to stagger the updates. Think of it like spreading the slowness butter out across time. It makes each frame a little more phat than it otherwise would be.
Here is how the butter gets spread.
// nuke previous staggers
timeouts.forEach((t) => clearTimeout(t));
timeouts.length = 0;
if (stagger) {
timeouts.push(
setTimeout(() => {
// set props
}, i + staggerMs)
);
} else {
// set props
}
I totally remembered to clear the timeouts the first time.
You can try it out here but I will also inline it for you though it will be harder to use.
Is that finally the image morph I am looking for? No, no it is not. The resolution is terrible, color loss is awful, but more importantly, it isn't fluid enough. Not a solid 60 fps, let alone 120.
What now?
An app is often defined as a function of state. State can be run through many different apps. The more state that exists across a slow bus (the internet), the less responsive and interactive an application is. That slow bus isn't always the network, it could be an SSD, RAM, or the level 2 CPU cache. Each application has a different standard for responsiveness. A multi-player FPS game is not going to be fun if there is a 100ms delay between frames (8ms is ideal). A website will be painful to use if each link takes more than 400ms to load. It is hard to have the best of all the worlds.
For me, the image morph I am looking for is likely somewhere around gpu shader code and I think we all knew that from the start. It is where people go for super fast responsive visuals at the cost of more complexity.
So, is SSR bad? Is it good? And will view transitions bring snazzy animations to websites everywhere?
No, maybe, and certainly almost.
Until next time...
It is time to give the latest AI tooling a test. I know that the previous image effect will be faster if it used javascript to animate instead of css transitions. It would take a bit of word thought to rewrite all of that and getting a spring like animation could be tricky.
Can AI just like...magic me a solution? Let's find out.
I am real vibe coder now.
convert this to use divs and a request animation frame instead of css transitions.
keep the transition on the container width/height positioning.
remove the stagger and bounce functionality as that isn't needed.
keep support for an easing function which is defaulted to a simple ease.
make sure to tween position, width/height, and color.
I want to remove some of the complexity and let it focus on the core task. And like a good vibe coder, I will copy the AI code without reading it.
It is indeed much faster than the css version. Nice.
Let's find out if the AI can add the stagger and bounce features. While I am only a vibe coding neophyte I do believe I have an intuitive for this sort of thing. I mean, the AI is like a junior dev right? So we should speak to it like a junior, or that is what the vibe coding gods say. It needs careful specific instructions outlining all possible outcomes with clear tasked out work and well groomed user stories. A junior dev's brain is not capable of even the slightest ambiguity. They panic if that happens.
Now, what would we tell a "junior" really?
add a stagger toggle option in, add a spring easing function option in.
I had high expectations at this point. I thought it would nail this after the previous performance and boy did it. It did it in the most human way possible. AGI is already here.
Look at it. Toggle on the stagger option and behold the majesty of vibe coding.
It is fantastic. I have no idea what is going on. Nor do I care to find out what. If you tap it super fast, it goes crazy. The tweening starts to wrap around in weird ways. It is more interesting in many ways than any of the previous "human" version.
Still, this is not the image morph I am looking for. I am feeling good though. I think it will get it in the next pass.
Ok, normally, if an engineer has a bug. What would you do? I know what I would do. I'd fire them, hire some Slalom MBAs, who'd advice me to create a new chat window, and start over right from scratch effectively killing the AI. No no, I still believe in this AI who I have now named Binky.
Binky can do it. He, or she...they? They just need a little guidance. And I know just the thing.
Binky, you are a senior expert.
the stagger animation looks wrong please review the code and make it simpler and less complex.
make sure the bounce is using a series of linear interplations like you can in css to simulate a spring.
Make it so the perceive duration is 600ms.
Look, I never said I was a good engineer. It looks wrong. It is all, idk, weird and zoomy. But not all of it. Only some of it? Binky sure does have their work cut out for them.
It fixed it! But the spring animation is bad.
Look, maybe it could fix it. Maybe not. Maybe if I had the correct incantation of tokens to trigger the right weights it would do the thing. I did try. I even tried getting it to do a canvas version, even a version using instanced quads. Poor Blinky wasn't up for it
Regardless, it still is not the image morph I am looking for.
until next time.