Octocat

Stack

A component for creating scrollable sections with sticky headers, perfect for long-form content and documentation where maintaining context while scrolling is important.

import { BookOpenIcon, LightbulbIcon, WrenchIcon } from '@phosphor-icons/react';
import {
  Stack,
  StackHeader,
  StackItem,
} from '@/components/stack';
import { Divider } from '@/components/divider';
import type { PreviewMeta } from '@/lib/preview';

const items = [
  {
    title: 'Getting Started',
    icon: BookOpenIcon,
    content:
      "The Stack component helps create scrollable sections with sticky headers. It's particularly useful for long-form content or documentation where you want to maintain context while scrolling.",
  },
  {
    title: 'Key Features',
    icon: LightbulbIcon,
    content:
      'Smooth sticky header transitions, configurable alignment (top/bottom), and automatic content height calculations make this component highly versatile.',
  },
  {
    title: 'Implementation',
    icon: WrenchIcon,
    content:
      "To use the Stack component, wrap your content sections in StackItem components and include StackHeader components for the sticky headers. The Stack parent component manages all the positioning and scroll behavior automatically. You can customize the appearance using standard CSS classes and configure the stick behavior using the 'stick' prop.",
  },
];

const StackPreview = () => {
  return (
    <div className="pt-[70vh] pb-[70vh]">
      <Stack stick="top">
        {items.map((item, index) => (
          <StackItem key={index} className="bg-background px-4">
            <StackHeader className="text-xl">
              <div className="flex items-center gap-3">
                <item.icon className="size-6" />
                <h3 className="py-2">{item.title}</h3>
              </div>
              <Divider />
            </StackHeader>

            <div className="w-2/3 pt-4 pb-12 text-foreground-secondary text-md">
              {item.content}
            </div>
          </StackItem>
        ))}
      </Stack>
    </div>
  );
};

export const meta = {
  layout: 'fullscreen',
  mode: 'iframe',
} satisfies PreviewMeta;

export default StackPreview;

Dependencies

Source Code

'use client';

import {
  type HTMLAttributes,
  type ReactElement,
  type ReactNode,
  useEffect,
  useRef,
} from 'react';

import { debounce } from '@/lib/debounce';
import { sum } from '@/lib/math/sum';

const px = (value: number) => (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
stick top 'top' | 'bottom' Where in the viewport the stack should stick.

StackItem

Extends the div element.

StackHeader

Extends the div element.

Examples

Stick Position

Scroll down
Scroll up
Section 1
This section demonstrates the default top sticky behavior where headers stick to the top of the viewport as you scroll down.
Section 2
This section demonstrates the default top sticky behavior where headers stick to the top of the viewport as you scroll down.
Section 3
This section demonstrates the default top sticky behavior where headers stick to the top of the viewport as you scroll down.
Section 1
This section shows how headers can stick to the bottom of the viewport when using stick=bottom. This is useful for bottom-up navigation patterns.
Section 2
This section shows how headers can stick to the bottom of the viewport when using stick=bottom. This is useful for bottom-up navigation patterns.
Section 3
This section shows how headers can stick to the bottom of the viewport when using stick=bottom. This is useful for bottom-up navigation patterns.
'use client';

import { MouseScrollIcon } from '@phosphor-icons/react';

import {
  Stack,
  StackHeader,
  StackItem,
} from '@/components/stack';
import { Divider } from '@/components/divider';

const StackStickPositionPreview = () => {
  return (
    <div className="relative grid grid-cols-2 gap-8 py-[100vh]">
      <div className="absolute top-[50vh] flex w-full items-center justify-center gap-1 text-foreground-secondary">
        <MouseScrollIcon />
        Scroll down
      </div>
      <div className="absolute bottom-[50vh] flex w-full items-center justify-center gap-1 text-foreground-secondary">
        <MouseScrollIcon />
        Scroll up
      </div>
      <div>
        <Stack stick="top">
          {[1, 2, 3].map((num) => (
            <StackItem key={num} className="bg-background px-4">
              <StackHeader>
                <div className="py-2 font-medium">Section {num}</div>
                <Divider />
              </StackHeader>
              <div className="py-2 text-foreground-secondary">
                This section demonstrates the default top sticky behavior where
                headers stick to the top of the viewport as you scroll down.
              </div>
            </StackItem>
          ))}
        </Stack>
      </div>

      <div>
        <Stack stick="bottom">
          {[1, 2, 3].map((num) => (
            <StackItem key={num} className="bg-background px-4">
              <StackHeader>
                <div className="py-2 font-medium">Section {num}</div>
                <Divider />
              </StackHeader>
              <div className="py-2 text-foreground-secondary">
                This section shows how headers can stick to the bottom of the
                viewport when using stick=bottom. This is useful for bottom-up
                navigation patterns.
              </div>
            </StackItem>
          ))}
        </Stack>
      </div>
    </div>
  );
};

export default StackStickPositionPreview;

Previous

Slot

Next

Accessible Forms