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;
4 Likes

This is a great use of the intersection observer API. Thanks for sharing!

Hi @candid , thanks for sharing your solution. We have implemented a similar solution inspired by yours on our ZenUML App. However, there is some issue due to the top toolbar on the confluence page. Here is a screen capture. Did you come across a similar issue? Did you find a solution?
When the iframe is at the yellow rectangle position, the top bar in the iFrame would be covered but we have no way to know about that.

Hey @eagle.xiao, my code contains a mechanism to compensate for the guessed position of the Confluence toolbar (through paddingTop). I cannot think of another way than guessing the size and position of the toolbar and compensating for it inside the iframe.

Thanks for your quick response. I really appreciate your help.

I searched through the DOM tree but could not find paddingTop for the toolbar. The closest thing I find is the display: grid style. Is the paddingTop attribute still available?

Second question: when you “guess” the size and position of the toolbar, is that a static/hardcoded value or you “guess” it based on some attributes or context inside the iFrame?

In the code that I posted above you can find the mechanism by looking for paddingTop. The value is hardcoded and needs to be adjusted whenever Atlassian changes the dimensions of the toolbar.

1 Like