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

  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

  • onMouseEnter
  • onMouseMove
  • onMouseLeave

React Synthetic Events

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

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

e.nativeEvent

Phase 3: track the photo coordinates

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

Fig 3.0 — Read-only is fine

getBoundingClientRect

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.

Phase 4: calculate necessary changes

  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

  • 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

requestAnimationFrame

getValues

  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

componentWillUnmount

handleMouseEnter

reset

Fig 5.0 — CSS Transform

setTransition

onMouseLeave

Fig 5.1 — Reset the effects onMouseLeave

Phase 6: make the component reusable

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

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.

More from Medium

How to use CSS grid layout including a react example

Creating a Glitching Typing Animation in React

Stylify vs Master UI/Styles: next-generation Tailwind-like CSS libraries

How to implement CSS FadeIn Animation using styled-components【React&CSS】