├── .babelrc ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── examples ├── basic │ └── basic.js ├── bundle.js ├── bundle.js.map ├── header.js ├── index.html ├── index.js ├── navbar.js ├── relative │ └── relative.js ├── stacked │ └── stacked.js └── styles.js ├── package.json ├── src ├── Container.js ├── Sticky.js └── index.js ├── test ├── setup.js └── spec │ ├── Container.js │ └── Sticky.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-proposal-class-properties", 8 | "@babel/transform-runtime" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | # I'm submitting a ... 7 | - [ ] bug report 8 | - [ ] feature request 9 | - [ ] support request 10 | 11 | ### If you're reporting a bug, please provide a minimal demonstration of the problem 12 | 13 | 14 | 15 | [Bug Demo]( INSERT THE URL OF YOUR BUG DEMO HERE ) 16 | 17 | ### What is the current behavior? 18 | 19 | 20 | 21 | ### What is the expected or desired behavior? 22 | 23 | 24 | 25 | ### Why do you want this? What use case do you have? 26 | 27 | 28 | 29 | ### What is your environment? 30 | 31 | - Version: 32 | - Browser: 33 | 34 | 35 | 36 | ### Is there anything else I should know? 37 | 38 | 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | lib 4 | package-lock.json 5 | 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | examples 3 | dist 4 | .gitignore 5 | .npmignore 6 | .babelrc 7 | .travis.yml 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Captivation Software, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-sticky [![Build Status](https://travis-ci.org/captivationsoftware/react-sticky.svg?branch=master)](https://travis-ci.org/captivationsoftware/react-sticky) 2 | 3 | Make your React components sticky! 4 | 5 | #### Update No longer actively maintained: 6 | 7 | The 6.0.3 release is the last release maintained. This means we will not be considering any PR's and/or responding to any issues until a new maintainer is identified. It is *highly* recommended that you begin transitioning to another sticky library to ensure better support and sustainability. This is obviously less than ideal - sorry for any inconvenience! 8 | 9 | #### Demos 10 | 11 | * [Basic](http://react-sticky.netlify.com/#/basic) 12 | * [Relative](http://react-sticky.netlify.com/#/relative) 13 | * [Stacked](http://react-sticky.netlify.com/#/stacked) 14 | 15 | #### Version 6.x Highlights 16 | 17 | * Completely redesigned to support sticky behavior via higher-order component, giving you ultimate control of implementation details 18 | * Features a minimal yet efficient API 19 | * Drops support for versions of React < 15.3. If you are using an earlier version of React, continue to use the 5.x series 20 | 21 | #### CSS 22 | There's a CSS alternative to `react-sticky`: the `position: sticky` feature. However it currently does not have [full browser support](https://caniuse.com/#feat=css-sticky), specifically a lack of IE11 support and some bugs with table elements. Before using `react-sticky`, check to see if the browser support and restrictions prevent you from using `position: sticky`, as CSS will always be faster and more durable than a JS implementation. 23 | ```css 24 | position: -webkit-sticky; 25 | position: sticky; 26 | top: 0; 27 | ``` 28 | 29 | ## Installation 30 | 31 | ```sh 32 | npm install react-sticky 33 | ``` 34 | 35 | ## Overview & Basic Example 36 | 37 | The goal of `react-sticky` is make it easier for developers to build UIs that have sticky elements. Some examples include a sticky navbar, or a two-column layout where the left side sticks while the right side scrolls. 38 | 39 | `react-sticky` works by calculating the position of a `` component relative to a `` component. If it would be outside the viewport, the styles required to affix it to the top of the screen are passed as an argument to a render callback, a function passed as a child. 40 | 41 | ```js 42 | 43 | {({ style }) =>

