How to implement a FolderFlip with React

Haven't updated the blogs for a long time. Just had been struggling on the house work during the whole summer time, painting external and internal wall, new bathroom and etc. But there is still the good news, implemented an interesting frontend component with React, which would inspired by lifeatspotify - borrow the name FolderFlip.

The original idea was come up with by the UX designer in our team, she would like to develop a fancy component which can be used to present the company culture. Here is how it looks like:

After investing, I found it can be done with the css feature position: sticky and javascript's IntersectionObserver.

Tip: position: sticky

This is not a new feature, but I rarely used it before because of not fully supported by all the browsers before. But now definitely it's supported by all the main stream browsers. Check CanIUse.

Let's start with creating a list with three items which consists of a title and some simple texts as the content. And before the list, it also has some texts.

We can see when we scroll down and the list enters the screen, all the three titles would always be inside of screen with setting the value of top, bottom and margin-top. And display the titles in order according to the item's index in the list.

1marginTop: `${tagHeight * idx}px`,
2top: `${idx * tagHeight}px`,
3bottom: `${(stepLength - idx - 1) * tagHeight}px`

Here there is one thing I would like to mention. When we use position: sticky, the stickied item would be attached to its parent node, to make all the titles have the same parent node, we use React's fragment to compose each item.

 1<React.Fragment key={"FolderFlipStep" + Title.value + idx}>
 2  <div id={id}></div>
 3  <a
 4    href={"#" + id}
 5    className="FolderFlip-Tag">
 6    <span className="FolderFlip-Tag-Number" />
 7    <h2>{Title.value ?? ""}</h2>
 8  </a>
 9  <div className="FolderFlip-Content">
10    <span className="FolderFlip-Content-Title">{Title ?? ""}</span>
11    <div className="FolderFlip-Content-Container">
12      <div>{Ingress}</div>
13      <button>{Button}</button>
14    </div>
15  </div>
16</React.Fragment>

OK, now it seems we have fixed the most important feature of the component. Nja, kind of. Actually, maybe you have found it when we keep scrolling down (there are some texts under the list as well) and beyond the list, the titles are still sticky and only contents move up. Definitely the title should move together with contents fluently. How do we solve this?

Floating with IntersectionObserver

It comes the js API IntersectionObserver, which observes how the node intersects with the specified master node (defaultly and normally it's the screen) in the non-main process. For detail and description, you can check Mozilla's doc.

 1let observer = new IntersectionObserver(
 2  ([entry]) => {
 3    console.log("reach 100%");
 4  },
 5  {
 6    threshold: 1.0
 7  }
 8);
 9
10observer.observe(element);

In this example, when the node reaches 100% in the screen, it would print out the log.

Therefore, the logic would be simple, when the content of the last item reaches the threshold 100% (taking the screen as master), we would change the items inside the screen from position: sticky to position: relative to allow the items float.

 1let observer = new IntersectionObserver(
 2  ([entry]) => {
 3    setIntersection(
 4      entry.isIntersecting || entry.boundingClientRect.top < 0
 5    );
 6  },
 7  {
 8    threshold: 1.0
 9  }
10);
11
12useEffect(() => {
13  observer.observe(textRef.current);
14
15  return () => {
16    observer.disconnect();
17  };
18}, [observer]);

In the code, the variable observer would be created every time when the page renders which would disconnect the observer and observe the same element again in useEffect. To avoid this, we can use useMemo to reserve the observer from rendering and we can pass a memorized callback function to deal with the entries. And we can create a new hooks to handle this:

 1function useIntersection (textRef, callbackFunc) {
 2  let observer = useMemo(() => {
 3    return new IntersectionObserver(callbackFunc,
 4      {
 5        threshold: 1.0
 6      }
 7    );
 8  }, [callbackFunc]);
 9
10  useEffect(() => {
11    if (textRef?.current) observer.observe(textRef.current);
12
13    return () => {
14      observer.disconnect();
15    };
16  }, [textRef, observer]);
17}

And we can use this hooks to observe the last item of the list.

 1  const textRef = useRef(null);
 2
 3  const callbackFunc = useCallback(
 4    ([entry]) =>
 5      setIntersection(entry.isIntersecting || entry.boundingClientRect.top < 0),
 6    []
 7  );
 8
 9  useIntersection(textRef, intersectCallbackFunc);
10
11  ...
12
13  return (<React.Fragment>
14  <div
15    className="FolderFlip-Content"
16    ref={stepLength - 1 == idx ? textRef : undefined}
17    ></div>
18  </React.Fragment>)

Notice that the textRef is a React ref which would not trigger the execution of useEffect when it changes.

Now we can see when we keep scrolling down, the whole items move out of the screen fluently.

What if more items?

Till now, we have implemented the component. And maybe someone has noticed that we have only three items in the example, what if there are more items, like 6 or more? And actually the title of items would take over the whole screen, the content of the item would only have a very small part of the screen or even cannot show, especially in mobile. How could we fix that?

The solution to this in simple would be that we set maximum value of displayed items in the screen, like 3, no matter how many items we have. And definitely it is more complicated and will be explained in the next article.

comments powered by Disqus