Search code examples
javascriptcssreactjssticky

How to get section to become sticky until the user has scrolled a specific length


I'm trying to replicate something similar to the Postmates fleet website, where they use position: sticky on a section and change elements within that section, until the user has scrolled through all the content.

Here's an example of what I mean:

Here's an example of it

So far I have set up a ref on the section I want to make sticky:

        ref={(r) => this.ref = r}
     ...
/>

And then I get the height of the container on page load:

  componentDidMount() {
    if (this.ref) {
      this.setState({ targetY: this.ref.getBoundingClientRect().bottom },
      // tslint:disable-next-line:no-console
      () => console.log(this.state, 'state'))
    }
    window.addEventListener('scroll', this.handleScroll)
  }

After which I detect the scrolling of the page and know when the section is in view:

  handleScroll(e) {
    const scrollY = window.scrollY + window.innerHeight;
    const { lockedIntoView, targetY } = this.state;
    // tslint:disable-next-line:no-console
    console.log(scrollY, "scrollY");
    if (scrollY >= targetY) {
      this.setState({ lockedIntoView: true }, () => {
        // tslint:disable-next-line:no-console
        console.log(`LockedIntoView: ${lockedIntoView}`);
      });
    } else {
      this.setState({ lockedIntoView: false }, () => {
        // tslint:disable-next-line:no-console
        console.log(`LockedIntoView: ${lockedIntoView}`);
      });
    }
  }

Before setting the container to sticky position:

  <section
    ...
    style={{position: lockedIntoView ? "sticky" : ""}}
    ...
  </>

I would like to know how to make it like the postmates website, where the section just remains in full view of the screen until the content has been scrolled (or for right now, until the user has scrolled a specified height)?

Or just an idea of how they've done it, and what steps I need to take in order to replicate it?

Here's my Codesandbox


Solution

  • Few things to consider:

    1) When you set the target to position: fixed, you are removing it from the dom flow so you need to compensate by adding some height back to the dom - example below does this on the body.

    2) You need to factor in the height of the target when checking scrollY.

    3) When you pass your target height, you add the target back into the dom flow and remove the additional height added in step 1.

    4) You need to keep track if we scrolling up or down - this is done but comparing the last scroll position and the current.

    See comments inline below for a rough example.

    styles.css:

    .lockedIntoView {
      position: fixed;
      top: 0;
    }
    

    index.js

    import * as React from "react";
    import { render } from "react-dom";
    
    import "./styles.css";
    
    interface IProps {
      title: string;
      content: string;
    }
    
    interface IHeroState {
      targetY: number;
      lockedIntoView: boolean;
    }
    
    let lastScrollY = 0;
    
    class App extends React.Component<IProps, IHeroState> {
      ref: HTMLElement | null;
    
      constructor(props) {
        super(props);
        this.handleScroll = this.handleScroll.bind(this);
        this.state = {
          lockedIntoView: false,
          targetY: 0
        };
      }
    
      componentDidMount() {
        if (this.ref) {
          this.setState(
            { targetY: this.ref.getBoundingClientRect().bottom },
            // tslint:disable-next-line:no-console
            () => console.log(this.state, "state")
          );
        }
        window.addEventListener("scroll", this.handleScroll);
      }
    
      componentWillUnmount() {
        window.removeEventListener("scroll", this.handleScroll);
      }
    
      handleScroll(e) {
        const scrollY = window.scrollY + window.innerHeight;
    
        const { lockedIntoView, targetY } = this.state;
    
        if (lockedIntoView) {
          console.info("we locked");
    
          // update the padding on the doc as we now removing the target from normal flow
          window.document.body.style.paddingBottom = `${this.ref.getBoundingClientRect().height}px`;
    
          // we passed the taret so reset -  we have to factor the target height in the calc
          if (scrollY > targetY + this.ref.getBoundingClientRect().height) {
            window.document.body.style.paddingBottom = "0px";
            this.setState({ lockedIntoView: false });
          }
        } else {
          // if we scrollign down and at the target, then lock
          if (
            scrollY > lastScrollY &&
            (scrollY >= targetY &&
              scrollY < targetY + this.ref.getBoundingClientRect().height)
          ) {
            console.info("we locked");
            this.setState({ lockedIntoView: true });
          }
        }
    
        // update last scroll position to determine if we going down or up
        lastScrollY = scrollY;
      }
    
      render() {
        const { lockedIntoView, targetY } = this.state;
        const fixed = lockedIntoView ? "lockedIntoView" : "";
    
        return (
          <div className="App">
            <div className="bg-near-white min-vh-100 ">
              <h1>First section</h1>
            </div>
            <div
              ref={r => (this.ref = r)}
              className={`vh-100 bg-blue pa0 ma0 ${fixed}`}
            >
              <h2>
                When this is in full view of the window, it should remain fixed
                until the window has scrolled the full length of the window
              </h2>
            </div>
            <div style={{ height: "300vh" }} className="bg-near-white min-vh-100 ">
              <h2>The next section</h2>
              <p>
                Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
                eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
                ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
                aliquip ex ea commodo consequat. Duis aute irure dolor in
                reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
                pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
                culpa qui officia deserunt mollit anim id est laborum.
              </p>
            </div>
          </div>
        );
      }
    }
    
    const rootElement = document.getElementById("root");
    render(<App />, rootElement);
    

    codesandbox demo