Sticky element

}
44 |
45 | ``` 46 | 47 | The majority of use cases will only need the style to pass to the DOM, but some other properties are passed for advanced use cases: 48 | 49 | * `style` _(object)_ - modifiable style attributes to optionally be passed to the element returned by this function. For many uses, this will be the only attribute needed. 50 | * `isSticky` _(boolean)_ - is the element sticky as a result of the current event? 51 | * `wasSticky` _(boolean)_ - was the element sticky prior to the current event? 52 | * `distanceFromTop` _(number)_ - number of pixels from the top of the `Sticky` to the nearest `StickyContainer`'s top 53 | * `distanceFromBottom` _(number)_ - number of pixels from the bottom of the `Sticky` to the nearest `StickyContainer`'s bottom 54 | * `calculatedHeight` _(number)_ - height of the element returned by this function 55 | 56 | The `Sticky`'s child function will be called when events occur in the parent `StickyContainer`, 57 | and will serve as the callback to apply your own logic and customizations, with sane `style` attributes 58 | to get you up and running quickly. 59 | 60 | ### Full Example 61 | 62 | Here's an example of all of those pieces together: 63 | 64 | app.js 65 | 66 | ```js 67 | import React from 'react'; 68 | import { StickyContainer, Sticky } from 'react-sticky'; 69 | // ... 70 | 71 | class App extends React.Component { 72 | render() { 73 | return ( 74 | 75 | {/* Other elements can be in between `StickyContainer` and `Sticky`, 76 | but certain styles can break the positioning logic used. */} 77 | 78 | {({ 79 | style, 80 | 81 | // the following are also available but unused in this example 82 | isSticky, 83 | wasSticky, 84 | distanceFromTop, 85 | distanceFromBottom, 86 | calculatedHeight 87 | }) => ( 88 |
89 | {/* ... */} 90 |
91 | )} 92 |
93 | {/* ... */} 94 |
95 | ); 96 | }, 97 | }; 98 | ``` 99 | 100 | When the "stickiness" becomes activated, the arguments to the sticky function 101 | are modified. Similarly, when deactivated, the arguments will update accordingly. 102 | 103 | ### `` Props 104 | 105 | `` supports all valid `
` props. 106 | 107 | ### `` Props 108 | 109 | #### relative _(default: false)_ 110 | 111 | Set `relative` to `true` if the `` element will be rendered within 112 | an overflowing `` (e.g. `style={{ overflowY: 'auto' }}`) and you want 113 | the `` behavior to react to events only within that container. 114 | 115 | When in `relative` mode, `window` events will not trigger sticky state changes. Only scrolling 116 | within the nearest `StickyContainer` can trigger sticky state changes. 117 | 118 | #### topOffset _(default: 0)_ 119 | 120 | Sticky state will be triggered when the top of the element is `topOffset` pixels from the top of the closest ``. Positive numbers give the impression of a lazy sticky state, whereas negative numbers are more eager in their attachment. 121 | 122 | app.js 123 | 124 | ```js 125 | 126 | ... 127 | 128 | { props => (...) } 129 | 130 | ... 131 | 132 | ``` 133 | 134 | The above would result in an element that becomes sticky once its top is greater than or equal to 80px away from the top of the ``. 135 | 136 | #### bottomOffset _(default: 0)_ 137 | 138 | Sticky state will be triggered when the bottom of the element is `bottomOffset` pixels from the bottom of the closest ``. 139 | 140 | app.js 141 | 142 | ```js 143 | 144 | ... 145 | 146 | { props => (...) } 147 | 148 | ... 149 | 150 | ``` 151 | 152 | The above would result in an element that ceases to be sticky once its bottom is 80px away from the bottom of the ``. 153 | 154 | #### disableCompensation _(default: false)_ 155 | 156 | Set `disableCompensation` to `true` if you do not want your `` to apply padding to 157 | a hidden placeholder `
` to correct "jumpiness" as attachment changes from `position:fixed` 158 | and back. 159 | 160 | app.js 161 | 162 | ```js 163 | 164 | ... 165 | 166 | { props => (...) } 167 | 168 | ... 169 | 170 | ``` 171 | 172 | #### disableHardwareAcceleration _(default: false)_ 173 | 174 | When `disableHardwareAcceleration` is set to `true`, the `` element will not use hardware acceleration (e.g. `transform: translateZ(0)`). This setting is not recommended as it negatively impacts 175 | the mobile experience, and can usually be avoided by improving the structure of your DOM. 176 | 177 | app.js 178 | 179 | ```js 180 | 181 | ... 182 | 183 | { props => (...) } 184 | 185 | ... 186 | 187 | ``` 188 | 189 | ## FAQ 190 | 191 | ### I get errors while using React.Fragments 192 | React.Fragments does not correspond to an actual DOM node, so `react-sticky` can not calculate its position. Because of this, React.Fragments is not supported. 193 | -------------------------------------------------------------------------------- /examples/basic/basic.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import { Sticky, StickyContainer } from "../../src"; 5 | import { Header } from "../header"; 6 | 7 | let renderCount = 0; 8 | export class Basic extends PureComponent { 9 | render() { 10 | return ( 11 |
12 |

Content before the Sticky...

13 |
17 | 18 | 19 | {({ style }) => ( 20 |
21 | )} 22 | 23 | 24 |

{""}

25 | 26 |
30 |

Content after the Sticky...

31 |
32 |
33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export class Header extends React.Component { 4 | static defaultProps = { 5 | className: "" 6 | }; 7 | render() { 8 | const { style, renderCount, className } = this.props; 9 | return ( 10 |
11 |

12 | 13 | {" "} 14 | {renderCount ? (invocation: #{renderCount}) : null} 15 | 16 |

17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | react-sticky by Captivation Software 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { HashRouter as Router, Route, Redirect } from "react-router-dom"; 4 | import { Basic } from "./basic/basic"; 5 | import { Relative } from "./relative/relative"; 6 | import { Stacked } from "./stacked/stacked"; 7 | import { Navbar } from "./navbar"; 8 | import styles from "./styles"; 9 | 10 | ReactDOM.render( 11 | 12 |
13 | 14 | 15 |
16 | } /> 17 | 18 | 19 | 20 |
21 |
22 |
, 23 | document.querySelector("#mount") 24 | ); 25 | -------------------------------------------------------------------------------- /examples/navbar.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { Sticky, StickyContainer } from "../src/"; 4 | 5 | export class Navbar extends React.Component { 6 | render() { 7 | return ( 8 |
9 |
    10 |
  • 11 | Basic 12 |
  • 13 |
  • 14 | Relative 15 |
  • 16 |
  • 17 | Stacked 18 |
  • 19 |
20 |
21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/relative/relative.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import { Sticky, StickyContainer } from "../../src"; 5 | import { Header } from "../header"; 6 | 7 | let renderCount = 0; 8 | export class Relative extends PureComponent { 9 | render() { 10 | return ( 11 |
12 | 13 |
17 |
18 | 19 | {({ style }) => ( 20 |
21 | )} 22 | 23 |
24 |

scrolling container

25 |
26 | 27 |
28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/stacked/stacked.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import { Sticky, StickyContainer } from "../../src"; 5 | import { Header } from "../header"; 6 | 7 | const containerBg = i => `hsl(${i * 40}, 70%, 90%)`; 8 | const headerBg = i => `hsl(${i * 40}, 70%, 50%)`; 9 | 10 | export class Stacked extends PureComponent { 11 | render() { 12 | return ( 13 |
14 | {[1, 2, 3, 4, 5, 6, 7, 8].map(i => ( 15 | 20 | 21 | {({ style }) => ( 22 |
23 | )} 24 | 25 | 26 |

{``}

27 | 28 | ))} 29 |
30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/styles.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | html { 3 | box-sizing: border-box; 4 | } 5 | 6 | * { 7 | box-sizing: inherit; 8 | } 9 | 10 | body { 11 | font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; 12 | padding: 0; 13 | margin: 0; 14 | margin-top: 2rem; 15 | font-size: 16px; 16 | line-height: 1rem; 17 | } 18 | 19 | h1 { 20 | line-height: 2rem; 21 | display: inline-block; 22 | } 23 | 24 | h2 { 25 | line-height: 1.5rem; 26 | display: inline-block; 27 | } 28 | 29 | .app { 30 | display: grid; 31 | grid-template-columns: 20% 80%; 32 | } 33 | 34 | @media (max-width: 700px) { 35 | .app { 36 | grid-template-columns: unset; 37 | } 38 | } 39 | 40 | .navbar { 41 | padding: .5rem; 42 | } 43 | 44 | .navbar .nav-link { 45 | padding: .5rem; 46 | } 47 | 48 | .header { 49 | height: 80px; 50 | overflow: auto; 51 | background: #aaa; 52 | } 53 | 54 | .container { 55 | height: 500px; 56 | background: #ddd; 57 | } 58 | 59 | .gap { 60 | height: 500px; 61 | } 62 | 63 | .gap.short { 64 | height: 250px; 65 | } 66 | 67 | .gap.tall { 68 | height: 1000px; 69 | } 70 | 71 | .container.relative { 72 | overflow-y: auto; 73 | } 74 | `; 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-sticky", 3 | "version": "6.0.3", 4 | "description": "Sticky component for React", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "clean": "rimraf lib", 8 | "compile": "babel src --loose --out-dir lib", 9 | "demos:start": "webpack-dev-server --watch", 10 | "demos:build": "NODE_ENV=production webpack", 11 | "prepublish": "npm run prepare", 12 | "prepare": "npm run clean && npm run compile", 13 | "test": "mocha test/setup.js test/spec/*.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/captivationsoftware/react-sticky" 18 | }, 19 | "keywords": [ 20 | "react-component", 21 | "React", 22 | "Sticky" 23 | ], 24 | "author": "Captivation Software", 25 | "license": "MIT", 26 | "homepage": "https://github.com/captivationsoftware/react-sticky", 27 | "dependencies": { 28 | "@babel/runtime": "^7.3.1", 29 | "prop-types": "^15.5.8", 30 | "raf": "^3.3.0" 31 | }, 32 | "peerDependencies": { 33 | "react": ">=15", 34 | "react-dom": ">=15" 35 | }, 36 | "devDependencies": { 37 | "@babel/cli": "^7.0.0", 38 | "@babel/core": "^7.0.0", 39 | "@babel/plugin-proposal-class-properties": "^7.0.0", 40 | "@babel/plugin-transform-runtime": "^7.2.0", 41 | "@babel/preset-env": "^7.0.0", 42 | "@babel/preset-react": "^7.0.0", 43 | "@babel/register": "^7.0.0", 44 | "babel-loader": "^8.0.5", 45 | "chai": "^4", 46 | "enzyme": "^2.8.2", 47 | "jsdom": "8.0.4", 48 | "mocha": "^5", 49 | "react": "^15.5.4", 50 | "react-dom": "^15.5.4", 51 | "react-router-dom": "^4.2.2", 52 | "react-test-renderer": "^15.5.4", 53 | "rimraf": "^2.5.2", 54 | "webpack": "^4.29.5", 55 | "webpack-cli": "^3.2.3", 56 | "webpack-dev-server": "^3.2.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Container.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import raf from "raf"; 4 | 5 | export default class Container extends PureComponent { 6 | static childContextTypes = { 7 | subscribe: PropTypes.func, 8 | unsubscribe: PropTypes.func, 9 | getParent: PropTypes.func 10 | }; 11 | 12 | getChildContext() { 13 | return { 14 | subscribe: this.subscribe, 15 | unsubscribe: this.unsubscribe, 16 | getParent: this.getParent 17 | }; 18 | } 19 | 20 | events = [ 21 | "resize", 22 | "scroll", 23 | "touchstart", 24 | "touchmove", 25 | "touchend", 26 | "pageshow", 27 | "load" 28 | ]; 29 | 30 | subscribers = []; 31 | 32 | rafHandle = null; 33 | 34 | subscribe = handler => { 35 | this.subscribers = this.subscribers.concat(handler); 36 | }; 37 | 38 | unsubscribe = handler => { 39 | this.subscribers = this.subscribers.filter(current => current !== handler); 40 | }; 41 | 42 | notifySubscribers = evt => { 43 | if (!this.framePending) { 44 | const { currentTarget } = evt; 45 | 46 | this.rafHandle = raf(() => { 47 | this.framePending = false; 48 | const { top, bottom } = this.node.getBoundingClientRect(); 49 | 50 | this.subscribers.forEach(handler => 51 | handler({ 52 | distanceFromTop: top, 53 | distanceFromBottom: bottom, 54 | eventSource: currentTarget === window ? document.body : this.node 55 | }) 56 | ); 57 | }); 58 | this.framePending = true; 59 | } 60 | }; 61 | 62 | getParent = () => this.node; 63 | 64 | componentDidMount() { 65 | this.events.forEach(event => 66 | window.addEventListener(event, this.notifySubscribers) 67 | ); 68 | } 69 | 70 | componentWillUnmount() { 71 | if (this.rafHandle) { 72 | raf.cancel(this.rafHandle); 73 | this.rafHandle = null; 74 | } 75 | 76 | this.events.forEach(event => 77 | window.removeEventListener(event, this.notifySubscribers) 78 | ); 79 | } 80 | 81 | render() { 82 | return ( 83 |
(this.node = node)} 86 | onScroll={this.notifySubscribers} 87 | onTouchStart={this.notifySubscribers} 88 | onTouchMove={this.notifySubscribers} 89 | onTouchEnd={this.notifySubscribers} 90 | /> 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Sticky.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import PropTypes from "prop-types"; 4 | 5 | export default class Sticky extends Component { 6 | static propTypes = { 7 | topOffset: PropTypes.number, 8 | bottomOffset: PropTypes.number, 9 | relative: PropTypes.bool, 10 | children: PropTypes.func.isRequired 11 | }; 12 | 13 | static defaultProps = { 14 | relative: false, 15 | topOffset: 0, 16 | bottomOffset: 0, 17 | disableCompensation: false, 18 | disableHardwareAcceleration: false 19 | }; 20 | 21 | static contextTypes = { 22 | subscribe: PropTypes.func, 23 | unsubscribe: PropTypes.func, 24 | getParent: PropTypes.func 25 | }; 26 | 27 | state = { 28 | isSticky: false, 29 | wasSticky: false, 30 | style: {} 31 | }; 32 | 33 | componentWillMount() { 34 | if (!this.context.subscribe) 35 | throw new TypeError( 36 | "Expected Sticky to be mounted within StickyContainer" 37 | ); 38 | 39 | this.context.subscribe(this.handleContainerEvent); 40 | } 41 | 42 | componentWillUnmount() { 43 | this.context.unsubscribe(this.handleContainerEvent); 44 | } 45 | 46 | componentDidUpdate() { 47 | this.placeholder.style.paddingBottom = this.props.disableCompensation 48 | ? 0 49 | : `${this.state.isSticky ? this.state.calculatedHeight : 0}px`; 50 | } 51 | 52 | handleContainerEvent = ({ 53 | distanceFromTop, 54 | distanceFromBottom, 55 | eventSource 56 | }) => { 57 | const parent = this.context.getParent(); 58 | 59 | let preventingStickyStateChanges = false; 60 | if (this.props.relative) { 61 | preventingStickyStateChanges = eventSource !== parent; 62 | distanceFromTop = 63 | -(eventSource.scrollTop + eventSource.offsetTop) + 64 | this.placeholder.offsetTop; 65 | } 66 | 67 | const placeholderClientRect = this.placeholder.getBoundingClientRect(); 68 | const contentClientRect = this.content.getBoundingClientRect(); 69 | const calculatedHeight = contentClientRect.height; 70 | 71 | const bottomDifference = 72 | distanceFromBottom - this.props.bottomOffset - calculatedHeight; 73 | 74 | const wasSticky = !!this.state.isSticky; 75 | const isSticky = preventingStickyStateChanges 76 | ? wasSticky 77 | : distanceFromTop <= -this.props.topOffset && 78 | distanceFromBottom > -this.props.bottomOffset; 79 | 80 | distanceFromBottom = 81 | (this.props.relative 82 | ? parent.scrollHeight - parent.scrollTop 83 | : distanceFromBottom) - calculatedHeight; 84 | 85 | const style = !isSticky 86 | ? {} 87 | : { 88 | position: "fixed", 89 | top: 90 | bottomDifference > 0 91 | ? this.props.relative 92 | ? parent.offsetTop - parent.offsetParent.scrollTop 93 | : 0 94 | : bottomDifference, 95 | left: placeholderClientRect.left, 96 | width: placeholderClientRect.width 97 | }; 98 | 99 | if (!this.props.disableHardwareAcceleration) { 100 | style.transform = "translateZ(0)"; 101 | } 102 | 103 | this.setState({ 104 | isSticky, 105 | wasSticky, 106 | distanceFromTop, 107 | distanceFromBottom, 108 | calculatedHeight, 109 | style 110 | }); 111 | }; 112 | 113 | render() { 114 | const element = React.cloneElement( 115 | this.props.children({ 116 | isSticky: this.state.isSticky, 117 | wasSticky: this.state.wasSticky, 118 | distanceFromTop: this.state.distanceFromTop, 119 | distanceFromBottom: this.state.distanceFromBottom, 120 | calculatedHeight: this.state.calculatedHeight, 121 | style: this.state.style 122 | }), 123 | { 124 | ref: content => { 125 | this.content = ReactDOM.findDOMNode(content); 126 | } 127 | } 128 | ); 129 | 130 | return ( 131 |
132 |
(this.placeholder = placeholder)} /> 133 | {element} 134 |
135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Sticky from "./Sticky"; 2 | import Container from "./Container"; 3 | 4 | export { Sticky }; 5 | export { Container as StickyContainer }; 6 | export default Sticky; 7 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | require("@babel/register")(); 2 | 3 | const jsdom = require("jsdom").jsdom; 4 | 5 | const exposedProperties = ["window", "navigator", "document"]; 6 | 7 | global.document = jsdom(""); 8 | global.window = document.defaultView; 9 | Object.keys(document.defaultView).forEach(property => { 10 | if (typeof global[property] === "undefined") { 11 | exposedProperties.push(property); 12 | global[property] = document.defaultView[property]; 13 | } 14 | }); 15 | 16 | global.navigator = { 17 | userAgent: "node.js" 18 | }; 19 | 20 | documentRef = document; 21 | 22 | const mount = document.createElement("div"); 23 | mount.id = "mount"; 24 | document.body.appendChild(mount); 25 | -------------------------------------------------------------------------------- /test/spec/Container.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { expect } from "chai"; 3 | import { mount } from "enzyme"; 4 | import { StickyContainer } from "../../src"; 5 | 6 | const attachTo = document.getElementById("mount"); 7 | 8 | describe("StickyContainer", () => { 9 | let container, containerNode; 10 | beforeEach(() => { 11 | container = mount(, { attachTo }); 12 | containerNode = container.node; 13 | }); 14 | 15 | describe("getChildContext", () => { 16 | let childContext; 17 | beforeEach(() => { 18 | childContext = containerNode.getChildContext(); 19 | }); 20 | 21 | it("should expose a subscribe function that adds a callback to the subscriber list", () => { 22 | expect(childContext.subscribe).to.be.a("function"); 23 | 24 | const callback = () => ({}); 25 | expect(containerNode.subscribers).to.be.empty; 26 | childContext.subscribe(callback); 27 | expect(containerNode.subscribers[0]).to.equal(callback); 28 | }); 29 | 30 | it("should expose an unsubscribe function that removes a callback from the subscriber list", () => { 31 | expect(childContext.unsubscribe).to.be.a("function"); 32 | 33 | const callback = () => ({}); 34 | childContext.subscribe(callback); 35 | expect(containerNode.subscribers[0]).to.equal(callback); 36 | childContext.unsubscribe(callback); 37 | expect(containerNode.subscribers).to.be.empty; 38 | }); 39 | 40 | it("should expose a getParent function that returns the container's underlying DOM ref", () => { 41 | expect(childContext.getParent).to.be.a("function"); 42 | expect(childContext.getParent()).to.equal(containerNode.node); 43 | }); 44 | }); 45 | 46 | describe("subscribers", () => { 47 | let subscribe; 48 | beforeEach(() => { 49 | subscribe = containerNode.getChildContext().subscribe; 50 | }); 51 | 52 | // container events 53 | ["scroll", "touchstart", "touchmove", "touchend"].forEach(eventName => { 54 | it(`should be notified on container ${eventName} event`, done => { 55 | expect(containerNode.subscribers).to.be.empty; 56 | subscribe(() => done()); 57 | container.simulate(eventName); 58 | }); 59 | }); 60 | 61 | // window events 62 | [ 63 | "resize", 64 | "scroll", 65 | "touchstart", 66 | "touchmove", 67 | "touchend", 68 | "pageshow", 69 | "load" 70 | ].forEach(eventName => { 71 | it(`should be notified on window ${eventName} event`, done => { 72 | expect(containerNode.subscribers).to.be.empty; 73 | subscribe(() => done()); 74 | window.dispatchEvent(new Event(eventName)); 75 | }); 76 | }); 77 | }); 78 | 79 | describe("notifySubscribers", () => { 80 | it("should publish document.body as eventSource to subscribers when window event", done => { 81 | containerNode.subscribers = [ 82 | ({ eventSource }) => ( 83 | expect(eventSource).to.equal(document.body), done() 84 | ) 85 | ]; 86 | containerNode.notifySubscribers({ currentTarget: window }); 87 | }); 88 | 89 | it("should publish node as eventSource to subscribers when div event", done => { 90 | containerNode.subscribers = [ 91 | ({ eventSource }) => ( 92 | expect(eventSource).to.equal(containerNode.node), done() 93 | ) 94 | ]; 95 | containerNode.notifySubscribers({ currentTarget: containerNode.node }); 96 | }); 97 | 98 | it("should publish node top and bottom to subscribers", done => { 99 | containerNode.subscribers = [ 100 | ({ distanceFromTop, distanceFromBottom }) => { 101 | expect(distanceFromTop).to.equal(100); 102 | expect(distanceFromBottom).to.equal(200); 103 | done(); 104 | } 105 | ]; 106 | 107 | containerNode.node.getBoundingClientRect = () => ({ 108 | top: 100, 109 | bottom: 200 110 | }); 111 | containerNode.notifySubscribers({ currentTarget: window }); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /test/spec/Sticky.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { expect } from "chai"; 3 | import { mount } from "enzyme"; 4 | import { StickyContainer, Sticky } from "../../src"; 5 | 6 | const attachTo = document.getElementById("mount"); 7 | 8 | describe("Invalid Sticky", () => { 9 | it("should complain if Sticky child is not a function", () => { 10 | expect(() => 11 | mount( 12 | 13 | 14 | , 15 | { attachTo } 16 | ) 17 | ).to.throw(TypeError); 18 | }); 19 | 20 | it("should complain if StickyContainer is not found", () => { 21 | expect(() => 22 | mount({() =>
}, { attachTo }) 23 | ).to.throw(TypeError); 24 | }); 25 | }); 26 | 27 | describe("Valid Sticky", () => { 28 | const componentFactory = props => ( 29 | 30 | 31 | 32 | ); 33 | 34 | describe("lifecycle", () => { 35 | let container; 36 | beforeEach(() => { 37 | container = mount(componentFactory({ children: () =>
}), { 38 | attachTo 39 | }); 40 | }); 41 | 42 | it("should register as subscriber of parent on mount", () => { 43 | expect(container.node.subscribers).to.contain( 44 | container.children().node.handleContainerEvent 45 | ); 46 | }); 47 | 48 | it("should unregister as subscriber of parent on unmount", () => { 49 | expect(container.node.subscribers).to.contain( 50 | container.children().node.handleContainerEvent 51 | ); 52 | mount(, { attachTo }); 53 | expect(container.node.subscribers).to.be.empty; 54 | }); 55 | }); 56 | 57 | describe("with no props", () => { 58 | const expectedStickyStyle = { 59 | left: 10, 60 | top: 0, 61 | width: 100, 62 | position: "fixed", 63 | transform: "translateZ(0)" 64 | }; 65 | 66 | let sticky; 67 | beforeEach(() => { 68 | const wrapper = mount( 69 | componentFactory({ 70 | children: () =>
71 | }), 72 | { attachTo } 73 | ); 74 | 75 | const { 76 | position, 77 | transform, 78 | ...boundingClientRect 79 | } = expectedStickyStyle; 80 | 81 | sticky = wrapper.children().node; 82 | sticky.content.getBoundingClientRect = () => ({ 83 | ...boundingClientRect, 84 | height: 100 85 | }); 86 | sticky.placeholder.getBoundingClientRect = () => ({ 87 | ...boundingClientRect, 88 | height: 100 89 | }); 90 | }); 91 | 92 | it("should change have an expected start state", () => { 93 | expect(sticky.state).to.eql({ 94 | isSticky: false, 95 | wasSticky: false, 96 | style: {} 97 | }); 98 | }); 99 | 100 | it("should be sticky when distanceFromTop is 0", () => { 101 | sticky.handleContainerEvent({ 102 | distanceFromTop: 0, 103 | distanceFromBottom: 1000, 104 | eventSource: document.body 105 | }); 106 | expect(sticky.state).to.eql({ 107 | isSticky: true, 108 | wasSticky: false, 109 | style: expectedStickyStyle, 110 | distanceFromTop: 0, 111 | distanceFromBottom: 900, 112 | calculatedHeight: 100 113 | }); 114 | expect(parseInt(sticky.placeholder.style.paddingBottom)).to.equal(100); 115 | }); 116 | 117 | it("should be sticky when distanceFromTop is negative", () => { 118 | sticky.handleContainerEvent({ 119 | distanceFromTop: -1, 120 | distanceFromBottom: 999, 121 | eventSource: document.body 122 | }); 123 | expect(sticky.state).to.eql({ 124 | isSticky: true, 125 | wasSticky: false, 126 | style: expectedStickyStyle, 127 | distanceFromTop: -1, 128 | distanceFromBottom: 899, 129 | calculatedHeight: 100 130 | }); 131 | expect(parseInt(sticky.placeholder.style.paddingBottom)).to.equal(100); 132 | }); 133 | 134 | it("should continue to be sticky when distanceFromTop becomes increasingly negative", () => { 135 | sticky.handleContainerEvent({ 136 | distanceFromTop: -1, 137 | distanceFromBottom: 999, 138 | eventSource: document.body 139 | }); 140 | sticky.handleContainerEvent({ 141 | distanceFromTop: -2, 142 | distanceFromBottom: 998, 143 | eventSource: document.body 144 | }); 145 | expect(sticky.state).to.eql({ 146 | isSticky: true, 147 | wasSticky: true, 148 | style: expectedStickyStyle, 149 | distanceFromTop: -2, 150 | distanceFromBottom: 898, 151 | calculatedHeight: 100 152 | }); 153 | expect(parseInt(sticky.placeholder.style.paddingBottom)).to.equal(100); 154 | }); 155 | 156 | it("should cease to be sticky when distanceFromTop becomes greater than 0", () => { 157 | sticky.handleContainerEvent({ 158 | distanceFromTop: -1, 159 | distanceFromBottom: 999, 160 | eventSource: document.body 161 | }); 162 | sticky.handleContainerEvent({ 163 | distanceFromTop: 1, 164 | distanceFromBottom: 1001, 165 | eventSource: document.body 166 | }); 167 | expect(sticky.state).to.eql({ 168 | isSticky: false, 169 | wasSticky: true, 170 | style: { transform: "translateZ(0)" }, 171 | distanceFromTop: 1, 172 | distanceFromBottom: 901, 173 | calculatedHeight: 100 174 | }); 175 | expect(parseInt(sticky.placeholder.style.paddingBottom)).to.equal(0); 176 | }); 177 | 178 | it("should compensate sticky style height when distanceFromBottom is < 0", () => { 179 | sticky.handleContainerEvent({ 180 | distanceFromTop: -901, 181 | distanceFromBottom: 99, 182 | eventSource: document.body 183 | }); 184 | expect(sticky.state).to.eql({ 185 | isSticky: true, 186 | wasSticky: false, 187 | style: { ...expectedStickyStyle, top: -1 }, 188 | distanceFromTop: -901, 189 | distanceFromBottom: -1, 190 | calculatedHeight: 100 191 | }); 192 | expect(parseInt(sticky.placeholder.style.paddingBottom)).to.equal(100); 193 | }); 194 | }); 195 | 196 | describe("with topOffset not equal to 0", () => { 197 | it("should attach lazily when topOffset is positive", () => { 198 | const wrapper = mount( 199 | componentFactory({ 200 | topOffset: 1, 201 | children: () =>
202 | }), 203 | { attachTo } 204 | ); 205 | 206 | const sticky = wrapper.children().node; 207 | sticky.handleContainerEvent({ 208 | distanceFromTop: 0, 209 | distanceFromBottom: 100, 210 | eventSource: document.body 211 | }); 212 | expect(sticky.state.isSticky).to.be.false; 213 | sticky.handleContainerEvent({ 214 | distanceFromTop: -1, 215 | distanceFromBottom: 99, 216 | eventSource: document.body 217 | }); 218 | expect(sticky.state.isSticky).to.be.true; 219 | }); 220 | 221 | it("should attach aggressively when topOffset is negative", () => { 222 | const wrapper = mount( 223 | componentFactory({ 224 | topOffset: -1, 225 | children: () =>
226 | }), 227 | { attachTo } 228 | ); 229 | 230 | const sticky = wrapper.children().node; 231 | sticky.handleContainerEvent({ 232 | distanceFromTop: 2, 233 | distanceFromBottom: 99, 234 | eventSource: document.body 235 | }); 236 | expect(sticky.state.isSticky).to.be.false; 237 | sticky.handleContainerEvent({ 238 | distanceFromTop: 1, 239 | distanceFromBottom: 98, 240 | eventSource: document.body 241 | }); 242 | expect(sticky.state.isSticky).to.be.true; 243 | }); 244 | }); 245 | 246 | describe("when relative = true", () => { 247 | let eventSource, sticky; 248 | beforeEach(() => { 249 | const wrapper = mount( 250 | componentFactory({ 251 | relative: true, 252 | children: () =>
253 | }), 254 | { attachTo } 255 | ); 256 | 257 | eventSource = wrapper.node.node; 258 | eventSource.scrollHeight = 1000; 259 | eventSource.offsetTop = 0; 260 | eventSource.offsetParent = { scrollTop: 0 }; 261 | 262 | sticky = wrapper.children().node; 263 | }); 264 | 265 | it("should not change sticky state when event source is not StickyContainer", () => { 266 | sticky.placeholder.offsetTop = 0; 267 | eventSource.scrollTop = 0; 268 | 269 | sticky.handleContainerEvent({ 270 | distanceFromTop: 100, 271 | distanceFromBottom: 500, 272 | eventSource 273 | }); 274 | expect(sticky.state.isSticky).to.be.true; 275 | 276 | sticky.handleContainerEvent({ 277 | distanceFromTop: 100, 278 | distanceFromBottom: 500, 279 | eventSource: document.body 280 | }); 281 | expect(sticky.state.isSticky).to.be.true; 282 | }); 283 | 284 | it("should change sticky state when event source is StickyContainer", () => { 285 | sticky.placeholder.offsetTop = 1; 286 | eventSource.scrollTop = 0; 287 | 288 | sticky.handleContainerEvent({ 289 | distanceFromTop: 100, 290 | distanceFromBottom: 500, 291 | eventSource 292 | }); 293 | expect(sticky.state.isSticky).to.be.false; 294 | 295 | eventSource.scrollTop = 1; 296 | sticky.handleContainerEvent({ 297 | distanceFromTop: 100, 298 | distanceFromBottom: 500, 299 | eventSource 300 | }); 301 | expect(sticky.state.isSticky).to.be.true; 302 | 303 | eventSource.scrollTop = 2; 304 | sticky.handleContainerEvent({ 305 | distanceFromTop: 100, 306 | distanceFromBottom: 500, 307 | eventSource 308 | }); 309 | expect(sticky.state.isSticky).to.be.true; 310 | }); 311 | 312 | it("should adjust sticky style.top when StickyContainer has a negative distanceFromTop", () => { 313 | sticky.placeholder.offsetTop = 0; 314 | eventSource.scrollTop = 0; 315 | 316 | sticky.handleContainerEvent({ 317 | distanceFromTop: 0, 318 | distanceFromBottom: 1000, 319 | eventSource 320 | }); 321 | expect(sticky.state.isSticky).to.be.true; 322 | expect(sticky.state.style.top).to.equal(0); 323 | 324 | eventSource.offsetParent.scrollTop = 1; 325 | sticky.handleContainerEvent({ 326 | distanceFromTop: -1, 327 | distanceFromBottom: 999, 328 | eventSource: document.body 329 | }); 330 | expect(sticky.state.isSticky).to.be.true; 331 | expect(sticky.state.style.top).to.equal(-1); 332 | 333 | eventSource.scrollTop = 1; 334 | sticky.handleContainerEvent({ 335 | distanceFromTop: -1, 336 | distanceFromBottom: 1000, 337 | eventSource 338 | }); 339 | expect(sticky.state.isSticky).to.be.true; 340 | expect(sticky.state.style.top).to.equal(-1); 341 | }); 342 | }); 343 | 344 | describe("with disableHardwareAcceleration = true", () => { 345 | it("should not include translateZ style when sticky", () => { 346 | const wrapper = mount( 347 | componentFactory({ 348 | disableHardwareAcceleration: true, 349 | children: () =>
350 | }), 351 | { attachTo } 352 | ); 353 | 354 | const sticky = wrapper.children().node; 355 | sticky.handleContainerEvent({ 356 | distanceFromTop: 1, 357 | distanceFromBottom: 100, 358 | eventSource: document.body 359 | }); 360 | expect(sticky.state.isSticky).to.be.false; 361 | expect(sticky.state.style.transform).to.be.undefined; 362 | 363 | sticky.handleContainerEvent({ 364 | distanceFromTop: -1, 365 | distanceFromBottom: 99, 366 | eventSource: document.body 367 | }); 368 | expect(sticky.state.isSticky).to.be.true; 369 | expect(sticky.state.style.transform).to.be.undefined; 370 | }); 371 | }); 372 | 373 | describe("with disableCompensation = true", () => { 374 | it("should not include translateZ style when sticky", () => { 375 | const wrapper = mount( 376 | componentFactory({ 377 | disableCompensation: true, 378 | children: () =>
379 | }), 380 | { attachTo } 381 | ); 382 | 383 | const sticky = wrapper.children().node; 384 | sticky.handleContainerEvent({ 385 | distanceFromTop: -1, 386 | distanceFromBottom: 99, 387 | eventSource: document.body 388 | }); 389 | expect(sticky.state.isSticky).to.be.true; 390 | expect(parseInt(sticky.placeholder.style.paddingBottom)).to.equal(0); 391 | }); 392 | }); 393 | }); 394 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | 4 | const isLive = process.env.NODE_ENV === "production"; 5 | 6 | module.exports = { 7 | mode: isLive ? "production" : "development", 8 | devtool: isLive ? "source-map" : "cheap-eval-source-map", 9 | entry: { 10 | demos: path.resolve("examples", "index.js") 11 | }, 12 | output: { 13 | path: path.join(__dirname, "examples"), 14 | filename: "bundle.js" 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | exclude: /node_modules/, 21 | loader: "babel-loader" 22 | } 23 | ] 24 | }, 25 | devServer: { 26 | contentBase: path.join(__dirname, "examples"), 27 | publicPath: "/", 28 | compress: true, 29 | port: 9000, 30 | historyApiFallback: true 31 | } 32 | }; 33 | --------------------------------------------------------------------------------