An animated image picker
Today I have to ask the user to select one image among a list of 4.
Let's setup the assets
First I create a simple array with images handfully importable by ViteJS.
import graphic1 from './assets/images/1.webp';
import graphic2 from './assets/images/2.webp';
import graphic3 from './assets/images/3.webp';
import graphic4 from './assets/images/4.webp';
const images = [graphic1, graphic2, graphic3, graphic4];
Types and data
My user will be able to select one of these images, we'll use the value -1
to represent the "nothing selected" option.
export type GraphicGuideline = -1 | 1 | 2 | 3 | 4;
export const graphicGuidelines: Array<GraphicGuideline> = [1, 2, 3, 4];
The component for 1 image
type GraphicDemoProps = {
num: GraphicGuideline;
};
const $GraphicDemo = styled.div`
padding: 12px;
cursor: pointer;
img {
height: 300px;
transition: all 0.2s ease-in-out;
&:hover {
scale: 1.2;
}
}
`;
const GraphicDemo = (props: GraphicDemoProps) => {
const {num} = props;
return (
<$GraphicDemo>
<img src={images[num - 1]} alt={`guideline${num}`} />
</$GraphicDemo>
);
};
Notes:
- on
hover
I scale the image to give a visual feedback to the user, andtransition
allows to animate smoothly the changes. - I've been prefixing my styled components with
$
to make them easily recognizable from React components with logic, call it a personal taste.
The flexbox container
Now we can map these options to display every image, in a Flexbox container.
const $Graphics = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 10px 100px;
`;
// ... in the component:
<$Graphics>
{graphicGuidelines.map(graphicGuideline => (
<GraphicDemo
key={`graphic-${graphicGuideline}`}
num={graphicGuideline}
/>
))}
</$Graphics>
The state
The state will be updated the same way I explained in my previous article, you can go there to see how I implement the action and reducing in the barrel
;
I'll only show how is the onClick
plugged here:
import {useTaverne} from 'taverne/hooks';
// ... in the component:
const {dispatch, pour} = useTaverne();
const preferredGraphicGuideline = pour(
'user.preferredGraphicGuideline'
) as GraphicGuideline;
const pickGraphicGuideline = (graphicGuideline: GraphicGuideline) => () => {
dispatch({
type: PICK_GRAPHIC_GUIDELINE,
payload: {graphicGuideline}
} as PickGraphicGuidelineAction);
// ...
<GraphicDemo
key={`graphic-${graphicGuideline}`}
num={graphicGuideline}
onClick={pickGraphicGuideline(graphicGuideline)}
/>
Thinking the animation
So, we have now our list and the state to select one of them.
I also added a button to reset the selection (and somehow dispatch this -1), but let's dig the fun part: the animation.
The idea is to smoothly hide the items not selected and to focus on the selected one.
I want to center the selected item, but everything is positioned with flexbox
.
Let's try to use a transform: translateX
to move evey elements.
a bit of math
- The center of a list of integers is
(length + 1) / 2
- The x axis goes from left to right, so the distance of an item from the center of the row is
center - (index + 1)
Our list starts at 1, which gives:
distanceFromCenter={
0.5 * (graphicGuidelines.length + 1) - graphicGuideline
}
few booleans
The goal is:
- to see the row of items when they are all free,
- to hide all items but the selected one when an item is selected.
So while we loop on the items, we'll define:
- an item
isSelected
ifpreferredGraphicGuideline
is the one we currently reach in the loop. - an item
isFree
when no item is selected, sopreferredGraphicGuideline === -1
Which gives:
isSelected={preferredGraphicGuideline === graphicGuideline}
isFree={preferredGraphicGuideline === -1}
Here I printed the booleans and the distance from the center to illustrate:
Now we just need to translate every items within X, depending on the distance from the center, and to hide the items that are not selected.
Animating with React and Styled Components
React
First we need to get the width of the item.
Let's create update the GraphicDemo
with a React ref and a hook to get the offsetWidth
of the item:
const [width, setWidth] = useState(0);
const imageRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (imageRef.current) {
setWidth(imageRef.current.offsetWidth);
}
}, [imageRef]);
return (
<$GraphicDemo ref={imageRef} $width={width} {...props}
// ...
/>
);
Note: $with
has a $
prefix here: while it's not a strict rule, it can serve as a visual indicator to developers that a prop is specific to the styled component and not a standard HTML or React prop.
Moreover, it avoids warnings such as "Warning: React does not recognize the xxx
prop on a DOM element."
Style
The transform to center the selected item is:
transform: translateX(
${props =>
props.isFree ? '0%' : `${props.distanceFromCenter * props.$width}px`}
);
The opacity to hide the items is:
opacity: ${props => (props.isFree || props.isSelected ? '1' : '0')};
The scaling depends on the item being isSelected
or hovered
while isFree
:
img {
scale: ${props => (props.isSelected ? 1.5 : 1)};
${props =>
props.isFree &&
`
&:hover {
scale: 1.2;
}
`};
}
(well yes this final part is quite ugly 🤮)
Wrapping up
Now here is a reminder about the Props we handle to better understand the CSS
type GraphicDemoProps = {
num: GraphicGuideline;
distanceFromCenter: number;
onClick: () => void;
isFree: boolean;
isSelected: boolean;
};
And here is final styled CSS:
const $GraphicDemo = styled.div<GraphicDemoProps & {$width: number}>`
transition: all 0.2s ease-in-out;
transform: translateX(
${props =>
props.isFree ? '0%' : `${props.distanceFromCenter * props.$width}px`}
);
opacity: ${props => (props.isFree || props.isSelected ? '1' : '0')};
img {
height: 300px;
scale: ${props => (props.isSelected ? 1.5 : 1)};
transition: scale 0.2s ease-in-out;
${props =>
props.isFree &&
`
&:hover {
scale: 1.2;
}
`};
${maxWidth_736} {
height: 180px;
}
}
`;
Notes:
- I should explain someday my media queries and breakpoints system you see here with
${maxWidth_736}
- I simplified the rules to focus on what matters here, but I tweaked some borders and shadows to make it look shiny.
And finally the loop itself, rendering the row:
<$Graphics>
<>
{graphicGuidelines.map(graphicGuideline => (
<GraphicDemo
isSelected={preferredGraphicGuideline === graphicGuideline}
isFree={preferredGraphicGuideline === -1}
key={`graphic-${graphicGuideline}`}
num={graphicGuideline}
distanceFromCenter={
0.5 * (graphicGuidelines.length + 1) - graphicGuideline
}
onClick={pickGraphicGuideline(graphicGuideline)}
/>
))}
{preferredGraphicGuideline > 0 ? (
<UnselectButton onClick={pickGraphicGuideline(-1)} />
) : null}
</>
</$Graphics>
Caveats
This method works only if the items :
- are positioned in a row,
- have the same width.
There may be issues calculating the offsetWidth
,
Also, the offsetWidth
is not updated immediately, when the image is loaded, so I need to wait for the image to be loaded before updating the offsetWidth
.
const [imageLoaded, setImageLoaded] = useState(false);
const handleImageLoad = () => {
setImageLoaded(true);
};
<img onLoad={handleImageLoad}>
But it also can be an issue if the window is resized, as the offsetWidth
will not be updated.
I overcame this issue by adding a resize
event listener to the window, and by updating the offsetWidth
of the items when the window is resized.
Here is the hook I use to listen to the window dimensions:
import {useEffect, useState} from 'react';
export const useWindowDimensions = () => {
const [width, setWidth] = useState(window.innerWidth);
const [height, setHeight] = useState(window.innerHeight);
const updateDimensions = () => {
setWidth(window.innerWidth);
setHeight(window.innerHeight);
};
useEffect(() => {
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}, []);
return {width, height};
};
Now our GraphicDemo component can be defined entirely:
const GraphicDemo = (props: GraphicDemoProps) => {
const {num} = props;
const [width, setWidth] = useState(0);
const {width: windowWidth} = useWindowDimensions();
const itemRef = useRef<HTMLDivElement>(null);
const [imageLoaded, setImageLoaded] = useState(false);
const handleImageLoad = () => {
setImageLoaded(true);
};
useEffect(() => {
if (itemRef.current) {
setWidth(itemRef.current.offsetWidth);
}
}, [itemRef, imageLoaded, windowWidth]);
return (
<$GraphicDemo ref={itemRef} $width={width} {...props}>
<img
onLoad={handleImageLoad}
src={images[num - 1]}
alt={`guideline${num}`}
/>
</$GraphicDemo>
);
};
Thanks for reading, see you around!
Feel free to comment on parts you need more explanations for, or share your thoughts on how you would have handled the parts you disagree with.
Top comments (0)