Use React to make a photo follow the mouse (aka transform perspective or tilt)

Fig 1.0 — Dat Perspective

TURBO USERS: Grab the completed files from GitHub:

  • React JS Environment
  • ES6 JavaScript
  • Spread Operator
  • findDOMNode (the one your mother warned you about)
  • getBoundingClientRect
  • requestAnimationFrame
  • Brutal Mathematics (just kidding)
  1. the X and Y coordinates of the photo
  2. the X and Y coordinates of the mouse

Phase 1: render the image

Let’s slap some boilerplate in, so we’re all on the same page. Fire up Create-React-App (CRA) from your local wizards at Facebook. If you aren’t using CRA, you should consider it because it brings an emphasis on zero-config or at least minimal config. No one likes to spend 700 hours configuring their app before they start developing it, not that there’s anything wrong with that.

  1. Clone the GitHub repo and read the project’s App.js file.
  2. Continue reading and type now in your terminal: create-react-app react-tilt
import React, { Component } from 'react'
import dogBackground from './dogBackground.jpg'
const styles = {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}
class TiltPhaseOne extends Component {
render() {
return (
<div style={styles}>
<h1>Mouse over the photo:</h1>
<img src={dogBackground} alt="" />
</div>
)
}
}
export default TiltPhaseOne
Fig 1.0 — Dog Background

Phase 2: track mouse movements

For this task, we look at these Synthetic Events:

  • onMouseEnter
  • onMouseMove
  • onMouseLeave

React Synthetic Events

Sounds pretty intuitive right? These are React Synthetic Events that fire on those events. It’s very important to understand — React does not handle events like you would expect in vanilla JS. The bottom line is React manages these events without us requiring to start and stop the handlers manually.

import React, { Component } from 'react'
import dogBackground from './dogBackground.jpg'
const styles = {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}
class TiltPhaseTwo extends Component {
constructor(props) {
super(props)
this.state = {}
this.handleMouseEnter = this.handleMouseEnter.bind(this, this.props.handleMouseEnter)
this.handleMouseMove = this.handleMouseMove.bind(this, this.props.handleMouseMove)
this.handleMouseLeave = this.handleMouseLeave.bind(this, this.props.handleMouseLeave)
}
handleMouseEnter(e) {
console.log('onMouseEnter', e.clientX, e.clientY)
}
handleMouseMove(e){
console.log(
'onMouseMove',
e.nativeEvent.clientX, e.nativeEvent.clientY
)
}
handleMouseLeave(e) {
console.log('onMouseLeave', e.clientX, e.clientY)
}
render() {
return (
<div style={styles}>
<h1>Mouse over the photo:</h1>
<div
style={{
display: 'flex',
justifyContent: 'center',
width: '100%',
height: '100%',
}}
onMouseEnter={this.handleMouseEnter}
onMouseMove={this.handleMouseMove}
onMouseLeave={this.handleMouseLeave}
>
<img src={dogBackground} alt="" />
</div>
</div>
)
}
}
export default TiltPhaseTwo

On vs. Handle

Notice how we called the Class Methods handle rather than on. I like to remind people about the distinction between the two. on refers to the event on which we are doing something. handle refers to the action we are taking or the result of the event. When an event occurs, we are going to handle it with our Class Methods.

<button onClick={onClick}>
Submit
</button>

e.nativeEvent

The React Documentation states:

Phase 3: track the photo coordinates

We are going to incrementally update your Class Methods. From now on when I show code, just replace the entire function with the new one (in case you get confused).

import { findDOMNode } from 'react-dom'// ...constructor(props) {
super(props)
this.state = {}
this.element = null
this.width = null
this.height = null
this.left = null
this.top = null
// Remember, we are not using move or leave events yet,
// but add them now.
this.handleMouseEnter = this.handleMouseEnter.bind(this, this.props.handleMouseEnter)
this.handleMouseMove = this.handleMouseMove.bind(this, this.props.handleMouseMove)
this.handleMouseLeave = this.handleMouseLeave.bind(this, this.props.handleMouseLeave)
}
componentDidMount() {
this.element = findDOMNode(this)
}
// ...handleMouseEnter(e) {
this.updateElementPosition()
}
// ...updateElementPosition() {
const rect = this.element.getBoundingClientRect()
this.width = this.element.offsetWidth
this.height = this.element.offsetHeight
this.left = rect.left
this.top = rect.top
console.log('REKT', rect)
console.log('OFFSET WIDTH', this.element.offsetWidth)
console.log('OFFSET HEIGHT', this.element.offsetHeight)
}
// ...

findDOMNode

There is one key mention with this. You’ve probably heard people express a bit of hesitation in some cases when findDOMNode is mentioned. It provides direct access to the DOM Node, but React manages the DOM for us. That is the central reason we don’t want everybody to start linking directly to DOM Nodes. Not letting React manage your DOM elements is like paying an accountant to track every cent of your money and then losing receipts.

