Domphy

stickyScrollReveal

A Scroll block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call stickyScrollReveal() with no arguments for a working demo, or edit the code below live.

Implementation notes

Active section is picked by nearest-title-to-viewport-center (rAF-debounced getBoundingClientRect scan) rather than an IntersectionObserver root-margin band -- simpler, no observer needed, and degenerates sanely under jsdom (all rects 0 -> first item wins deterministically). The panel is N pre-rendered, absolutely-stacked color/content layers whose opacity cross-fades via CSS transition on activeIndex change (a discrete State<number>), matching the spec's 'snap to nearest, then animate the swap' behavior rather than continuous scroll-scrubbing. Panel background color varies by ThemeColor family (primary/success/info/secondary cycle) at a fixed dark dataTone floor.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Sticky Scroll Reveal" — clean-room reimplementation from
// the public behavior/visual spec only (no upstream source viewed or
// copied). A two-column layout: a list of long-form text sections scrolls
// past on the left while a rounded panel on the right stays pinned
// (`position: sticky`) and swaps its background color/content to match
// whichever section is currently nearest the viewport's vertical center.
//
// "Nearest to center" is computed the same `getBoundingClientRect()` +
// rAF-debounced scroll/resize idiom this package already uses for
// scroll-scrubbed effects (`textReveal`, `googleGeminiEffect`) rather than
// an `IntersectionObserver` root-margin band — a plain distance-to-center
// comparison across every title block is simpler to reason about, needs no
// observer polyfill, and (unlike an observer) still yields a sane answer in
// jsdom where every rect defaults to zero (all distances tie at 0, so the
// first item wins deterministically).
//
// The panel itself does NOT scrub continuously — the active index is a
// discrete `State<number>`, and every content layer is pre-rendered
// stacked in the panel with its opacity/pointer-events reactively tied to
// "is this index active", plus a CSS `transition` on `opacity` and
// `background-color`. That is the "snap to nearest section, then animate
// the swap with a short eased transition" pattern the spec calls for,
// implemented as N always-mounted layers cross-fading rather than
// destroying/recreating a single "current" node on every index change.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { heading, paragraph, strong } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSize, themeSpacing } from "@domphy/theme";

export interface StickyScrollContentItem {
  /** Section heading shown in the left column. */
  title: string;
  /** Muted description paragraph beneath the title. */
  description: string;
  /** Content rendered inside the sticky panel while this section is active.
   * Defaults to a large numeral badge. */
  node?: DomphyElement | DomphyElement[];
  /** Theme color family for this item's panel background. Cycles through a
   * default set (`primary`, `success`, `info`, `secondary`) when omitted. */
  color?: ThemeColor;
}

export interface StickyScrollRevealProps {
  /** Sections to scroll through. Defaults to 4 items describing Domphy's own architecture. */
  content?: StickyScrollContentItem[];
  /** Fraction (0–1) of the row's width the sticky panel occupies. Defaults to `0.42`. */
  panelWidthFraction?: number;
  /** Passthrough style merged onto the sticky panel. */
  panelStyle?: StyleObject;
  /** Passthrough style merged onto the outer two-column wrapper. */
  style?: StyleObject;
}

const DEFAULT_COLORS: ThemeColor[] = ["primary", "success", "info", "secondary"];

const DEFAULT_CONTENT: StickyScrollContentItem[] = [
  {
    title: "Reactive by default",
    description:
      "A state read happens inside a listener, so every element that touched it re-renders exactly when it changes — no diffing pass, no virtual DOM.",
  },
  {
    title: "Theme tokens, not magic numbers",
    description:
      "Every color and spacing value routes through themeColor()/themeSpacing(), so the whole surface adapts instantly when the palette or density changes.",
  },
  {
    title: "SSR and hydration, built in",
    description:
      "The same element tree renders on the server and mounts on the client — no separate server/client component split to maintain side by side.",
  },
  {
    title: "Patches over components",
    description:
      "Behavior and style compose through the $ array onto plain elements, so nothing hides behind a wrapper component's own opaque render function.",
  },
];

let stickyScrollRevealInstanceCounter = 0;

/** Big numeral badge used as a section's panel content when no `node` is supplied. */
function defaultPanelNode(index: number): DomphyElement<"div"> {
  return {
    div: [
      {
        strong: String(index + 1),
        $: [strong({ color: "neutral" })],
        style: {
          fontSize: (listener: Listener) => themeSize(listener, "increase-6"),
          // Redeclared alongside `fontSize` (not just left to the `strong()`
          // patch above) so the doctor's `missing-color` check — which only
          // inspects an element's own literal `style` object, not a merged
          // view of its patches — sees a themed `color` next to the other
          // themed prop it flagged.
          color: (listener: Listener) => themeColor(listener, "shift-11"),
        } as StyleObject,
      } as DomphyElement,
    ],
    style: { display: "flex", alignItems: "center", justifyContent: "center", height: "100%" } as StyleObject,
  };
}

