├── .babelrc ├── .editorconfig ├── .gitignore ├── .npmignore ├── .prettierrc ├── .storybook ├── main.js └── manager.js ├── LICENSE.md ├── README.md ├── assets └── background.png ├── package.json ├── src ├── index.jsx └── index.stories.jsx └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react", "@babel/preset-env"], 3 | "plugins": [] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .DS_Store 3 | npm-debug.log* 4 | node_modules 5 | amd 6 | lib 7 | tmp-bower-repo 8 | .sass-cache 9 | dist 10 | *.log 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .* 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | addons: ['@storybook/addon-essentials'], 3 | stories: ['../src/**/*.stories.jsx'], 4 | }; 5 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons'; 2 | 3 | addons.setConfig({ 4 | panelPosition: 'right', 5 | }); 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Tyler Kelley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Parallax Hover 2 | 3 | This is a 4kb (gzipped) component inspired by Apple TV's beautiful overlay effects, and the amazingly talented [@drewwilson’s](http://drewwilson.com/) [atvImg](https://github.com/drewwilson/atvImg) work. 4 | 5 | # Demo 6 | 7 | https://tylerk.github.io/react-parallax-hover/ 8 | 9 | # Install 10 | 11 | You will need the following versions listed as a dependency in your project: 12 | 13 | - `react @ 16.8.x` 14 | - `react-dom @ 16.8.x` 15 | 16 | Install: 17 | 18 | ``` 19 | $ yarn add react-parallax-hover 20 | 21 | - or - 22 | 23 | $ npm install react-parallax-hover 24 | ``` 25 | 26 | # Usage 27 | 28 | ``` 29 | import { ParallaxHover } from 'react-parallax-hover'; 30 | 31 | 32 | ... 33 | 34 | ``` 35 | 36 | # Options 37 | 38 | ### `children` 39 | 40 | - Required: `true` 41 | - Type: `Any` 42 | 43 | Component will accept a single child, or a flat array of children. 44 | 45 | > Note: While this will 'layer' the parallax effect per-child, you will typiclaly see diminishing returns after two or three components. 46 | 47 | --- 48 | 49 | ### `width` 50 | 51 | - Required: `true` 52 | - Type: `number` 53 | - Default: `200` 54 | 55 | > Note: Currently only accepts values to be used as pixels. This component does not accept percentages, em, rem, etc... 56 | 57 | --- 58 | 59 | ### `height` 60 | 61 | - Required: `true` 62 | - Type: `number` 63 | - Default: `200` 64 | 65 | > Note: Currently does not accept a percentage, or relative height 66 | 67 | --- 68 | 69 | ### `rotation` 70 | 71 | - Type: `number` 72 | - Range: `0 - 9` 73 | - Default: `5` 74 | 75 | Adjust the exaggeration of the rotation on pointer move. 76 | 77 | --- 78 | 79 | ### `shadow` 80 | 81 | - Type: `number` 82 | - Range: `0 - 9` 83 | - Default: `5` 84 | 85 | Adjusts the darkness of the shadow. 86 | 87 | --- 88 | 89 | ### `borderRadius` 90 | 91 | - Type: `number` in pixels 92 | - Default: `0` 93 | 94 | --- 95 | 96 | # How To Contribute 97 | 98 | Run the following after forking this repo: 99 | 100 | ``` 101 | $ git clone https://github.com//react-parallax-hover/ 102 | $ cd react-parallax-hover 103 | $ yarn 104 | $ yarn start 105 | ``` 106 | 107 | You should see a Storybook instance open up in your default browser. 108 | 109 | Happy hacking, and feel free to issue PR's against this repo. 110 | -------------------------------------------------------------------------------- /assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TylerK/react-parallax-hover/084d6af5148edfd02f9a89072d37fbf8ef63c2f3/assets/background.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-parallax-hover", 3 | "version": "2.0.2", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/TylerK/react-parallax-hover.git" 7 | }, 8 | "keywords": [ 9 | "react", 10 | "react-component", 11 | "parallax", 12 | "hover" 13 | ], 14 | "description": "A pointer driven hover effect that rotates images in place with subtle parallax effects", 15 | "main": "./dist/index.js", 16 | "scripts": { 17 | "build": "yarn clean && babel src/index.jsx -o dist/index.js", 18 | "clean": "rm -rf ./dist && mkdir ./dist", 19 | "prepublish": "yarn build", 20 | "deploy": "storybook-to-ghpages", 21 | "start": "start-storybook -p 3000" 22 | }, 23 | "author": "Tyler Kelley ", 24 | "license": "ISC", 25 | "devDependencies": { 26 | "@babel/cli": "^7.16.8", 27 | "@babel/core": "^7.16.12", 28 | "@babel/preset-env": "^7.16.11", 29 | "@babel/preset-react": "^7.16.7", 30 | "@storybook/addon-essentials": "^6.1.21", 31 | "@storybook/react": "^6.1.21", 32 | "@storybook/storybook-deployer": "^2.8.10", 33 | "@types/prop-types": "^15.7.4", 34 | "@types/react": "^16.8.24", 35 | "@types/react-dom": "^16.8.5", 36 | "@types/styled-components": "^5.1.21", 37 | "babel-loader": "^8.2.3" 38 | }, 39 | "dependencies": { 40 | "prop-types": "^15.8.1", 41 | "react": "^16.8.24", 42 | "react-dom": "^16.8.5", 43 | "styled-components": "^5.3.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const ParallaxWrapper = styled.div` 6 | transform-style: preserve-3d; 7 | transform: perspective(1000px); 8 | `; 9 | 10 | const ParallaxContainer = styled.div` 11 | position: relative; 12 | width: 100%; 13 | height: 100%; 14 | `; 15 | 16 | const ParallaxShadow = styled.div` 17 | position: absolute; 18 | width: 95%; 19 | height: 95%; 20 | top: 2.55%; 21 | left: 2.55%; 22 | background: none; 23 | `; 24 | 25 | const ParallaxLayer = styled.div` 26 | overflow: hidden; 27 | width: 100%; 28 | height: 100%; 29 | position: absolute; 30 | top: 0; 31 | left: 0; 32 | bottom: 0; 33 | right: 0; 34 | `; 35 | 36 | const ParallaxLighting = styled(ParallaxLayer)` 37 | opacity: 0; 38 | `; 39 | 40 | const initialState = { 41 | rotateX: 0, 42 | rotateY: 0, 43 | scale: 1, 44 | shine: 0, 45 | isHovered: false, 46 | }; 47 | 48 | export class ParallaxHover extends Component { 49 | constructor(props) { 50 | super(props); 51 | this.state = initialState; 52 | } 53 | 54 | buildTransitionTimingString = (depth = 0) => { 55 | const START_SPEED = 160; 56 | const MAX_SPEED = 260; 57 | const DEPTH_MODIFIER = 15; 58 | let speedModifier; 59 | 60 | if (depth > 0) { 61 | speedModifier = START_SPEED + depth * DEPTH_MODIFIER; 62 | } else if (depth > 10) { 63 | speedModifier = MAX_SPEED; 64 | } 65 | 66 | return { transition: `all ${speedModifier}ms ease-out` }; 67 | }; 68 | 69 | buildTransformStrings(depth = 0) { 70 | const { isHovered, rotateX, rotateY, scale } = this.state; 71 | 72 | const scaleModifier = isHovered ? 1 + scale / 100 : 1; 73 | const rotationXModifier = Math.floor(rotateX / depth); 74 | const rotationYModifier = Math.floor(rotateY / depth); 75 | 76 | const transformString = `scale(${scaleModifier}) rotateX(${rotationXModifier}deg) rotateY(${rotationYModifier}deg)`; 77 | 78 | return { 79 | WebkitTransform: transformString, 80 | MozTransform: transformString, 81 | MsTransform: transformString, 82 | OTransform: transformString, 83 | transform: transformString, 84 | }; 85 | } 86 | 87 | calculateDistance(bounds, offsetX, offsetY) { 88 | const distanceX = Math.pow(offsetX - bounds.width / 2, 2); 89 | const distanceY = Math.pow(offsetY - bounds.height / 2, 2); 90 | return Math.floor(Math.sqrt(distanceX + distanceY)); 91 | } 92 | 93 | calculateShineFromCenter(current) { 94 | const { width, height, shine } = this.props; 95 | const max = Math.max(width, height); 96 | return (current / max) * shine; 97 | } 98 | 99 | handleParallaxBegin = () => { 100 | this.setState({ 101 | isHovered: true, 102 | shine: this.props.shine, 103 | }); 104 | }; 105 | 106 | handleParallaxEnd = () => { 107 | this.setState(initialState); 108 | }; 109 | 110 | handleParallaxMove = ({ pageX, pageY }) => { 111 | const { width, height, rotation, scale } = this.props; 112 | const { scrollY: scrollTop, scrollX: scrollLeft } = window; 113 | 114 | const bounds = this.wrapper.getBoundingClientRect(); 115 | const centerX = width / 2; 116 | const centerY = height / 2; 117 | 118 | const widthMultiplier = 360 / width; 119 | const offsetX = (pageX - bounds.left - scrollLeft) / width; 120 | const offsetY = (pageY - bounds.top - scrollTop) / height; 121 | const deltaX = pageX - bounds.left - scrollLeft - centerX; 122 | const deltaY = pageY - bounds.top - scrollTop - centerY; 123 | 124 | const rotateX = (deltaY - offsetY) * ((rotation / 100) * widthMultiplier); 125 | const rotateY = (offsetX - deltaX) * ((rotation / 100) * widthMultiplier); 126 | 127 | const angleRad = Math.atan2(deltaY, deltaX); 128 | const angleRaw = (angleRad * 180) / Math.PI - 90; 129 | const angle = angleRaw < 0 ? angleRaw + 360 : angleRaw; 130 | 131 | this.setState({ 132 | angle, 133 | rotateX, 134 | rotateY, 135 | scale, 136 | }); 137 | }; 138 | 139 | renderLayers() { 140 | const { borderRadius, children, height, width } = this.props; 141 | 142 | const style = depth => ({ 143 | height: `${height}px`, 144 | width: `${width}px`, 145 | borderRadius: `${borderRadius}px`, 146 | ...this.buildTransitionTimingString(depth), 147 | }); 148 | 149 | if (!Array.isArray(children)) { 150 | return ( 151 | 152 | {children} 153 | 154 | ); 155 | } 156 | 157 | return children.map((layer, i) => { 158 | return ( 159 | 160 | {layer} 161 | 162 | ); 163 | }); 164 | } 165 | 166 | render() { 167 | const { angle, isHovered, shine, rotateX } = this.state; 168 | const { children, borderRadius, shadow, width, height } = this.props; 169 | 170 | const shadowPositionModifier = rotateX + (shadow * shadow) / 2; 171 | const shadowBlurModifier = 20 + shadow * shadow; 172 | const opacityModifier = isHovered ? 1 : 0; 173 | const lightingShineModifier = shine * 0.1; 174 | 175 | const wrapperStyles = { 176 | width, 177 | height, 178 | }; 179 | 180 | const innerContainerStyles = { 181 | ...this.buildTransformStrings(1), 182 | ...this.buildTransitionTimingString(1), 183 | }; 184 | 185 | // prettier-ignore 186 | const shadowStyles = { 187 | ...this.buildTransitionTimingString(2), 188 | borderRadius: borderRadius + 'px', 189 | opacity: opacityModifier, 190 | boxShadow: ` 191 | 0px ${shadowPositionModifier}px ${shadowBlurModifier}px rgba(0, 0, 0, 0.5), 192 | 0px ${shadowPositionModifier * 0.33}px ${shadowBlurModifier * 0.33}px 5px rgba(0, 0, 0, 0.5)`, 193 | }; 194 | 195 | const lightingStyles = { 196 | ...this.buildTransitionTimingString(children.length), 197 | borderRadius: borderRadius + 'px', 198 | opacity: opacityModifier, 199 | backgroundImage: `linear-gradient(${angle}deg, rgba(255,255,255, ${lightingShineModifier}) 0%, rgba(255,255,255,0) 80%)`, 200 | }; 201 | 202 | return ( 203 | { 213 | this.wrapper = wrapper; 214 | }} 215 | > 216 | 217 | 218 | {this.renderLayers()} 219 | 220 | 221 | 222 | ); 223 | } 224 | } 225 | 226 | ParallaxHover.defaultProps = { 227 | /** How fast the item scales up and down in MS */ 228 | speed: 100, 229 | /** Rotation modifier */ 230 | rotation: 5, 231 | /** Shadow darkness modifier */ 232 | shadow: 5, 233 | /** Light shine brightness modifer */ 234 | shine: 5, 235 | /** Default height */ 236 | height: 200, 237 | /** Default width */ 238 | width: 200, 239 | /** Default border radius */ 240 | borderRadius: 0, 241 | }; 242 | 243 | ParallaxHover.propTypes = { 244 | children: PropTypes.any, 245 | width: PropTypes.number.isRequired, 246 | height: PropTypes.number.isRequired, 247 | shadow: PropTypes.number, 248 | rotation: PropTypes.number, 249 | shine: PropTypes.number, 250 | borderRadius: PropTypes.number, 251 | }; 252 | 253 | export default ParallaxHover; 254 | -------------------------------------------------------------------------------- /src/index.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { ParallaxHover } from './'; 4 | import Background from '../assets/background.png'; 5 | 6 | const ExampleWrapper = styled.div` 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | height: calc(100vh - 30px); 11 | `; 12 | 13 | const ImageExample = styled.div` 14 | height: 100%; 15 | background-color: tomato; 16 | `; 17 | 18 | const TextExample = styled.div` 19 | height: 100%; 20 | display: flex; 21 | justify-content: center; 22 | align-items: center; 23 | font-family: sans-serif; 24 | font-weight: lighter; 25 | font-size: 3rem; 26 | text-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); 27 | color: #fff; 28 | `; 29 | 30 | const CardExample = styled.div` 31 | width: 220px; 32 | height: 300px; 33 | box-shadow: 0 0 0 2px grey; 34 | background: #eee; 35 | `; 36 | 37 | const Container = styled.div` 38 | padding: 1rem; 39 | * { 40 | font-family: sans-serif; 41 | margin: 0; 42 | } 43 | `; 44 | 45 | const rangeOptions = { 46 | min: 0, 47 | max: 9, 48 | step: 1, 49 | }; 50 | 51 | const initialValues = { 52 | radius: 5, 53 | rotation: 5, 54 | shine: 5, 55 | scale: 5, 56 | shadow: 5, 57 | }; 58 | 59 | export default { 60 | title: 'Parallax Hover', 61 | argTypes: { 62 | radius: { 63 | control: { 64 | type: 'number', 65 | }, 66 | }, 67 | rotation: { 68 | control: { 69 | type: 'range', 70 | ...rangeOptions, 71 | }, 72 | }, 73 | shine: { 74 | control: { 75 | type: 'range', 76 | ...rangeOptions, 77 | }, 78 | }, 79 | scale: { 80 | control: { 81 | type: 'range', 82 | ...rangeOptions, 83 | }, 84 | }, 85 | shadow: { 86 | control: { 87 | type: 'range', 88 | ...rangeOptions, 89 | }, 90 | }, 91 | }, 92 | }; 93 | 94 | export const ImageWithText = (args) => { 95 | return ( 96 | 97 | 106 | 107 | Demo image 108 | 109 | Hello There 110 | 111 | 112 | ); 113 | }; 114 | 115 | ImageWithText.args = { 116 | width: 500, 117 | height: 300, 118 | ...initialValues, 119 | }; 120 | 121 | export const SimpleCard = (args) => { 122 | return ( 123 | 124 | 133 | 134 | Demo image 135 | 136 |

John Doe

137 |

Architect & Engineer

138 |
139 |
140 |
141 |
142 | ); 143 | }; 144 | 145 | SimpleCard.args = { 146 | width: 220, 147 | height: 300, 148 | ...initialValues, 149 | }; 150 | --------------------------------------------------------------------------------