Overview
We recently faced a common front end challenge here at Pinwheel which led to some interesting learnings for advanced use of React functional components.
Here’s the scenario. We have a form that doesn’t always fit on-screen, requiring the user to scroll through it. However, we want to keep the submit button at the bottom visible at all times and don’t want to resort to a fixed footer because let’s face it, they’re kind of ugly and cliche. Instead, we want a footer that floats in a fixed location when it would normally scroll below the bottom of the page and otherwise displays like a normal element in the natural flow of the page.
A few more requirements increase the challenge:
- We have a header that we want to float if scrolled out of view as well
- The footer height and space below the footer can vary with different amounts of content
- The position of the footer in the normal page flow can be changed in real time by the user since we have expanding tool tips
- The height of the scrolling element can change
Here is the final UI and in this blog, I’ll describe the robust solution we came up with in React to make this possible:
Solution
Here is the overall, high-level solution which I’ll then get into piece-by-piece:
- A function to render the footer UI is defined.
- We render two copies of this footer on the page - one wrapped in a div that is part of the natural page flow and a second wrapped in a div that is fixed/floating. We show one while hiding the other, depending on whether we want to float the footer.
- We make the determination of whether to float by calculating a shouldFloatFooter variable with React’s useMemo function. To ensure this variable is recalculated anytime the footer should switch from floating to non-floating, we add dependencies on the footer and scrolling elements themselves as well as a few other variables which could affect their size or position.
1. Footer UI rendering function
The only thing special about this function is that it takes a parameter that specifies if it is the visible copy or not. Based on the isActive parameter, we conditionally disable the button and include a testid used for indicating the element is visible during unit testing. Otherwise, the invisible copy is identical to the visible copy to ensure that it takes up the exact same space on the page:
const renderFooter = useCallback( (isActive: Boolean) => { return ( <div data-testid={isActive ? 'footer-container-id' : ''}> <Button disabled={!isActive || normalButtonDisableCondition} dataTestId={isActive ? 'footer-button-id' : null} > Footer Action Text </Button> </div> ); }, [normalButtonDisableCondition] );
view rawFloatingFooterWithReact-1.tsx hosted with ❤ by GitHub
2. Render two copies of the footer
We then render the two copies of the UI as shown below - note we use visibility: hidden instead of display: none to ensure the in-flow div continues to take up the space it normally would if visible - this is the whole point of having the duplicate copy so it can react to all the intricacies of HTML rendering. We spent days trying to avoid rendering two copies of the footer UI and instead simply calculate where the footer would have shown up on the page and with what height if not floating. However, trying to emulate the outcome of HTML rendering is a messy, dirty thing and it ended up being much cleaner to simply use the hidden duplicate so we could always know for a fact where the in-flow copy would be positioned and what its height would be:
{/* Floating copy */}<div className="footer--floating" style={{ visibility: shouldFloatFooter ? 'visible' : 'hidden' }}> {renderFooter(shouldFloatFooter)}</div>{/* In flow copy */}<div ref={onFooterContainerChange} style={{ visibility: shouldFloatFooter ? 'hidden' : 'visible' }}> {renderFooter(!shouldFloatFooter)}</div>
view rawFloatingFooterWithReact-2a.tsx hosted with ❤ by GitHub
.footer--floating { position: absolute; bottom: 12px; left: 12px; right: 12px; padding: 12px; background-color: white; border-radius: 8px; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.15); z-index: 99;}
view rawFloatingFooterWithReact-2b.css hosted with ❤ by GitHub
The one other part of the code to note is the ref={onFooterContainerChange} part of the in-flow div. This is the React way to get a reference to that div element so we can read its height and top positioning attributes. Here’s what the code for that function and associated state variables looks like:
const [footerContainerElement, setFooterContainerElement] = useState<HTMLElement>(null);const onFooterContainerChange = useCallback( node => { if (node !== null) { setFooterContainerElement(node) } }, [setFooterContainerElement]);
view rawFloatingFooterWithReact-2c.tsx hosted with ❤ by GitHub
This reference to the footer container element is used in the shouldFloatFooter function.
3. shouldFloatFooter function
Here’s the function that actually makes the determination whether to float the footer or not. In itself, it’s a pretty simple calculation - the tricky part is when to call it again to recalculate. This is determined by the dependencies which I’ll get into one by one afterward. The basic condition is the following:
Is (the height of the scrolling container + the position of the scrolling container’s top border + the amount its been scrolled) less than (the position of the footer in the natural flow of the page + the footer height and the amount of padding we want below it)?
If the condition is true, that means the bottom border of the footer would show up lower on the page than we’d want to display it so we want to float it. If the condition is false, this means the bottom border of the footer is at or above the point in the page we’d want it displayed so we return false to remove the floating copy and show the natural flow copy instead.
const footerBottomPadding = 24;const floatFooter = useMemo(() => { if (scrollingElement && footerContainerElement) { const { clientHeight: parentHeight, offsetTop: parentOffsetTop, } = scrollingElement; const { clientHeight: footerHeight, offsetTop: footerOffsetTop, } = footerContainerElement; return ( parentHeight + parentOffsetTop + scrollTop < footerOffsetTop + footerHeight + footerBottomPadding ); } return false;}, [ scrollTop, scrollingElement, footerContainerElement, windowHeight, lastMutation,]);
view rawFloatingFooterWithReact-3a.tsx hosted with ❤ by GitHub
Note that the entire function is wrapped in the condition that we’ve received a reference to the scrolling element and footer element. If we don’t have these references, we can’t make the calculation so we just return false. In the case of our code, the scrolling element is passed in from a parent element but ultimately it can be retrieved with a ref attribute on the scrolling element just like I showed above with how we get the reference to the footerContainerElement.
I’ll now review each of the dependencies on this memoized function which will retrigger its calculation if they ever change:
scrollTop
This is the most obvious condition we want to retrigger the calculation for. Any time the page is scrolled this variable holds the new offset in pixels of how far down it’s been scrolled. It is obtained as follows:
const onScroll = useCallback( (e: SyntheticEvent) => { const target = e.target as HTMLDivElement; if (target.scrollTop > 0) { setFloatHeader(true); } else { setFloatHeader(false); } setScrollTop(target.scrollTop); }, [setScrollTop, setFloatHeader]);
view rawFloatingFooterWithReact-3b.tsx hosted with ❤ by GitHub
This onScroll function is just put in the onScroll attribute of the div where the scrolling occurs. Note that in the case of the floating header, we want it to float if the page is scrolled down at all (since its top edge should always be flush with the top of the page) so we use this function to simply set that variable to true if scrollTop is ever greater than 0 and that variable in turn is used to set a simple floating class on the header (unlike the footer it’s a fixed height and nothing can affect where it’s top position should be so it’s a much easier use case).
scrollingElement / footerContainerElement
These only change from null to the reference to their respective elements when they are first initialized.
windowHeight
If the window height changes, this means the height of the scrolling element could have changed so we need to recalculate shouldFloatFooter. This is tracked pretty simply - here’s the proper way of doing it functionally:
const [windowHeight, setWindowHeight] = React.useState(window.innerHeight);useEffect(() => { function handleResize() { setWindowHeight(window.innerHeight); } window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); };});
view rawFloatingFooterWithReact-3c.tsx hosted with ❤ by GitHub
lastMutation
This is the most subtle of the dependencies and may not be needed depending on what content you could have above the footer. In our case, as you saw from the screenshot above, we have expandable tooltips which can animate open. There are also other places in our code where user actions can change the height of the page above the footer. Ultimately, we don’t want our footer to depend on the implementation of other parts of the page so here comes MutationObserver to the rescue.
What this allows us to do is simply monitor a given element and all its child elements for certain types of changes. In our case, almost any change could affect the position of the footer on the page - an element added or removed or a change in the styling - so we listen to everything.
The first task is to define the MutationObserver - this would be pretty straightforward except for some slight hackiness needed to handle changes (such as expanding tip sections) which execute with a CSS animation since no mutation is triggered after an animation is finished:
const [mutationObserver, setMutationObserver] = useState<MutationObserver>(null);useEffect(() => { let delayedMutationEffect; function handleMutations(mutations: MutationRecord[]) { setLastMutation(mutations[mutations.length - 1]); delayedMutationEffect = setTimeout(() => setLastMutation(null), maxAnimationLength); } if (window.MutationObserver && !mutationObserver) { setMutationObserver(new MutationObserver(handleMutations)); } return () => { if (mutationObserver) { mutationObserver.disconnect(); } if (delayedMutationEffect) { clearTimeout(delayedMutationEffect); } };}, [setMutationObserver, mutationObserver, maxAnimationLength]);
view rawFloatingFooterWithReact-3d.tsx hosted with ❤ by GitHub
From the MutationObserver perspective we check if the browser supports it (almost all do) and if it isn’t defined yet and if so, we create one and save it in a state variable with a simple callback handler that just keeps track of the last mutation which has happened in another state variable.
The rub here is that when a mutation happens, we need to update the value of lastMutation after an animation could have finished so that the shouldFloatFooter function recalculates again in case the animation changed anything. We don’t actually use the value of lastMutation anywhere so we simply set it back to null.
This is the one piece of code that is less than ideal. It leads to a jerky update after the animation finishes instead of smoothly transitioning into floating if needed during the animation. It also depends on knowing the longest animation that could take place that would matter. And furthermore, it recalculates whether it should float the footer maxAnimationLength milliseconds after every mutation, not just the ones that trigger an animation. Unfortunately, based on my research, there’s currently no support for a more accurate and targeted way of listening to animation changes on the page. If anyone knows of one, would love to hear about it — tweet us @PinwheelAPI!
In the end, though, the code works pretty decently and we clean up our mutationObserver and potential timeout handler to avoid any memory leakage in the effect return statement.
Now that it’s defined, we have to set it up to listen to the elements we care about:
This is the footerElement itself and the parent scrollingElement so I just added it to the function referenced earlier where we first get the reference to the footer element:
const onFooterContainerChange = useCallback( node => { if (node !== null) { setFooterContainerElement(node); if (mutationObserver) { mutationObserver.observe(node, { childList: true, characterData: true, attributes: true, subtree: true, }); if (scrollingElement) { mutationObserver.observe(scrollingElement, { childList: true, characterData: true, attributes: true, subtree: true, }); } } } }, [mutationObserver, scrollingElement]);
view rawFloatingFooterWithReact-3e.tsx hosted with ❤ by GitHub
Conclusion
That’s it! This solution handles the many complex scenarios which could change the height of the scrolling element, the height of the footer element, or the position of the footer element on the page which together determine whether it should float or not.
The code makes the determination instantaneously while also avoiding unnecessary recalculations, leading to a completely fluid and seamless transition from floating to non-floating which doesn’t hurt the performance of the page.