├── .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 |
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 |
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 |
--------------------------------------------------------------------------------