/**
 * A two-column scroll section: long-form text scrolls on the left while a
 * pinned panel on the right cross-fades its background/content to match
 * whichever section is nearest the viewport's vertical center. Call with no
 * arguments for a working demo — 4 sections describing Domphy's own design.
 */
function stickyScrollReveal(props: StickyScrollRevealProps = {}): DomphyElement<"div"> {
  const instanceId = ++stickyScrollRevealInstanceCounter;
  const content = props.content && props.content.length > 0 ? props.content : DEFAULT_CONTENT;
  const panelWidthFraction = Math.min(0.7, Math.max(0.25, props.panelWidthFraction ?? 0.42));
  const activeIndex = toState(0, `sticky-scroll-reveal-active-${instanceId}`);

  const titleElements: (HTMLElement | null)[] = content.map(() => null);

  function textSection(item: StickyScrollContentItem, index: number): DomphyElement<"div"> {
    return {
      div: [
        {
          h3: item.title,
          $: [heading()],
          _onMount: (node: ElementNode) => {
            titleElements[index] = node.domElement as HTMLElement;
          },
          _onRemove: () => {
            titleElements[index] = null;
          },
          style: {
            transition: "color 300ms ease, opacity 300ms ease",
            opacity: (listener: Listener) => (activeIndex.get(listener) === index ? 1 : 0.4),
            color: (listener: Listener) => (activeIndex.get(listener) === index ? themeColor(listener, "shift-11") : themeColor(listener, "shift-6")),
          } as StyleObject,
        } as DomphyElement,
        { p: item.description, $: [paragraph()] } as DomphyElement,
      ],
      _key: `sticky-scroll-text-${instanceId}-${index}`,
      style: { minHeight: "60vh", display: "flex", flexDirection: "column", justifyContent: "center" } as StyleObject,
    };
  }

  function panelLayer(item: StickyScrollContentItem, index: number): DomphyElement<"div"> {
    const color = item.color ?? DEFAULT_COLORS[index % DEFAULT_COLORS.length];
    const layerContent = item.node ? (Array.isArray(item.node) ? item.node : [item.node]) : [defaultPanelNode(index)];
    return {
      div: layerContent,
      _key: `sticky-scroll-panel-${instanceId}-${index}`,
      dataTone: "shift-15",
      style: {
        position: "absolute",
        inset: 0,
        borderRadius: themeSpacing(5),
        padding: themeSpacing(6),
        transition: "opacity 400ms ease, background-color 400ms ease",
        opacity: (listener: Listener) => (activeIndex.get(listener) === index ? 1 : 0),
        pointerEvents: (listener: Listener) => (activeIndex.get(listener) === index ? "auto" : "none"),
        backgroundColor: (listener: Listener) => themeColor(listener, "inherit", color),
        color: (listener: Listener) => themeColor(listener, "shift-9", color),
      } as StyleObject,
    };
  }

  return {
    div: [
      {
        div: content.map((item, index) => textSection(item, index)),
        style: { flex: `1 1 ${(1 - panelWidthFraction) * 100}%`, minWidth: 0 } as StyleObject,
      } as DomphyElement<"div">,
      {
        div: [
          {
            div: content.map((item, index) => panelLayer(item, index)),
            style: { position: "relative", height: themeSpacing(120) } as StyleObject,
          } as DomphyElement<"div">,
        ],
        style: {
          flex: `0 0 ${panelWidthFraction * 100}%`,
          position: "sticky",
          insetBlockStart: themeSpacing(8),
          alignSelf: "flex-start",
          ...(props.panelStyle ?? {}),
        } as StyleObject,
      } as DomphyElement<"div">,
    ],
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return;

      let animationFrameHandle: number | null = null;

      function computeActiveIndex(): number {
        const viewportCenter = window.innerHeight / 2;
        let bestIndex = 0;
        let bestDistance = Number.POSITIVE_INFINITY;
        titleElements.forEach((element, index) => {
          if (!element) return;
          const rect = element.getBoundingClientRect();
          const distance = Math.abs(rect.top + rect.height / 2 - viewportCenter);
          if (distance < bestDistance) {
            bestDistance = distance;
            bestIndex = index;
          }
        });
        return bestIndex;
      }

      function scheduleUpdate(): void {
        if (animationFrameHandle !== null) return;
        animationFrameHandle = window.requestAnimationFrame(() => {
          animationFrameHandle = null;
          const nextIndex = computeActiveIndex();
          if (nextIndex !== activeIndex.get()) activeIndex.set(nextIndex);
        });
      }

      scheduleUpdate();
      window.addEventListener("scroll", scheduleUpdate, { passive: true });
      window.addEventListener("resize", scheduleUpdate);

      node.addHook("Remove", () => {
        window.removeEventListener("scroll", scheduleUpdate);
        window.removeEventListener("resize", scheduleUpdate);
        if (animationFrameHandle !== null) window.cancelAnimationFrame(animationFrameHandle);
      });
    },
    style: {
      display: "flex",
      flexWrap: "wrap",
      gap: themeSpacing(10),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { stickyScrollReveal };

← Back to Aceternity UI catalog