An animated image picker with React and Styled Components
Published: 2024-03-11 08:25:18

# An animated image picker


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s0tzzzko1y84uqolcnrm.gif)

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.

```ts
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.

```ts
export type GraphicGuideline = -1 | 1 | 2 | 3 | 4;
export const graphicGuidelines: Array<GraphicGuideline> = [1, 2, 3, 4];
```

## The component for 1 image

```tsx
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, 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.

```tsx
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>
```

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pkghhq2suowde57izodz.gif)

## The state

The state will be updated the same way I explained in my [previous article](todo link to article la taverne 1), 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:

```tsx
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:

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

Which gives:

```tsx
  isSelected={preferredGraphicGuideline === graphicGuideline}
  isFree={preferredGraphicGuideline === -1}
```

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


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3kanj5nqigbwdblfrj8p.png)

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:

```tsx
  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:

```tsx
  transform: translateX(
    ${props =>
      props.isFree ? '0%' : `${props.distanceFromCenter * props.$width}px`}
  );
```

The opacity to hide the items is:

```tsx
  opacity: ${props => (props.isFree || props.isSelected ? '1' : '0')};
```

The scaling depends on the item being `isSelected` or `hovered` while `isFree`:

```tsx
  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`

```tsx
type GraphicDemoProps = {
  num: GraphicGuideline;
  distanceFromCenter: number;
  onClick: () => void;
  isFree: boolean;
  isSelected: boolean;
};
```

And here is final styled CSS:

```tsx
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:

```tsx
<$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`.

```tsx
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:

```tsx
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:

```tsx
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>
  );
};
```


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bbhvxozpmog1xy229ggq.gif)

### 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.