Fig 3.0 — Read-only is fine

getBoundingClientRect

We always check the MDN first:

Fig 3.1 — You what mate ??
Fig 3.2 — Graphing Coordinates

getBoundingClientRect gets the X and Y coordinates and the width and height of a DOM element.

Things are about to escalate very quickly, but all we are doing is re-calculating where the mouse is with respect to the photo. We are doing that every time the mouse moves via the onMouseMove event. If you get overwhelmed, just imagine we are declaring a couple formulas and telling React to go nuts with them every time the mouse moves.

Phase 4: calculate necessary changes

We are going to need to talk about each function. Let’s start by updating our class for Phase 4. Here’s what I want you to do:

  1. paste all of this code in as the guts of your Class
  2. fix the formatting
  3. look at the type of things that are happening in the code
  4. take your time, this is serious learning potential
  5. join me at the bottom of this code block:
// This is your entire Class now
// with Phase 5 included but commented out.
constructor(props) {
super(props)
this.state = {
style: {}
}
const defaultSettings = {
reverse: false,
max: 35,
perspective: 1000,
easing: 'cubic-bezier(.03,.98,.52,.99)',
scale: '1.1',
speed: '1000',
transition: true,
axis: null,
reset: true
}
this.width = null
this.height = null
this.left = null
this.top = null
this.transitionTimeout = null
this.updateCall = null
this.element = null
this.settings = {
...defaultSettings,
...this.props.options,
}
this.reverse = this.settings.reverse ? -1 : 1
this.handleMouseEnter = this.handleMouseEnter.bind(this, this.props.handleMouseEnter)
this.handleMouseMove = this.handleMouseMove.bind(this, this.props.handleMouseMove)
this.handleMouseLeave = this.handleMouseLeave.bind(this, this.props.handleMouseLeave)
}
componentDidMount() {
this.element = findDOMNode(this)
}
// componentWillUnmount() {
// clearTimeout(this.transitionTimeout)
// cancelAnimationFrame(this.updateCall)
// }
handleMouseEnter(cb = () => { }, e) {
this.updateElementPosition()
// this.setState(prevState => ({
// style: {
// ...prevState.style,
// }
// }))
// this.setTransition()
// return cb(e)
}
// reset() {
// window.requestAnimationFrame(() => {
// console.log('RESETTING TRANSFORM STATE', `perspective(${this.settings.perspective}px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)`)
// this.setState(prevState => ({
// style: {
// ...prevState.style,
// transform: `perspective(${this.settings.perspective}px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)`,
// }
// }))
// })
// }
handleMouseMove(cb = () => { }, e) {
e.persist()
if (this.updateCall !== null) {
window.cancelAnimationFrame(this.updateCall)
}
this.event = e
this.updateCall = requestAnimationFrame(this.update.bind(this, e))
return cb(e)
}
// setTransition() {
// clearTimeout(this.transitionTimeout)
// console.log('SET TRANSITION', `Speed: ${this.settings.speed}ms Easing: ${this.settings.easing}`)
// this.setState(prevState => ({
// style: {
// ...prevState.style,
// transition: `${this.settings.speed}ms ${this.settings.easing}`,
// }
// }))
// this.transitionTimeout = setTimeout(() => {
// console.log('TRANSITION COMPLETE')
// this.setState(prevState => ({
// style: {
// ...prevState.style,
// transition: '',
// }
// }))
// }, this.settings.speed)
// }
handleMouseLeave(cb = () => { }, e) {
// this.setTransition()
// if (this.settings.reset) {
// this.reset()
// }
// return cb(e)
}
getValues(e) {
const x = (e.nativeEvent.clientX - this.left) / this.width
const y = (e.nativeEvent.clientY - this.top) / this.height
const _x = Math.min(Math.max(x, 0), 1)
const _y = Math.min(Math.max(y, 0), 1)
const tiltX = (this.reverse * (this.settings.max / 2 - _x * this.settings.max)).toFixed(2)
const tiltY = (this.reverse * (_y * this.settings.max - this.settings.max / 2)).toFixed(2)
const percentageX = _x * 100
const percentageY = _y * 100
console.log('JUST GOT NEW VALUES', `X: ${x} Y: ${y} -- TILT X: ${tiltX} TILT Y: ${tiltY} -- TILT X%: ${percentageX} TILT Y%: ${percentageY}`)
console.log('Notice how X turned into percentageX.')
return {
tiltX,
tiltY,
percentageX,
percentageY,
}
}
updateElementPosition() {
const rect = this.element.getBoundingClientRect()
this.width = this.element.offsetWidth
this.height = this.element.offsetHeight
this.left = rect.left
this.top = rect.top
}
update(e) {
let values = this.getValues(e)
console.log('VALUES', values)
console.log('NEW CSS TRANSFORM VALUES', `perspective(${this.settings.perspective}px) rotateX(${this.settings.axis === 'x' ? 0 : values.tiltY}deg) rotateY(${this.settings.axis === 'y' ? 0 : values.tiltX}deg) scale3d(${this.settings.scale}, ${this.settings.scale}, ${this.settings.scale})`)
// this.setState(prevState => ({
// style: {
// ...prevState.style,
// transform: `perspective(${this.settings.perspective}px) rotateX(${this.settings.axis === 'x' ? 0 : values.tiltY}deg) rotateY(${this.settings.axis === 'y' ? 0 : values.tiltX}deg) scale3d(${this.settings.scale}, ${this.settings.scale}, ${this.settings.scale})`,
// }
// }))
this.updateCall = null
}
render() {
const style = {
...this.props.style,
...this.state.style
}
return (
<div style={styles}>
<h1>Mouse over the photo:</h1>
<div
style={style}
onMouseEnter={this.handleMouseEnter}
onMouseMove={this.handleMouseMove}
onMouseLeave={this.handleMouseLeave}
>
<img src={dogBackground} alt="" />
</div>
</div>
)
}
Fig 4.0 — Just wait ‘til this code his 88 mph
Fig 4.1 — Image Perspective
Vid 4.2–3D Web Design

