Stack
A component for creating scrollable sections with sticky headers, perfect for long-form content and documentation where maintaining context while scrolling is important.
Dependencies
Source Code
"use client";
import {
useEffect,
useRef,
HTMLAttributes,
ReactElement,
ReactNode,
} from "react";
import { debounce } from "@/lib/debounce";
import { sum } from "@/lib/math/sum";
const px = (value: number) => (isNaN(value) ? undefined : `${value}px`);
interface StackProps extends HTMLAttributes<HTMLDivElement> {
stick: "top" | "bottom";
children: ReactElement<typeof StackItem> | ReactElement<typeof StackItem>[];
}
const Stack = ({ stick = "top", children, ...rest }: StackProps) => {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const items = [
...root.querySelectorAll('[data-stack="item"]'),
] as HTMLElement[];
const headers = items.map((item) =>
item.querySelector('[data-stack="header"]')
) as HTMLElement[];
const length = items.length;
const heights = {
viewport: 0,
items: new Array(length).fill(0),
headers: new Array(length).fill(0),
contents: new Array(length).fill(0),
update: () => {
heights.viewport = window.innerHeight;
for (let index = 0; index < length; index++) {
heights.items[index] = items[index].getBoundingClientRect().height;
heights.headers[index] =
headers[index]?.getBoundingClientRect().height || 0;
heights.contents[index] =
heights.items[index] - heights.headers[index];
}
},
};
const computeAlignTopBottomMargins = () => {
const values: number[] = [];
for (let i = length - 1; i >= 0; i--) {
values[i] =
i === length - 1
? 0
: heights.items[i + 1] -
heights.items[i] +
heights.headers[i] +
values[i + 1];
}
return values;
};
const onResize = debounce(() => {
heights.update();
const preComputedMarginBottoms =
stick === "top" ? computeAlignTopBottomMargins() : [];
for (let index = 0; index < length; index++) {
const styles = {
position: "sticky" as const,
top: undefined as string | undefined,
bottom: undefined as string | undefined,
marginTop: undefined as string | undefined,
marginBottom: undefined as string | undefined,
};
// sum of all headers before the current item
const preHeaderHeightSum = sum(...heights.headers.slice(0, index));
if (stick === "top") {
styles.top = px(preHeaderHeightSum);
styles.marginBottom = px(preComputedMarginBottoms[index]);
styles.marginTop = px(
-1 * (preComputedMarginBottoms[index - 1] || 0)
);
}
if (stick === "bottom") {
// sum of all headers after the current item
const subHeaderHeightSum = sum(
...heights.headers.slice(index + 1, length)
);
styles.bottom = px(
-1 * (heights.contents[index] - subHeaderHeightSum)
);
styles.marginTop = px(preHeaderHeightSum);
styles.marginBottom = px(
-1 * (preHeaderHeightSum + heights.headers[index])
);
}
Object.assign(items[index].style, styles);
}
if (stick === "bottom") {
// correct layout deficit create by negative margins
root.style.paddingBottom = px(sum(...heights.headers)) as string;
}
});
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(root);
return () => {
resizeObserver.disconnect();
// clear styles
root.style.removeProperty("paddingBottom");
items.forEach((item) => {
item.style.removeProperty("position");
item.style.removeProperty("bottom");
item.style.removeProperty("top");
item.style.removeProperty("marginTop");
item.style.removeProperty("marginBottom");
});
};
}, [stick]);
return (
<div {...rest} ref={rootRef} data-stack="root">
{children}
</div>
);
};
interface StackItemProps extends HTMLAttributes<HTMLDivElement> {
children: [ReactElement<typeof StackHeader>, ...ReactNode[]];
}
const StackItem = ({ children, ...rest }: StackItemProps) => {
return (
<div {...rest} data-stack="item">
{children}
</div>
);
};
const StackHeader = ({ children, ...rest }: HTMLAttributes<HTMLDivElement>) => {
return (
<div {...rest} data-stack="header">
{children}
</div>
);
};
export { Stack, StackHeader, StackItem };
Features
- Unstyled: No default styles, just the sticky behavior
- Automatic: Handles all offset calculations and supports dynamic content heights
- Native: Uses
position: sticky
under the hood without any on-scroll logic, so it's performant and works in all browsers - No exit overlap: Trailing item never overlaps with the header when exiting the viewport (see Stack vs. Manual
position: sticky
)
Anatomy
<Stack>
<StackItem>
<StackHeader />
{/* content */}
</StackItem>
</Stack>
API Reference
Stack
Prop | Default | Type | Description |
---|---|---|---|
|
|
| Where in the viewport the stack should stick. |
StackItem
Extends the div
element.
StackHeader
Extends the div
element.