DEV Community

Cover image for An animated image picker with React and Styled Components
Uralys
Uralys

Posted on

An animated image picker with React and Styled Components

An animated image picker

Image description

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];
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Notes:

  • on hover I scale the image to give a visual feedback to the user, and transition 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>
Enter fullscreen mode Exit fullscreen mode

Image description

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)}
/>
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

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 if preferredGraphicGuideline is the one we currently reach in the loop.
  • an item isFree when no item is selected, so preferredGraphicGuideline === -1

Which gives:

  isSelected={preferredGraphicGuideline === graphicGuideline}
  isFree={preferredGraphicGuideline === -1}
Enter fullscreen mode Exit fullscreen mode

Here I printed the booleans and the distance from the center to illustrate:

Image description

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}
      // ...
      />
  );
Enter fullscreen mode Exit fullscreen mode

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`}
  );
Enter fullscreen mode Exit fullscreen mode

The opacity to hide the items is:

  opacity: ${props => (props.isFree || props.isSelected ? '1' : '0')};
Enter fullscreen mode Exit fullscreen mode

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;
      }
    `};
  }
Enter fullscreen mode Exit fullscreen mode

(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;
};
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}>
Enter fullscreen mode Exit fullscreen mode

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};
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Image description

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)