146 | overflowScroll="flow"
147 |
148 |
149 | appliedOverflowScroll: {appliedOverflowScroll}
150 |
151 |
152 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed
153 | do eiusmod tempor incididunt ut labore et dolore magna aliqua.
154 | Ut enim ad minim veniam, quis nostrud exercitation ullamco
155 | laboris nisi ut aliquip ex ea commodo consequat. Duis aute
156 | irure dolor in reprehenderit in voluptate velit esse cillum
157 | dolore eu fugiat nulla pariatur. Excepteur sint occaecat
158 | cupidatat non proident, sunt in culpa qui officia deserunt
159 | mollit anim id est laborum.
160 |
161 | )}
162 |
163 |
164 |
165 |
166 |
167 | overflowScroll="end"
168 |
169 |
170 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
171 | eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
172 | enim ad minim veniam, quis nostrud exercitation ullamco laboris
173 | nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
174 | in reprehenderit in voluptate velit esse cillum dolore eu fugiat
175 | nulla pariatur. Excepteur sint occaecat cupidatat non proident,
176 | sunt in culpa qui officia deserunt mollit anim id est laborum.
177 |
214 | );
215 | };
216 |
217 | render(
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 | ,
228 | document.getElementById('root'),
229 | );
230 |
--------------------------------------------------------------------------------
/lib/StickyScrollUp.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ObserveViewport, IRect, IScroll } from 'react-viewport-utils';
3 |
4 | import { connect as connectStickyProvider } from './StickyProvider';
5 | import StickyElement from './StickyElement';
6 | import StickyPlaceholder from './StickyPlaceholder';
7 | import {
8 | TRenderChildren,
9 | IStickyComponentProps,
10 | IStickyInjectedProps,
11 | IPositionStyles,
12 | } from './types';
13 | import { supportsWillChange, shallowEqualPositionStyles } from './utils';
14 |
15 | interface IOwnProps extends IStickyComponentProps {
16 | /**
17 | * The child node that is rendered within the sticky container. When rendered as a function it will add further information the the function which can be used e.g. to update stylings.
18 | */
19 | children?: TRenderChildren<{
20 | isNearToViewport: boolean;
21 | isSticky: boolean;
22 | }>;
23 | /**
24 | * When not initialized as the first element within the page (directly at the top) this allows to set an offset by hand from where the component will be sticky.
25 | * @deprecated If not set, the start position is now calculated by default as it was already the case for the `Sticky` component. As there is no use case for this property anymore it will be removed in the future.
26 | */
27 | defaultOffsetTop?: number;
28 | }
29 |
30 | interface IProps extends IOwnProps, IStickyInjectedProps {}
31 |
32 | interface IState {
33 | styles: IPositionStyles;
34 | isNearToViewport: boolean;
35 | isSticky: boolean;
36 | }
37 |
38 | const calcPositionStyles = (
39 | rect: IRect,
40 | scroll: IScroll,
41 | { offsetTop = 0 },
42 | ): IPositionStyles => {
43 | const rectTop = Math.round(rect.top);
44 | const scrollY = Math.round(scroll.y);
45 | if (scroll.isScrollingDown) {
46 | // disable sticky mode above the top offset while scrolling down
47 | if (rectTop > 0 && scrollY < offsetTop) {
48 | return {
49 | position: 'absolute',
50 | top: 0,
51 | };
52 | }
53 |
54 | // element is visible and scrolls down
55 | return {
56 | position: 'absolute',
57 | top: Math.max(scrollY - offsetTop + rectTop, 0),
58 | };
59 | }
60 |
61 | const isTopVisible = rectTop >= 0;
62 | const isBottomVisible = rectTop + rect.height <= 0;
63 | // element is visible and scrolls up
64 | if (!isTopVisible && !isBottomVisible) {
65 | return {
66 | position: 'absolute',
67 | top: scrollY - offsetTop + rectTop,
68 | };
69 | }
70 |
71 | // disable sticky mode above the top offset while scrolling up
72 | if (scrollY <= offsetTop) {
73 | return {
74 | position: 'absolute',
75 | top: 0,
76 | };
77 | }
78 |
79 | if (Math.round(scroll.yDTurn) === 0) {
80 | // scroll direction changed from down to up and the element was not visible
81 | if (isBottomVisible) {
82 | return {
83 | position: 'absolute',
84 | top: Math.round(scroll.yTurn) - offsetTop - rect.height,
85 | };
86 | }
87 | // scroll direction changed from down to up and the element was fully visible
88 | return {
89 | position: 'absolute',
90 | top: Math.max(scrollY - offsetTop, 0),
91 | };
92 | }
93 |
94 | // set sticky
95 | return {
96 | position: 'fixed',
97 | top: 0,
98 | };
99 | };
100 |
101 | class StickyScrollUp extends React.PureComponent {
102 | private stickyRef: React.RefObject = React.createRef();
103 | private placeholderRef: React.RefObject = React.createRef();
104 | private stickyOffset: number | null = null;
105 | private stickyOffsetHeight: number = 0;
106 |
107 | static defaultProps = {
108 | disableHardwareAcceleration: false,
109 | disableResizing: false,
110 | style: {},
111 | };
112 |
113 | state: IState = {
114 | styles: {},
115 | isNearToViewport: false,
116 | isSticky: false,
117 | };
118 |
119 | componentDidUpdate(prevProps: IProps, prevState: IState) {
120 | if (
121 | this.props.updateStickyOffset &&
122 | prevProps.disabled !== this.props.disabled
123 | ) {
124 | this.props.updateStickyOffset(
125 | this.props.disabled ? 0 : this.stickyOffset,
126 | this.stickyOffsetHeight,
127 | );
128 | }
129 | }
130 |
131 | isNearToViewport = (rect: IRect): boolean => {
132 | const padding = 700;
133 | return rect.top - padding < 0;
134 | };
135 |
136 | getStickyStyles(stickyRect: IRect, placeholderRect: IRect, scroll: IScroll) {
137 | const offsetTop = isNaN(this.props.defaultOffsetTop)
138 | ? Math.round(placeholderRect.top) + Math.round(scroll.y)
139 | : this.props.defaultOffsetTop;
140 | const styles = calcPositionStyles(stickyRect, scroll, {
141 | offsetTop,
142 | });
143 |
144 | if (!this.props.disableHardwareAcceleration) {
145 | const shouldAccelerate = this.isNearToViewport(stickyRect);
146 | if (supportsWillChange) {
147 | styles.willChange = shouldAccelerate ? 'position, top' : null;
148 | } else {
149 | styles.transform = shouldAccelerate ? `translateZ(0)` : null;
150 | }
151 | }
152 |
153 | return styles;
154 | }
155 |
156 | recalculateLayoutBeforeUpdate = (): {
157 | stickyRect: IRect;
158 | placeholderRect: IRect;
159 | } => {
160 | return {
161 | placeholderRect: this.placeholderRef.current.getBoundingClientRect(),
162 | stickyRect: this.stickyRef.current.getBoundingClientRect(),
163 | };
164 | };
165 |
166 | handleViewportUpdate = (
167 | { scroll }: { scroll: IScroll },
168 | {
169 | stickyRect,
170 | placeholderRect,
171 | }: { stickyRect: IRect; placeholderRect: IRect },
172 | ) => {
173 | if (this.props.disabled) {
174 | return;
175 | }
176 | // in case children is not a function renderArgs will never be used
177 | const willRenderAsAFunction = typeof this.props.children === 'function';
178 |
179 | const nextOffset = Math.max(Math.round(stickyRect.bottom), 0);
180 | const nextOffsetHeight = stickyRect.height;
181 | const offsetDidChange = this.stickyOffset !== nextOffset;
182 | const offsetHeightDidChange = this.stickyOffsetHeight !== nextOffsetHeight;
183 |
184 | const styles = this.getStickyStyles(stickyRect, placeholderRect, scroll);
185 | const stateStyles = this.state.styles;
186 | const stylesDidChange = !shallowEqualPositionStyles(styles, stateStyles);
187 | const isNearToViewport = this.isNearToViewport(stickyRect);
188 | const isSticky = willRenderAsAFunction
189 | ? styles.top === 0 && styles.position === 'fixed'
190 | : false;
191 | const isNearToViewportDidChange =
192 | this.state.isNearToViewport !== isNearToViewport;
193 | const isStickyDidChange = this.state.isSticky !== isSticky;
194 |
195 | if (
196 | this.props.updateStickyOffset &&
197 | (offsetDidChange || offsetHeightDidChange)
198 | ) {
199 | this.props.updateStickyOffset(nextOffset, nextOffsetHeight);
200 | }
201 |
202 | if (!stylesDidChange && !isNearToViewportDidChange && !isStickyDidChange) {
203 | return;
204 | }
205 |
206 | this.setState({
207 | styles: stylesDidChange ? styles : stateStyles,
208 | isNearToViewport,
209 | isSticky,
210 | });
211 | };
212 |
213 | renderSticky = ({ isRecalculating }: { isRecalculating: boolean }) => {
214 | const { disabled, children, stickyProps } = this.props;
215 | return (
216 |
221 | >
222 | forwardRef={this.stickyRef}
223 | positionStyle={this.state.styles}
224 | disabled={disabled || isRecalculating}
225 | children={children}
226 | renderArgs={{
227 | isNearToViewport: this.state.isNearToViewport,
228 | isSticky: this.state.isSticky,
229 | }}
230 | {...stickyProps}
231 | />
232 | );
233 | };
234 |
235 | render() {
236 | const { disabled, disableResizing, style, className } = this.props;
237 | return (
238 | <>
239 |
247 | {this.renderSticky}
248 |
249 |
256 | >
257 | );
258 | }
259 | }
260 |
261 | export default connectStickyProvider()(StickyScrollUp);
262 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Stickup
2 |
3 | React components to stick elements at the top of the page while scrolling.
4 |
5 | 
6 | [](https://www.npmjs.com/package/react-stickup)
7 | 
8 | [](https://bundlephobia.com/result?p=react-stickup)
9 | [](https://travis-ci.org/garthenweb/react-stickup)
10 |
11 | ## Features
12 |
13 | * [`Sticky`](#sticky) component like `position: sticky` with options for elements bigger than the viewport
14 | * [`StickyScrollUp`](#stickyscrollup) component that is only visible when scrolling up (like the Chrome Browser url bar on Android)
15 | * Support for modern browsers (including IE11)
16 | * Build with performance in mind: Blazing fast, even on low end devices
17 | * Typescript
18 |
19 | [See the example to see how the library will work in production.](http://garthenweb.github.io/react-stickup)
20 |
21 | ## Installation
22 |
23 | ```
24 | npm install --save react-stickup
25 | ```
26 |
27 | ## Requirements
28 |
29 | * [react](https://reactjs.org/) version `16.3` or higher
30 |
31 | ## Static Types
32 |
33 | * Support for [Typescript](https://www.typescriptlang.org/) is included within this library, no need to install additional packages
34 | * [Flow](https://flow.org/en/) is not supported (feel free to create a PR)
35 |
36 | ## Client Support
37 |
38 | This library aims to support the following clients
39 |
40 | * Chrome/ Chrome Android (latest)
41 | * Firefox (latest)
42 | * Edge (latest)
43 | * Safari/ Safari iOS (latest)
44 | * NodeJS (latest)
45 | * Internet Explorer 11 (with @babel/polyfill)
46 |
47 | Please fill an issue in case your client is not supported or you see an issue in one of the above.
48 |
49 | ## Usage
50 |
51 | ### StickyProvider
52 |
53 | This component is required as a parent for `Sticky` and `StickyScrollUp` component to work. It will take care of registration of event handlers and provides a communication channel between components. The main implementation is based on [react-viewport-utils](https://github.com/garthenweb/react-viewport-utils).
54 |
55 | ### Sticky
56 |
57 | Acts like `position: sticky` css property.
58 | By default the component will be sticky when the top offset is reached and will stay that way. In case it should only stick within a certain container it can get assigned as a reference.
59 |
60 | **Important**: To work properly the `Sticky` component must have a `StickyProvider` as a parent within its tree.
61 |
62 | #### Example
63 |
64 | ``` javascript
65 | import * as React from 'react';
66 | import { Sticky } from 'react-stickup';
67 |
68 | const container = React.createRef();
69 |
70 | render(
71 |
72 |
73 |
74 | My Header
75 |
76 |
77 | Lots of content
78 |
79 |
80 |
81 | My Header
82 |
83 | ,
84 | document.querySelector('main')
85 | );
86 | ```
87 |
88 | #### Properties
89 |
90 | **`children?: React.ReactNode | ((options: { isSticky: boolean, isDockedToBottom: boolean, isNearToViewport: boolean, appliedOverflowScroll: 'end' | 'flow' }) => React.ReactNode)`**
91 |
92 | The child node that is rendered within the sticky container. When rendered as a function it will add further information the the function which can be used e.g. to update stylings.
93 |
94 | **`container?: React.RefObject`**
95 |
96 | The reference to the container to stick into. If this is not set, the component will be sticky regardless how far the user scrolls down.
97 |
98 | **`defaultOffsetTop?: number`**
99 |
100 | A top offset to create a padding between the browser window and the sticky component when sticky.
101 |
102 | **`overflowScroll?: 'end' | 'flow'`**
103 |
104 | Defines how the sticky element should react in case its bigger than the viewport.
105 | Different options are available:
106 |
107 | * `end`: The default value will keep the component sticky as long as it reaches the bottom of its container and only then will scroll down.
108 | * `flow`: The element scrolls with the flow of the scroll direction, therefore the content is easier to access.
109 |
110 | **`disabled?: boolean`**
111 |
112 | Allows to disable all sticky behavior. Use this in case you need to temporary disable the sticky behavior but you don't want to unmount it for performance reasons.
113 |
114 | **`disableHardwareAcceleration?: boolean`**
115 |
116 | By default css styles for hardware acceleration (`will-change` if supported, otherwise falls back to `transform`) are activated. This allows to turn it off.
117 |
118 | **`disableResizing?: boolean`**
119 |
120 | The components will resize when the width of the window changes to adjust its height and width. This allows to turn the resizing off.
121 |
122 | **`stickyProps?: {}`**
123 |
124 | All properties within this object are spread directly into the sticky element within the component. This e.g. allows to add css styles by `className` or `style`.
125 |
126 | **`style?: React.CSSProperties`**
127 |
128 | Will be merged with generated styles of the placeholder element. It also allows to override generated styles.
129 |
130 | **`className?: string`**
131 |
132 | The class name is passed directly to the placeholder element.
133 |
134 | ### StickyScrollUp
135 |
136 | Only Sticky to the top of the page in case it the page is scrolled up. When scrolled down, the content will just scroll out. `Sticky` next to the `StickyScrollUp` will stick to the bottom of it and will therefore not overlap.
137 |
138 | **Important**: To work properly the `StickyScrollUp` component must have a `StickyProvider` as a parent within its tree. All `Sticky` components must be wrapped by the same instance of the `StickyProvider`as the `StickyScrollUp` component to not overlap.
139 |
140 | #### Example
141 |
142 | ``` javascript
143 | import * as React from 'react';
144 | import { Sticky, StickyScrollUp, StickyProvider } from 'react-stickup';
145 |
146 | const container = React.createRef();
147 |
148 | render(
149 |
150 |
151 | My Stick up container
152 |
153 |
154 |
155 | My Header
156 |
157 |
158 | Lots of content
159 |
160 |
161 | ,
162 | document.querySelector('main')
163 | );
164 | ```
165 |
166 | #### Properties
167 |
168 | **`children?: React.ReactNode | ((options: { isSticky: boolean, isNearToViewport: boolean }) => React.ReactNode)`**
169 |
170 | The child node that is rendered within the sticky container. When rendered as a function it will add further information the the function which can be used e.g. to update stylings.
171 |
172 | **`disabled?: boolean`**
173 |
174 | Allows to disable all sticky behavior. Use this in case you need to temporary disable the sticky behavior but you don't want to unmount it for performance reasons.
175 |
176 | **`disableHardwareAcceleration?: boolean`**
177 |
178 | By default css styles for hardware acceleration (`will-change` if supported, otherwise falls back to `transform`) are activated. This allows to turn it off.
179 |
180 | **`disableResizing?: boolean`**
181 |
182 | The components will resize when the width of the window changes to adjust its height and width. This allows to turn the resizing off.
183 |
184 | **`stickyProps?: {}`**
185 |
186 | All properties within this object are spread directly into the sticky element within the component. This e.g. allows to add css styles by `className` or `style`.
187 |
188 | **`style?: React.CSSProperties`**
189 |
190 | Will be merged with generated styles of the placeholder element. It also allows to override generated styles.
191 |
192 | **`className?: string`**
193 |
194 | The class name is passed directly to the placeholder element.
195 |
196 | **`defaultOffsetTop?: number`**
197 |
198 | DEPRECATED: If not set, the start position is now calculated by default as it was already the case for the `Sticky` component. As there is no use case for this property anymore it will be removed in the future.
199 |
200 | When not initialized as the first element within the page (directly at the top) this allows to set an offset by hand from where the component will be sticky.
201 |
202 | ## Contributing
203 |
204 | Contributions are highly appreciated! The easiest is to fill an issue in case there is one before providing a PR so we can discuss the issue and a possible solution up front.
205 |
206 | At the moment there is not a test suite because its tricky to test the sticky behavior automated. I consider creating some e2e tests with Cypress in the future. In case you are interested in helping on that, please let me know!
207 |
208 | For now, please make sure to add a test case for all features in the examples.
209 |
210 | To start the example with the recent library changes just run `npm start` on the command.
211 |
212 | ## License
213 |
214 | Licensed under the [MIT License](https://opensource.org/licenses/mit-license.php).
215 |
--------------------------------------------------------------------------------
/lib/Sticky.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import {
3 | ObserveViewport,
4 | IRect,
5 | IScroll,
6 | IDimensions,
7 | } from 'react-viewport-utils';
8 |
9 | import { connect as connectStickyProvider } from './StickyProvider';
10 | import StickyElement from './StickyElement';
11 | import StickyPlaceholder from './StickyPlaceholder';
12 | import {
13 | TRenderChildren,
14 | IStickyComponentProps,
15 | IStickyInjectedProps,
16 | IPositionStyles,
17 | } from './types';
18 | import {
19 | supportsWillChange,
20 | shallowEqualPositionStyles,
21 | supportsPositionSticky,
22 | } from './utils';
23 |
24 | type OverflowScrollType = 'flow' | 'end';
25 |
26 | interface IOwnProps extends IStickyComponentProps {
27 | /**
28 | * The reference to the container to stick into. If this is not set, the component will be sticky regardless how far the user scrolls down.
29 | */
30 | container?: React.RefObject;
31 | /**
32 | * The child node that is rendered within the sticky container. When rendered as a function it will add further information the the function which can be used e.g. to update stylings.
33 | */
34 | children?: TRenderChildren<{
35 | isSticky: boolean;
36 | isDockedToBottom: boolean;
37 | isNearToViewport: boolean;
38 | appliedOverflowScroll: OverflowScrollType;
39 | }>;
40 | /**
41 | * Defines how the sticky element should react in case its bigger than the viewport.
42 | * Different options are available:
43 | * * end: The default value will keep the component sticky as long as it reaches the bottom of its container and only then will scroll down.
44 | * * flow: The element scrolls with the flow of the scroll direction, therefore the content is easier to access.
45 | */
46 | overflowScroll?: OverflowScrollType;
47 | /**
48 | * A top offset to create a padding between the browser window and the sticky component when sticky.
49 | */
50 | defaultOffsetTop?: number;
51 | /**
52 | * Tries to detect when the usage of native `position: sticky` is possible and uses it as long as possible. This is an experimental property and might change in its behavior or disappear in the future.
53 | */
54 | experimentalNative?: boolean;
55 | }
56 |
57 | interface IProps extends IOwnProps, IStickyInjectedProps {}
58 |
59 | interface IState {
60 | isSticky: boolean;
61 | isDockedToBottom: boolean;
62 | isNearToViewport: boolean;
63 | appliedOverflowScroll: OverflowScrollType;
64 | styles: IPositionStyles;
65 | useNativeSticky: boolean;
66 | }
67 |
68 | interface ILayoutSnapshot {
69 | stickyRect: IRect;
70 | containerRect: IRect;
71 | }
72 |
73 | class Sticky extends React.PureComponent {
74 | private stickyRef: React.RefObject = React.createRef();
75 | private placeholderRef: React.RefObject = React.createRef();
76 | private nativeStickyThrewOnce: boolean = false;
77 |
78 | static defaultProps = {
79 | stickyOffset: { top: 0, height: 0 },
80 | defaultOffsetTop: 0,
81 | disableResizing: false,
82 | disableHardwareAcceleration: false,
83 | overflowScroll: 'end' as OverflowScrollType,
84 | experimentalNative: false,
85 | style: {},
86 | };
87 |
88 | state: IState = {
89 | isSticky: false,
90 | isDockedToBottom: false,
91 | isNearToViewport: false,
92 | appliedOverflowScroll: 'end',
93 | styles: {},
94 | useNativeSticky: false,
95 | };
96 |
97 | get container() {
98 | return this.props.container || this.placeholderRef;
99 | }
100 |
101 | get offsetTop() {
102 | return this.props.stickyOffset.top + this.props.defaultOffsetTop;
103 | }
104 |
105 | hasContainer = () => {
106 | return Boolean(this.props.container);
107 | };
108 |
109 | isNearToViewport = (rect: IRect): boolean => {
110 | const padding = 700;
111 | return rect.top - padding < 0 && rect.bottom + padding > 0;
112 | };
113 |
114 | getOverflowScrollType = (
115 | rectSticky: IRect,
116 | dimensions: IDimensions,
117 | ): OverflowScrollType => {
118 | return this.props.overflowScroll === 'flow' &&
119 | this.calcHeightDifference(rectSticky, dimensions) > 0
120 | ? 'flow'
121 | : 'end';
122 | };
123 |
124 | isSticky = (rect: IRect, containerRect: IRect, dimensions: IDimensions) => {
125 | if (!this.hasContainer()) {
126 | return Math.round(containerRect.top) <= this.offsetTop;
127 | }
128 |
129 | if (Math.round(containerRect.top) > this.offsetTop) {
130 | return false;
131 | }
132 |
133 | const height =
134 | this.props.overflowScroll === 'flow'
135 | ? Math.min(rect.height, dimensions.height)
136 | : rect.height;
137 | if (Math.round(containerRect.bottom) - this.offsetTop < height) {
138 | return false;
139 | }
140 |
141 | return true;
142 | };
143 |
144 | shouldUseNativeSticky = (appliedOverflowScroll: OverflowScrollType) => {
145 | if (
146 | !this.props.experimentalNative ||
147 | !supportsPositionSticky ||
148 | appliedOverflowScroll !== 'end' ||
149 | this.props.stickyOffset.top !== 0
150 | ) {
151 | return false;
152 | }
153 |
154 | if (
155 | process.env.NODE_ENV !== 'production' &&
156 | !this.nativeStickyThrewOnce &&
157 | (this.placeholderRef && this.placeholderRef.current.parentElement) !==
158 | (this.props.container && this.props.container.current)
159 | ) {
160 | console.warn(
161 | 'react-stickup: a sticky element was used with property `experimentalNative` but its `container` is not the parent the sticky component. As the native sticky implementation always uses its parent element as the container. This can lead to unexpected results. It is therefore recommended to change the DOM structure so that the container is a direct parent of the Sticky component or to remove the `experimentalNative` property.',
162 | );
163 | this.nativeStickyThrewOnce = true;
164 | }
165 | return true;
166 | };
167 |
168 | isDockedToBottom = (
169 | rect: IRect,
170 | containerRect: IRect,
171 | dimensions: IDimensions,
172 | ) => {
173 | if (!rect || !containerRect) {
174 | return false;
175 | }
176 |
177 | if (!this.hasContainer()) {
178 | return false;
179 | }
180 |
181 | if (rect.height > containerRect.height) {
182 | return false;
183 | }
184 |
185 | const height =
186 | this.props.overflowScroll === 'flow'
187 | ? Math.min(rect.height, dimensions.height)
188 | : rect.height;
189 | if (Math.round(containerRect.bottom) - this.offsetTop >= height) {
190 | return false;
191 | }
192 |
193 | return true;
194 | };
195 |
196 | calcHeightDifference(rectSticky: IRect, dimensions: IDimensions) {
197 | if (!dimensions) {
198 | return 0;
199 | }
200 | return Math.max(0, Math.round(rectSticky.height) - dimensions.height);
201 | }
202 |
203 | calcOverflowScrollFlowStickyStyles(
204 | rectSticky: IRect,
205 | containerRect: IRect,
206 | scroll: IScroll,
207 | dimensions: IDimensions,
208 | ): IPositionStyles {
209 | const containerTop = Math.round(containerRect.top);
210 | const stickyTop = Math.round(rectSticky.top);
211 | const scrollY = Math.round(scroll.y);
212 | const scrollYTurn = Math.round(scroll.yTurn);
213 | const heightDiff = this.calcHeightDifference(rectSticky, dimensions);
214 | const containerTopOffset =
215 | containerTop + scrollY - this.props.stickyOffset.height;
216 | const isStickyBottomReached =
217 | Math.round(rectSticky.bottom) <= dimensions.height;
218 | const isContainerTopReached = containerTop < this.offsetTop;
219 | const isTurnWithinHeightOffset =
220 | scrollYTurn - heightDiff <= containerTopOffset;
221 | const isTurnPointBeforeContainer = scrollYTurn < containerTopOffset;
222 | const isTurnPointAfterContainer =
223 | scrollYTurn > containerTopOffset + containerRect.height;
224 | const isTurnPointWithinContainer = !(
225 | isTurnPointBeforeContainer || isTurnPointAfterContainer
226 | );
227 | // scroll down AND sticky rect bottom not reached AND turn point not within the container OR
228 | // scroll up AND container top not reached OR
229 | //scroll up AND turns within the height diff AND turn point not within the container
230 | if (
231 | (scroll.isScrollingDown &&
232 | !isStickyBottomReached &&
233 | !isTurnPointWithinContainer) ||
234 | (scroll.isScrollingUp && !isContainerTopReached) ||
235 | (scroll.isScrollingUp &&
236 | isTurnWithinHeightOffset &&
237 | !isTurnPointWithinContainer)
238 | ) {
239 | return {
240 | position: 'absolute',
241 | top: 0,
242 | };
243 | }
244 |
245 | // scroll down AND sticky bottom reached
246 | if (scroll.isScrollingDown && isStickyBottomReached) {
247 | return {
248 | position: 'fixed',
249 | top: -heightDiff,
250 | };
251 | }
252 |
253 | const isStickyTopReached = stickyTop >= this.offsetTop;
254 | // scroll down AND turn point within container OR
255 | // scroll up AND turn point not before container AND not sticky top reached
256 | if (
257 | (scroll.isScrollingDown && isTurnPointWithinContainer) ||
258 | (scroll.isScrollingUp &&
259 | !isTurnPointBeforeContainer &&
260 | !isStickyTopReached)
261 | ) {
262 | return {
263 | position: 'absolute',
264 | top: Math.abs(scrollY - stickyTop + (containerTop - scrollY)),
265 | };
266 | }
267 |
268 | return {
269 | position: 'fixed',
270 | top: this.offsetTop,
271 | };
272 | }
273 |
274 | calcPositionStyles(
275 | rectSticky: IRect,
276 | containerRect: IRect,
277 | scroll: IScroll,
278 | dimensions: IDimensions,
279 | ): IPositionStyles {
280 | if (this.isSticky(rectSticky, containerRect, dimensions)) {
281 | if (this.getOverflowScrollType(rectSticky, dimensions) === 'flow') {
282 | return this.calcOverflowScrollFlowStickyStyles(
283 | rectSticky,
284 | containerRect,
285 | scroll,
286 | dimensions,
287 | );
288 | }
289 | const stickyOffset = this.props.stickyOffset.top;
290 | const stickyHeight = this.props.stickyOffset.height;
291 | const headIsFlexible = stickyOffset > 0 && stickyOffset < stickyHeight;
292 | if (headIsFlexible) {
293 | const relYTurn =
294 | Math.round(scroll.yTurn - scroll.y + scroll.yDTurn) -
295 | Math.round(containerRect.top);
296 | return {
297 | position: 'absolute',
298 | top: relYTurn + this.offsetTop,
299 | };
300 | }
301 |
302 | return {
303 | position: 'fixed',
304 | top: this.offsetTop,
305 | };
306 | }
307 |
308 | if (this.isDockedToBottom(rectSticky, containerRect, dimensions)) {
309 | return {
310 | position: 'absolute',
311 | top: containerRect.height - rectSticky.height,
312 | };
313 | }
314 |
315 | return {
316 | position: 'absolute',
317 | top: 0,
318 | };
319 | }
320 |
321 | getStickyStyles(
322 | rect: IRect,
323 | containerRect: IRect,
324 | scroll: IScroll,
325 | dimensions: IDimensions,
326 | ): IPositionStyles {
327 | const styles = this.calcPositionStyles(
328 | rect,
329 | containerRect,
330 | scroll,
331 | dimensions,
332 | );
333 |
334 | if (!this.props.disableHardwareAcceleration) {
335 | const shouldAccelerate = this.isNearToViewport(rect);
336 | if (supportsWillChange) {
337 | styles.willChange = shouldAccelerate ? 'position, top' : null;
338 | } else {
339 | styles.transform = shouldAccelerate ? `translateZ(0)` : null;
340 | }
341 | }
342 |
343 | return styles;
344 | }
345 |
346 | recalculateLayoutBeforeUpdate = (): ILayoutSnapshot => {
347 | const containerRect = this.container.current.getBoundingClientRect();
348 | const stickyRect = this.stickyRef.current.getBoundingClientRect();
349 | return {
350 | stickyRect,
351 | containerRect,
352 | };
353 | };
354 |
355 | handleScrollUpdate = (
356 | { scroll, dimensions }: { scroll: IScroll; dimensions: IDimensions },
357 | { stickyRect, containerRect }: ILayoutSnapshot,
358 | ) => {
359 | if (this.props.disabled) {
360 | return;
361 | }
362 | // in case children is not a function renderArgs will never be used
363 | const willRenderAsAFunction = typeof this.props.children === 'function';
364 | const appliedOverflowScroll = this.getOverflowScrollType(
365 | stickyRect,
366 | dimensions,
367 | );
368 |
369 | const useNativeSticky = this.shouldUseNativeSticky(appliedOverflowScroll);
370 |
371 | const styles = useNativeSticky
372 | ? {}
373 | : this.getStickyStyles(stickyRect, containerRect, scroll, dimensions);
374 | const stateStyles = this.state.styles;
375 | const stylesDidChange = !shallowEqualPositionStyles(styles, stateStyles);
376 | const isSticky = willRenderAsAFunction
377 | ? this.isSticky(stickyRect, containerRect, dimensions)
378 | : false;
379 | const isDockedToBottom = willRenderAsAFunction
380 | ? this.isDockedToBottom(stickyRect, containerRect, dimensions)
381 | : false;
382 | const isNearToViewport = this.isNearToViewport(stickyRect);
383 | const useNativeStickyDidChange =
384 | this.state.useNativeSticky !== useNativeSticky;
385 | const isStickyDidChange = this.state.isSticky !== isSticky;
386 | const isDockedToBottomDidChange =
387 | this.state.isDockedToBottom !== isDockedToBottom;
388 | const isNearToViewportDidChange =
389 | this.state.isNearToViewport !== isNearToViewport;
390 | const appliedOverflowScrollDidChange =
391 | appliedOverflowScroll !== this.state.appliedOverflowScroll;
392 |
393 | if (
394 | !useNativeStickyDidChange &&
395 | !stylesDidChange &&
396 | !isStickyDidChange &&
397 | !isDockedToBottomDidChange &&
398 | !isNearToViewportDidChange &&
399 | !appliedOverflowScrollDidChange
400 | ) {
401 | return;
402 | }
403 |
404 | this.setState({
405 | useNativeSticky,
406 | isSticky,
407 | isDockedToBottom,
408 | isNearToViewport,
409 | appliedOverflowScroll,
410 | styles: stylesDidChange ? styles : stateStyles,
411 | });
412 | };
413 |
414 | renderSticky = ({ isRecalculating }: { isRecalculating: boolean }) => {
415 | const { children, disabled, stickyProps } = this.props;
416 | return (
417 |
424 | >
425 | forwardRef={this.stickyRef}
426 | positionStyle={this.state.styles}
427 | disabled={disabled || isRecalculating}
428 | children={children}
429 | renderArgs={{
430 | isSticky: this.state.isSticky,
431 | isDockedToBottom: this.state.isDockedToBottom,
432 | isNearToViewport: this.state.isNearToViewport,
433 | appliedOverflowScroll: this.state.appliedOverflowScroll,
434 | }}
435 | {...stickyProps}
436 | />
437 | );
438 | };
439 |
440 | render() {
441 | const {
442 | disabled,
443 | disableResizing,
444 | style,
445 | className,
446 | overflowScroll,
447 | } = this.props;
448 | return (
449 | <>
450 |
466 | {this.renderSticky}
467 |
468 |
475 | >
476 | );
477 | }
478 | }
479 |
480 | export default connectStickyProvider()(Sticky);
481 |
--------------------------------------------------------------------------------