Default Settings

Since we are making a reusable component, we need some default settings.

  • reverse: if true, photo will transform in the opposite direction
  • max: defines the tilt rotation amount (in degrees)
  • perspective: the transform perspective, (lower = more extreme)
  • easing: allows you to change the easing style and values (default is great)
  • scale: amount to zoom in onMouseEnter (ie: 1 = 100%, 2 = 200%)
  • speed: effect transition speed (in milliseconds)
  • transition: transition on or off (boolean)
  • axis: disable an axis (string, value can be X or Y)
  • reset: reset the effect onMouseLeave (boolean)

Additional Class Properties

We have a couple extra Class Properties now because they are holding the state. We are avoiding setState because we don’t want to trigger any unecessary re-rendering. As you could imagine, we are trending towards the worst idea ever when we consider re-renderingonMouseMove.

requestAnimationFrame

This helps execute animation related JavaScript efficiently. With it, we are telling the browser we want to load up on calls to this.update(). It helps us avoid using setTimeout and setInterval. Those can be unruly and janky. You can read more about it here, here, and here:

getValues

There is a bit of a chain reaction going on, and that’s the only reason why this code looks a bit crazy.

  1. requestAnimationFrame triggers this.update().
  2. this.update() calls this.getValues() which is the function that does all our dirty work. It re-calculates the X and Y position of the mouse with respect to the container and calculates the tilt percentage. Our CSS will eat these numbers up.
  3. Once this.update() gets those values, it updates the CSS. It’s actually kind of simple if you ignore all the complexity, wait wut?
  1. Mouse moves (handler fires)
  2. X and Y values update (state updates)
  3. Re-render with the new CSS (state or props just changed)

Phase 5: update the CSS

Now, uncomment everything starting from the top and let’s examine them real quick to ensure no one gets left in the dust.

componentWillUnmount

We added a componentWillUnmount Lifecycle Method which cleans up leftover garbage when the Component unmounts.

handleMouseEnter

We talked about this.updateElementPosition(). Now, we’ve added this.setTransition() which handles the transition as your mouse enters or leaves the container. The objective of this method is to aid with a smooth transition or at least a custom transition. The exact effects depend on your default settings and desires.

reset

We need to update the CSS onMouseLeave because we may wish for the container/image to quickly snap back to its original position… or we may not. Notice how this.reset() is modifying the transform property. This could straighten the edges.

Fig 5.0 — CSS Transform

setTransition

Both onMouseEnter and onMouseLeave present opportunities to trigger a function that handles a transition-type animation. With this opportunity, you can control the speed and transition effects.

onMouseLeave

When the mouse leaves, we can optionally reset as described above. This is where the reset function is fired from.

Fig 5.1 — Reset the effects onMouseLeave

Phase 6: make the component reusable

Congratulations, you now understand some pretty advanced stuff.

Fig 6.0 — Just add water, in the form of mental sweat

What will our strategy be?

  1. Component should be a single file
  2. Component should accept a config object
  3. Component should support children
