Sticky toolbar inside an Atlassian Connect iframe

I want to share with you a solution to achieve a sticky toolbar inside an iframe. This is relevant for Atlassian Connect modules that run in an auto-resizing iframe, for example macros. If the iframe becomes larger than the browser window, you may have some UI elements that you want to stay on screen, for example a header or a save button. Due to the nature of iframes, position: fixed and position: sticky will not work, as they are positioning elements relative to the iframe viewport, rather than the browser viewport.

Due to the nature of cross-origin iframes, we cannot access the scroll position of the top frame. AP.scrollPosition provides a way to access it, but it is only available on general pages, where it is particularly useless (see What is AP.scrollPosition and how to use it?).

To work around this problem, I found a trick using the Intersection Observer API, which can be used to calculate the section of a DOM element that is currently visible in the browser viewport in consideration of the current scroll position. To make use of the API, I am creating as many invisible 100px high elements as are needed to fill the entire height of the iframe and then register an intersection observer on each of these elements to calculate and be notified about the current scroll position of the iframe.

Here is the TypeScript+React code to create an absolutely positioned toolbar that always stays at the top of the browser window inside the iframe, even when scrolling down:

import { colors } from '@atlaskit/theme';
import React, { FunctionComponent, ReactNode, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import classNames from 'classnames';

const CONTAINER_HEIGHT = 100;
const threshold = [...Array(CONTAINER_HEIGHT + 1).keys()].map((i) => i / CONTAINER_HEIGHT);

/**
 * Registers an intersection handler that detects the scrolled position of the current
 * iframe within the browser viewport and calls a handler when it is first invoked and
 * whenever the scrolled position changes. This allows to position elements within the
 * iframe in a way that their position stays sticky in relation to the browser window.
 * @param handler Is invoked when the function is first called and whenever the scroll
 * position changes (for example due to the user scrolling the parent document). The
 * "top" parameter is the number of pixels from the top of the browser viewport to the
 * top of the iframe (if the top of the iframe is above the top of the browser viewport)
 * or 0 (if the top of the iframe is below the top of the browser viewport). Positioning
 * an element absolutely at this top position inside the iframe will simulate a sticky
 * positioning at the top edge of the browser viewport.
 * @returns Returns a callback that unregisters the handler.
 */
function registerScrollPositionHandler(handler: (top: number) => void): () => void {
    const elementContainer = document.createElement('div');
    Object.assign(elementContainer.style, {
        position: 'absolute',
        top: '0',
        bottom: '0',
        width: '1px',
        pointerEvents: 'none',
        overflow: 'hidden'
    });
    document.body.appendChild(elementContainer);

    const elements: HTMLDivElement[] = [];
    let intersectionObserver: IntersectionObserver | undefined = undefined;

    const resizeObserver = new ResizeObserver(() => {
        intersectionObserver = new IntersectionObserver((entries) => {
            for (const entry of entries) {
                if (entry.intersectionRatio > 0 && (entry.intersectionRect.top > entry.boundingClientRect.top || entry.target === elements[0])) {
                    handler(entry.intersectionRect.top);
                }
            }
        }, { threshold });

        const count = Math.ceil(document.documentElement.offsetHeight / CONTAINER_HEIGHT);
        for (let i = 0; i < count; i++) {
            if (!elements[i]) {
                elements[i] = document.createElement('div');
                Object.assign(elements[i].style, {
                    position: 'absolute',
                    top: `${i * CONTAINER_HEIGHT}px`,
                    height: `${CONTAINER_HEIGHT}px`,
                    width: '100%'
                });
                elementContainer.appendChild(elements[i]);
                intersectionObserver.observe(elements[i]);
            }
        }
    });
    resizeObserver.observe(document.documentElement);

    return () => {
        resizeObserver.disconnect();
        intersectionObserver?.disconnect();
        elementContainer.remove();
    };
}

function useScrollPosition(): number {
    const [scrollPosition, setScrollPosition] = useState(0);
    useEffect(() => {
        return registerScrollPositionHandler((top) => {
            setScrollPosition(top);
        });
    }, []);
    return scrollPosition;
}

const Container = styled.div`
    position: absolute;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    overflow: hidden;
    pointer-events: none;
    z-index: 90;

    > div {
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        pointer-events: auto;
        transition: padding-top 0.3s;
        background: ${colors.N0};
    }

    &.isFloating > div {
        border-bottom: 1px solid ${colors.N30};
        padding-bottom: 10px;
    }
`;

export interface StickyToolbarProps {
    children: ReactNode;
    className?: string;
}

const StickyToolbar: FunctionComponent<StickyToolbarProps> = ({ children, className }) => {
    const scrollPosition = useScrollPosition();

    const minPaddingTop = scrollPosition + 66; // Account for Confluence toolbar (56px)
    const maxPaddingTop = scrollPosition + 149; // Account for Confluence toolbar (56px) + page toolbar (83px) (floats in when scrolling up)

    const isFloating = scrollPosition > 0;

    const paddingTop = useRef(0);
    paddingTop.current = isFloating ? Math.max(minPaddingTop, Math.min(maxPaddingTop, paddingTop.current)) : 0;

    return (
        <Container className={classNames([className, { isFloating }])}>
            <div style={{ paddingTop: `${paddingTop.current}px` }}>
                {children}
            </div>
        </Container>
    );
};

export default StickyToolbar;
2 Likes