Hello folks!
It’s been quite a while, and would be pretty rude of me to get into anything without at least saying hello. I can’t tell what the reason is, why I’ve been writing a little less often. Hmm—“a little less often”—that’s an understatement!
So what it is is, I’ve been playing my hands ‘round a couple of stuff and just thought to share one simple yet interesting part of the things I fiddled with earlier this past week.
I had to sprinkle some images here and there while developing a landing page to help the company I work with hit marketing goals. It was a quick detour from my major tasks (I’ll explain later…not exactly, I’ll explain now).
Nice job the designer did! So they struck a deal with me to help breathe some life into their work of art. Everyone wants to see their products alive and well, everyone, especially product designers, haha! So I took the deal, and guess what I was to get in return, argh, it’s so ridiculous—a stick sweet! Well, not one, but two every day till the deal is done.
The original deal was four every day, but they said they were concerned about my health, now that’s not a bad thing, so two it was.
Time to stack these images
I implemented this design bottom-up after developing the basic layout structure, things like, the header and footer, etc. Although, I had no reason why I wanted to take it bottom up, or maybe I did, maybe I knew I had something special for the hero section which was at the top, immediately after the header, so I saved the best for the last. The thing is, the design never specified how these images should animate, so I conjured one from the deep of my mind, some kind of Leonardo da Vinci’s sh*t, yunno.
When there’s an incentive there’s a motive
Oh, they do mean the same thing!
Well, what I meant to mean by that was that incentives motivate people; but I think you already know that, right? I didn’t do it for the sweet, but because it was fun I thought I was doing it for the sweets. Oh, seven heavens! I’ve completely lost track of time. Okay, okay, okay, now that I’m motivated, let’s get into it yuh!
What we need
First, we need a couple of images to make this work. We can go cop some from Unsplash. Thereafter, we create a normal JavaScript/React project and install a couple of dependencies to make it work, but I trust you know how to make that work so I won’t get into that.
yarn add @chakra-ui/react @emotion/react @emotion/styled @emotion/memoize framer-motion react react-dom
# etc etc
Yeah, we’ll use chakra, because I don’t know how to create CSS files anymore and I just like to write everything inside the component.
Setting up our app
While working on this, I worked on a Next.js framework, but here I’ll try to be as
framework-agnostic as I can be. We start by creating our app file, doing a couple of imports as
necessary and writing some JSX or TSX. We go ahead to create our index file and import our app file
inside it rendering into the node#root
in the index.html
file.
import { ChakraProvider } from "@chakra-ui/react";
import { StackedImageAnimation } from './StackedImageAnimation';
const App = () => {
return (
<ChakraProvider>
<StackedImageAnimation>
</ChakraProvider>
);
};
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const rootElement = document.getElementById("root");
const root = createRoot(rootElement);
root.render(
<StrictMode>
<App />
</StrictMode>
);
Now to the main file that concerns us, aside from these boilerplates. We create our
StakedImageAnimation.tsx
file and get it started.
import { Box } from "@chakra-ui/react";
export const StackedImageAnimation = () => {
return (
<Box>
<Box></Box>
</Box>
);
};
Collecting assets
We’ll now head to Unsplash and get some images, about four of them at least. You don’t need to download the images, you can just copy their respective resource links as I did in the code below.
const images = [
'https://images.unsplash.com/photo-1576398289164-c48dc021b4e1?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2148&q=80',
'https://images.unsplash.com/photo-1499028344343-cd173ffc68a9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2370&q=80',
'https://images.unsplash.com/photo-1524593166156-312f362cada0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2370&q=80',
'https://images.unsplash.com/photo-1506917728037-b6af01a7d403?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2148&q=80',
]
Now, what we want to do is render these images inside our component. You could render them either by
using an <img />
tag (using chakra’s <Image />
component) or just render them in a <div />
as
a background image (using chakra’s <Box />
). I prefer to render them in a div
leveraging CSS background-image
and related properties.
import { Box } from "@chakra-ui/react";
const images = [
"https://images.unsplash.com/photo-1576398289164-c48dc021b4e1?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2148&q=80",
"https://images.unsplash.com/photo-1499028344343-cd173ffc68a9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2370&q=80",
"https://images.unsplash.com/photo-1524593166156-312f362cada0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2370&q=80",
"https://images.unsplash.com/photo-1506917728037-b6af01a7d403?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2148&q=80"
];
export const StackedImageAnimation = () => {
return (
<Box>
{images.map((image, i) => {
return (
<Box
key={image}
backgroundImage={`url(${image})`}
backgroundSize="cover"
backgroundRepeat="no-repeat"
/>
);
})}
</Box>
);
};
If you’re quite familiar with this, at this point we are in the code, you should know you won’t see
any image rendered. The boxes are rendered as regular divs in the DOM, but they’re empty, and
as a result, have no definite height or width. If height and width aren’t explicitly set on an
element, then they default to "auto"
for the browser to calculate itself based on what’s contained
inside the element.
What we’ll do now is set the height and width of the containing box, that is the one that wraps the inner boxes and let our inner boxes inherit their dimensions from this parent box.
Another thing is that we need to take these elements out of the regular layout flow because even the idea of stacking them atop each other suggests so. To do this, we give each of the rendered images an absolute position, which helps to position them relative to their closest relative-positioned ancestor.
export const StackedImageAnimation = () => {
return (
- <Box>
-+ <Box width={580} height={450} position="relative">
+ {images.map((image, i) => {
return (
<Box
key={image}
backgroundImage={`url(${image})`}
backgroundSize="cover"
backgroundRepeat="no-repeat"
+ width="inherit"
+ height="inherit"
+ position="absolute"
+ top={0}
+ right={0}
/>
);
})}
</Box>
);
};
The images are stacked now, but no, they aren’t visually stacked yet. To achieve that visual effect, we need to find a way to shrink down images in the rear and offset them to the right, each successive one farther than the one before it, all in the same geometric progression. This gives that perspective effect thereby making the images look stacked.
Understanding the animation flow
Before we can fully understand how the stacking will be achieved, we also need to understand how the swapping will happen. They are both intertwined.
How could we forget about z-index
when talking about stacking? To control how these images are
stacked we need to manage their respective z-indices at every point in the animation. But z-index
isn’t the only index we need to control, we also need to control the active index of the stack. The
active index tells us which of these images has just moved to the front of the stack. We manage the
active index—just like a fuel pump counter—using a circular roving technique, making use of modular
arithmetic (%
).
By moving just one of these images from their current position P
to Q
, their respective
z-indices are recomputed. This happens when the active index changes, say we have four images, the
lower limit is 0
the upper limit is 3
. We must make sure that when the upper limit is reached,
it doesn’t tend towards infinity, but returns to its lower limit to start all over again, on and on
in a circular roving manner, rotating from 0
to 3
to 0
. The mathematical formula to make sure
of this is:
(activeIndex + 1) % len(images)
(a + 1) % l = n
where `a` is active index;
`1` is a constant;
`l` is the length of the images list;
`n` is the next active index
(0 + 1) % 4 = 1 <--
(1 + 1) % 4 = 2 |
(2 + 1) % 4 = 3 |
(3 + 1) % 4 = 0 <--
Now that we know how to manage our active index let’s go ahead and add that to the code. Our active
index only changes every five seconds, and we manage that using a setInterval
inside a useEffect
hook.
+ import { useEffect, useMemo, useRef, useState } from 'react';
export const StackedImageAnimation = () => {
+ const [activeIndex, setActiveIndex] = useState(0);
+ const timer = useRef<NodeJS.Timeout | number>(-1);
+ const size = useMemo(() => images.length, []);
+
+ useEffect(() => {
+ timer.current = setInterval(
+ () => setActiveIndex((cur) => (cur + 1) % size),
+ 5000
+ );
+
+ return () => clearInterval(timer.current as number);
+ }, [size]);
+
return (...);
};
As observed in the figure above, we could map indices to z-indices, and notice that the z-indices aren’t changing, it’s only the active indices that keep rotating. When the rotation occurs the index at the top of the stack (active index) assumes or inherits the z-index at that position in the map for as long as the active index hasn’t changed (in our case for 5000 milliseconds).
The code block that follows is an implementation of the above image which specifies how the active index rotates around the z-index
const map = useMemo(() => {
const map = new Map<number, number>()
const len = images.length
let i = len
if (len < activeIndex || activeIndex < 0) throw new Error('Invalid index set as active index')
while (i > 0) {
map.set((activeIndex + len - i) % len, --i) }
return map
}, [activeIndex])
Now this map here, which computes every single time our activeIndex
changes (every 5000
milliseconds) is the solution to most of our problems.
Hopefully, the part that throws an exception will never be reached as long as all goes as planned. So it’s redundant, except that it communicates intent to whoever is reading it, as to what would be an unexpected behavior for our program.
It could be quite difficult to make sense of the line of code inside the while loop. It’s as good
as saying (activeIndex + i) % len
if i
had started from 0
and not the length of the images list 4
. So when i = 4; len - i = 0
, and
since
i
keeps decreasing every step of the loop, it would just seem like i
increases
from 0-3
Shrinking and offsetting rear images progressively
Now that we’ve established the framework for our animation, the majority of what seemed difficult to do
is a great deal easier. One way I can think of for shrinking down rear images is to
transform them using the CSS scale
function. For offsetting, there are two possible ways I can
think of: one of which is also to transform using the CSS translate
function; two, is to make use of
the CSS right
property since our images are absolute-positioned to their relative-positioned
parent box.
Now, some other deductions we can make is that the scale and the offset are a function of the z-index. The image with the highest z-index (top of the stack) needs no shrinking and offsetting, and the image with the least z-index (bottom of the stack) needs the most shrinking and offsetting.
The dependent variables here are the scale and offset factor, while the independent variable is the z-index which we can always retrieve from the map for each index of the images.
We can pick a constant, multiple of which will make the cumulative factor by which our images would
be scaled down and offset. I prefer to pick something small, say 0.075
or 0.09
maybe.
<Box width={580} height={450} position="relative">
{images.map((image, i) => {
+ const factor = size - 1 - map.get(i)!;
+
return (
<Box
key={image}
backgroundImage={`url(${image})`}
backgroundSize="cover"
backgroundRepeat="no-repeat"
width="inherit"
height="inherit"
position="absolute"
top={0}
- right={0}
+ right={0 - 0.075 * factor * 580}
+ transform={`scale(${1 - 0.075 * factor})`}
/>
)
})}
</Box>
size - 1
in the code above, when computing our factor is just a reminder that our
indices goes from 0-3
but length of our list goes from 1-4
What goes into factor?
So this is the deal: take a look at the figure image that lays out the active index to z-index
mapping. Say our active index is 2
and i
is also currently 2
, then to find the appropriate
z-index for images[2]
, we’ll do a map.get(i)
value of which should be 3
. Every time our active
index changes, all indices are re-rotated around the z-index. As I said earlier, the scale and
offset are a function of the z-index. images[2]
is at the top of the stack, so it needs the least
shrinking and offsetting; images[1]
is at the bottom of the stack, so it needs the most shrinking
and offsetting. In other words, scale and offset are inversely related to the z-index. At active
index 2
the z-index is up to 3
; to get the correct factor we do a size - 1 - map.get(i)
, where
size = 4
, giving a final result of 0
.
When i
was at 1
, map.get(i)
gives a z-index of 0
. Running that through our function again,
size - 1 - map.get(i)
will give 3
.
We take this computed factor and multiply it with our base diminishing factor of 0.075
to get
multiples of it progressively down the loop.
Where does 580
come from?
scale
acts on the size of an object (x
, y
) or (width
, height
). If we’ll be calculating the
offset across the x-axis (right
), then we must know what proportion of the initial size (width
)
of our object (in this case element) it was diminished by. We take out that same proportion to the
right to make up for the shrink in the offset. There and there we have it, our images looking
stacked.
Where do we go from here?
Now that we have our images stacked and swapping every five seconds we rotate the active index in
our useEffect
hook, we need to make this animation feel more natural. Some transitioning would do
the trick actually, and we can go ahead and achieve that using the CSS transition
property.
Something that we are forgetting, and you have failed to remind me is setting our z-index
.
<Box width={580} height={450} position="relative">
{images.map((image, i) => {
const factor = size - 1 - map.get(i)!;
+ const isPreviousActiveIndex = (activeIndex + size - 1) % size === i;
return (
<Box
key={image}
backgroundImage={`url(${image})`}
backgroundSize="cover"
backgroundRepeat="no-repeat"
width="inherit"
height="inherit"
position="absolute"
top={0}
right={0 - 0.075 * factor * 580}
transform={`scale(${1 - 0.075 * factor})`}
+ zIndex={map.get(i)}
+ transition={"z-index 0.6s ease, transform 0.6s ease".concat(
+ isPreviousActiveIndex ? ", right 0.3s ease" : ""
+ )}
/>
)
})}
</Box>
Hmm…I thought we only mentioned
z-index
andtransition
. Why are we doing aisPreviousActiveIndex
check?
Good question!
We want a right transition only for the image that was formerly at the top of the stack which is now getting swapped out to the bottom just for the one right behind it to come to the top. The others don’t need that right transition, they will just grow bigger and move quite inwards from the right. What matters is how they grow bigger, but for the one moving to the bottom, it starts with moving outwards to the right and then growing smaller so we want both transitions for it.
Putting the pieces together
import { Box } from "@chakra-ui/react";
import { useEffect, useMemo, useRef, useState } from 'react';
const images = [
"https://images.unsplash.com/photo-1576398289164-c48dc021b4e1?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2148&q=80",
"https://images.unsplash.com/photo-1499028344343-cd173ffc68a9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2370&q=80",
"https://images.unsplash.com/photo-1524593166156-312f362cada0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2370&q=80",
"https://images.unsplash.com/photo-1506917728037-b6af01a7d403?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2148&q=80"
];
export const StackedImageAnimation = () => {
const [activeIndex, setActiveIndex] = useState(0);
const timer = useRef<NodeJS.Timeout | number>(-1);
const size = useMemo(() => images.length, []);
useEffect(() => {
timer.current = setInterval(
() => setActiveIndex((cur) => (cur + 1) % size),
5000
);
return () => clearInterval(timer.current as number);
}, [size]);
const map = useMemo(() => {
const map = new Map<number, number>();
const len = images.length;
let i = len;
if (len < activeIndex || activeIndex < 0)
throw new Error('Invalid index set as active index');
while (i > 0) {
map.set((activeIndex + len - i) % len, --i);
}
return map;
}, [activeIndex]);
return (
<Box width={580} height={450} position="relative">
{images.map((image, i) => {
const factor = size - 1 - map.get(i)!;
const isPreviousActiveIndex = (activeIndex + size - 1) % size === i;
return (
<Box
key={image}
backgroundImage={`url(${image})`}
backgroundSize="cover"
backgroundRepeat="no-repeat"
width="inherit"
height="inherit"
position="absolute"
top={0}
right={0 - 0.075 * factor * 580}
transform={`scale(${1 - 0.075 * factor})`}
zIndex={map.get(i)}
transition={"z-index 0.6s ease, transform 0.6s ease".concat(
isPreviousActiveIndex ? ", right 0.3s ease" : ""
)}
/>
);
})}
</Box>
);
};
Don’t forget we have the codesandbox above there for you to take a look and fiddle with as you think is interesting.
Don’t forget to share also!
Thank you and have a wonderful week!