Sam Phillips
The Frontier

The Frontier

Create Beautiful 3D Perspective Images Easily Using React and CSS

Create Beautiful 3D Perspective Images Easily Using React and CSS

Sam Phillips's photo
Sam Phillips
ยทAug 15, 2022ยท

6 min read

Subscribe to my newsletter and never miss my upcoming articles

Table of contents

  • Introduction
  • Setup
  • A Little Math
  • A Little More Flare
  • It Doesn't Have to be an Image!
  • Conclusion

Introduction

Perspective transform looks great and creates a really nice experience for the user. When using a framework like React, implementing something like this becomes super simple only using a few CSS styles ๐Ÿ‘

complete.gif

Setup

Lets start with a simple Image component:

const Image = ({ width, height, src, alt }) => {
    return <img width={width} height={height} src={src} alt={alt} />;
};

This just takes our basic props and applies them to an img element.

Now let's add our base styles ready for manipulation, along with the state we will need:

const Image = ({ width, height, src, alt }) => {
    const [scale, setScale] = useState(1);
    const [rx, setRx] = useState(0);
    const [ry, setRy] = useState(0);

    return (
        <img
            width={width}
            height={height}
            src={src}
            alt={alt}
            style={{
                transition: "transform 150ms linear",
                transform: `perspective(1000px) rotateX(${ry}deg) rotateY(${rx}deg) scale3d(${scale},${scale},${scale})`
            }}
        />
    );
};

It may look a little intimidating at first! But it's really not that complex. First we defined scale, rx and ry state variables to handle the state changes we are yet to implement.

Then we added two styles. transition: "transform 150ms linear" applies an animation to any changes on the transform style over 150ms using the ease-out timing function. We also added transform which is where the magic happens. Let's break it down.

The transform style is performing 4 operations. perspective(1000px) is what gives the 3d perspective effect to the image. Without it, the rest of the operations would still apply but it wouldn't give an appearance of depth. The value of 1000px is generally a good value to use and looks good, you can play around with this value to see what it does and can make some crazy effects by setting it really low ๐Ÿ˜‚. rotateX(${ry}deg) is injecting the ry value telling the image to rotate on the x axis, we inverse the x and y axis when applying the values as rotating the x axis tilts the image back and forth not left to right, so its intuitive to the user when moving the mouse over it to inverse these values. The same goes for rotateY(${rx}deg). scale3d(${scale},${scale},${scale}) is applying scale to all 3 axis, this will give a nicer effect when we add our math to make the image a little larger whilst the users cursor is hovering over it.

In most browsers, changing the perspective of an element won't work without specifying a scale3d() function. I'm not sure if this is intentional or not, but for now, even if we don't want to scale an element, we still have to set scale3d(1,1,1).

A Little Math

Let's add in our event handlers and do a little math!

const Image = ({ width, height, src, alt }) => {
    const imgRef = useRef();
    const [scale, setScale] = useState(1);
    const [rx, setRx] = useState(0);
    const [ry, setRy] = useState(0);

    const onMouseMove = (ev) => {
        // Get the bounding rect for the image
        const rect = imgRef.current.getBoundingClientRect();

        // Get the X and Y position of the cursor relative to the image
        const x = ev.clientX - rect.left;
        const y = ev.clientY - rect.top;

        // Funky math to normalize the x/y position of the cursor
        // to between -20 and 20
        // For the effect to work as expected, the X axis need to be inversed
        const nX = 40 - (x * 40) / rect.width - 20;
        const nY = (y * 40) / rect.height - 20;

        // Set rx and ry
        setRx(nX);
        setRy(nY);

        // Set the scale to 1.1 shilst the cursor is over the image
        setScale(1.1);
    };

    const onMouseLeave = () => {
    // When the cursor leaves the image, reset the state back to default values
        setRx(0);
        setRy(0);
        setScale(1);
    };

    return (
        <img
            ref={imgRef}
            width={width}
            height={height}
            src={src}
            alt={alt}
            onMouseMove={onMouseMove}
            onMouseLeave={onMouseLeave}
            style={{
                transition: "transform 150ms linear",
                transform: `perspective(1000px) rotateX(${ry}deg) rotateY(${rx}deg) scale3d(${scale},${scale},${scale})`
            }}
        />
    );
};

Its Alive!

base.gif

We added an onMouseMove event to handle getting the position of the cursor relative to the image element, and normalized those x/y values to between -20 and 20. This allows us to simply apply those values to the rotateX and rotateY transform functions by setting them into rxand ry!

onMouseLeave simply resets the state for rx, ry and scale to their default values and returns the image to it's default state.

A Little More Flare

This looks great but another great feature that can be used in tandem with all this is box-shadow as perspective transforms render the shadow as well! Better yet, we can swap the shadow out for a glow effect when the user starts hovering over the image to really make it pop ๐ŸŽ‰

const BASE_SHADOW = "rgba(0, 0, 0, 0.4) 0px 12px 28px -10px";
const GLOW_SHADOW = "rgb(113,199,236) 0px 0px 15px 5px";

const Image = ({ width, height, src, alt }) => {
    const imgRef = useRef();
    const [scale, setScale] = useState(1);
    const [rx, setRx] = useState(0);
    const [ry, setRy] = useState(0);
    const [shadow, setShadow] = useState(BASE_SHADOW);

    const onMouseMove = (ev) => {
        // Get the bounding rect for the image
        const rect = imgRef.current.getBoundingClientRect();

        // Get the X and Y position of the cursor relative to the image
        const x = ev.clientX - rect.left;
        const y = ev.clientY - rect.top;

        // Funky math to normalize the x/y position of the cursor
        // to between -20 and 20
        // For the effect to work as expected, the X axis need to be inversed
        const nX = 40 - (x * 40) / rect.width - 20;
        const nY = (y * 40) / rect.height - 20;

        // Set rx and ry
        setRx(nX);
        setRy(nY);

        // Set the scale to 1.1 shilst the cursor is over the image
        setScale(1.1);

        // Set the glow shadow effect
        setShadow(GLOW_SHADOW);
    };

    const onMouseLeave = () => {
        // When the cursor leaves the image, reset the state back to default values
        setRx(0);
        setRy(0);
        setScale(1);
        setShadow(BASE_SHADOW);
    };

    return (
        <img
            ref={imgRef}
            width={width}
            height={height}
            src={src}
            alt={alt}
            onMouseMove={onMouseMove}
            onMouseLeave={onMouseLeave}
            style={{
                boxShadow: shadow,
                transition: "all 150ms linear",
                transform: `perspective(1000px) rotateX(${ry}deg) rotateY(${rx}deg) scale3d(${scale},${scale},${scale})`
            }}
        />
    );
};

And here is the final result!

complete.gif

We set a couple of constant values BASE_SHADOW and GLOW_SHADOW, added a new shadow state variable and switched between them in the same way as the other state values. We also added the boxShadow style on the img element, applying shadow.

Notice a small change was made to transition from transform 150ms linear to all 150ms linear, this ensure that the transition animation is also applied to the box-shadow and not just the transition style.

It Doesn't Have to be an Image!

Since this is just using CSS transforms, it can be applied to anything! Here is the same component edited to use a div with some text in it ๐Ÿ‘

card.gif

Conclusion

That's it! I hope this article helped you do fancy things with CSS transforms.

Follow me on Twitter to keep up to date with my projects and get notified of new articles like this in the future!

ย 
Share this