import React, { Component } from 'react'
import { findDOMNode } from 'react-dom'
class TiltPhaseSix extends Component {
constructor(props) {
super(props)
this.state = {
style: {}
}
const defaultSettings = {
reverse: false,
max: 35,
perspective: 1000,
easing: 'cubic-bezier(.03,.98,.52,.99)',
scale: '1.1',
speed: '1000',
transition: true,
axis: null,
reset: true
}
this.width = null
this.height = null
this.left = null
this.top = null
this.transitionTimeout = null
this.updateCall = null
this.element = null
this.settings = {
...defaultSettings,
...this.props.options,
}
this.reverse = this.settings.reverse ? -1 : 1
this.handleMouseEnter = this.handleMouseEnter.bind(this, this.props.handleMouseEnter)
this.handleMouseMove = this.handleMouseMove.bind(this, this.props.handleMouseMove)
this.handleMouseLeave = this.handleMouseLeave.bind(this, this.props.handleMouseLeave)
}
componentDidMount() {
this.element = findDOMNode(this)
}
componentWillUnmount() {
clearTimeout(this.transitionTimeout)
cancelAnimationFrame(this.updateCall)
}
handleMouseEnter(cb = () => { }, e) {
this.updateElementPosition()
this.setTransition()
return cb(e)
}
reset() {
window.requestAnimationFrame(() => {
this.setState(prevState => ({
style: {
...prevState.style,
transform: `perspective(${this.settings.perspective}px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)`,
}
}))
})
}
handleMouseMove(cb = () => { }, e) {
e.persist()
if (this.updateCall !== null) {
window.cancelAnimationFrame(this.updateCall)
}
this.event = e
this.updateCall = requestAnimationFrame(this.update.bind(this, e))
return cb(e)
}
setTransition() {
clearTimeout(this.transitionTimeout)
this.setState(prevState => ({
style: {
...prevState.style,
transition: `${this.settings.speed}ms ${this.settings.easing}`,
}
}))
this.transitionTimeout = setTimeout(() => {
this.setState(prevState => ({
style: {
...prevState.style,
transition: '',
}
}))
}, this.settings.speed)
}
handleMouseLeave(cb = () => { }, e) {
this.setTransition()
if (this.settings.reset) {
this.reset()
}
return cb(e)
}
getValues(e) {
const x = (e.nativeEvent.clientX - this.left) / this.width
const y = (e.nativeEvent.clientY - this.top) / this.height
const _x = Math.min(Math.max(x, 0), 1)
const _y = Math.min(Math.max(y, 0), 1)
const tiltX = (this.reverse * (this.settings.max / 2 - _x * this.settings.max)).toFixed(2)
const tiltY = (this.reverse * (_y * this.settings.max - this.settings.max / 2)).toFixed(2)
const percentageX = _x * 100
const percentageY = _y * 100
return {
tiltX,
tiltY,
percentageX,
percentageY,
}
}
updateElementPosition() {
const rect = this.element.getBoundingClientRect()
this.width = this.element.offsetWidth
this.height = this.element.offsetHeight
this.left = rect.left
this.top = rect.top
}
update(e) {
const values = this.getValues(e)
this.setState(prevState => ({
style: {
...prevState.style,
transform: `perspective(${this.settings.perspective}px) rotateX(${this.settings.axis === 'x' ? 0 : values.tiltY}deg) rotateY(${this.settings.axis === 'y' ? 0 : values.tiltX}deg) scale3d(${this.settings.scale}, ${this.settings.scale}, ${this.settings.scale})`,
}
}))
this.updateCall = null
}
render() {
const style = {
...this.props.style,
...this.state.style
}
return (
<div
style={style}
onMouseEnter={this.handleMouseEnter}
onMouseMove={this.handleMouseMove}
onMouseLeave={this.handleMouseLeave}
>
{this.props.children}
</div>
)
}
}
export default TiltPhaseSix

Conclusion

There you have it. You can create some awesome stuff now. Here is a sampler pack for how to use our Phase 6 refined gem:

import React, { Component } from 'react'
import TiltPhaseSix from './TiltPhaseSix'
import dogBackground from './dogBackground.jpg'
import dogForeground from './dogForeground.png'
import './App.css'
const options = {
max: 10,
perspective: 1000,
scale: 1.05,
}
class App extends Component {
render() {
return (
<div id="App">
<TiltPhaseSix
options={{}}
style={{
background: `url(${dogBackground}) no-repeat fixed center`,
backgroundSize: 'fit',
height: 700,
width: 740,
}}
>
<TiltPhaseSix
options={options}
>
<img src={dogForeground} alt="" />
</TiltPhaseSix>
</TiltPhaseSix>
</div>
)
}
}
export default App
Fig 7.1 — Completed dog

Follow me on Twitter. You will be glad you did :)

--

--

I prefer to work near ES6+, node.js, microservices, Neo4j, React, React Native; I compose functions and avoid classes unless private state is desired.

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Adam Mackintosh

Adam Mackintosh

I prefer to work near ES6+, node.js, microservices, Neo4j, React, React Native; I compose functions and avoid classes unless private state is desired.