Domphy

parallaxScroll

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

Implementation notes

Images are round-robin split into a fixed set of DOM columns (default 3, clamped 1-6), each with alternating up/down translateY driven by the section's own enter/exit scroll fraction, rAF-lerped for a trailing/spring-like feel. Column count reads as responsive via CSS media steps (1 col mobile / 2 tablet / repeat(N) desktop) on the same 3 DOM columns rather than re-bucketing images on resize -- matches the reference's own 'pre-split into literal arrays' approach. Uses direct DOM style writes (not reactive State) for the per-frame transform since there can be dozens of images. onImageClick is supported. No real spring-physics library is used (no dependency added) -- smoothing is a simple exponential rAF lerp, a reasonable approximation of 'spring/damping' per the spec's own wording.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Parallax Scroll" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// responsive image grid split into two or three vertical columns that
// translate up/down at different rates as the page scrolls past the
// section, producing a layered parallax depth effect. Purely a decorative
// photo mosaic — no text overlays, and no click interaction beyond the
// optional `onImageClick` hook.
//
// Images are round-robin distributed into a fixed set of DOM columns (the
// same "pre-split into N literal arrays" idiom the reference component
// uses, rather than re-bucketing the DOM on resize); each column's own
// `translateY` range alternates direction from its neighbors (up/down/up).
// Column *count* still reads as responsive because the grid's CSS
// `grid-template-columns` steps from 1 (mobile) to 2 (tablet) to the full
// column count (desktop) via `@media` — narrower viewports simply stack the
// same column `<div>`s as full-width rows instead of re-splitting `images`.
//
// Scroll progress is this section's own enter/exit fraction — 0 when its
// top reaches the viewport's bottom edge, 1 when its bottom reaches the
// viewport's top edge — the same `getBoundingClientRect()` math this
// package's `googleGeminiEffect`/`heroParallax` use for their own
// scroll-through ribbons/grids. It is rAF-lerped toward that raw target
// (the same smoothing idiom `scrollProgress`/`textReveal` use) so the
// columns lag slightly behind raw scroll instead of snapping 1:1, reading
// as a light spring/damping trail. Each column's DOM node is written to
// directly every frame (`element.style.transform = ...`) rather than routed
// through reactive `State` — this section can hold dozens of images across
// several columns, and a single shared rAF loop writing `transform` in
// place is far cheaper than re-running per-image reactive style functions
// every tick.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { themeColor, themeSpacing } from "@domphy/theme";

export interface ParallaxScrollImage {
  src: string;
  alt?: string;
}

export interface ParallaxScrollProps {
  /** Photos to distribute round-robin across the columns. Defaults to 15 generated placeholders. */
  images?: (string | ParallaxScrollImage)[];
  /** Number of DOM columns. Clamped to 1–6. Defaults to `3` (CSS steps it down
   * to 2 on tablet and 1 on mobile regardless of this value). */
  columns?: number;
  /** Gap between images (within a column) and between columns, in `themeSpacing` units. Defaults to `4`. */
  gap?: number;
  /** CSS `aspect-ratio` every image is cropped to via `object-fit: cover`. Defaults to `"3 / 4"`. */
  aspectRatio?: string;
  /** Maximum `translateY` distance any column travels, in px, at full scroll progress. Defaults to `200`. */
  intensity?: number;
  /** rAF lerp factor (0–1) smoothing the raw scroll target; higher catches up faster. Defaults to `0.15`. */
  smoothing?: number;
  /** Called when an image is clicked/tapped, with the image and its flat index in `images`. */
  onImageClick?: (image: ParallaxScrollImage, index: number) => void;
  /** Passthrough style merged onto the outer section. */
  style?: StyleObject;
}

const DEFAULT_IMAGE_COUNT = 15;

function buildDefaultImages(): ParallaxScrollImage[] {
  return Array.from({ length: DEFAULT_IMAGE_COUNT }, (_unused, index) => ({
    src: `https://picsum.photos/seed/domphy-parallax-${index + 1}/480/640`,
    alt: `Parallax gallery photo ${index + 1}`,
  }));
}

function normalizeImage(image: string | ParallaxScrollImage): ParallaxScrollImage {
  return typeof image === "string" ? { src: image } : image;
}

/** This section's own scroll-through fraction: 0 when its top reaches the viewport's
 * bottom edge, 1 when its bottom reaches the viewport's top edge. */
function computeSectionScrollFraction(element: HTMLElement): number {
  const rect = element.getBoundingClientRect();
  const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 1;
  const totalTravel = rect.height + viewportHeight;
  const traveled = viewportHeight - rect.top;
  const raw = totalTravel > 0 ? traveled / totalTravel : 0;
  return Math.min(1, Math.max(0, raw));
}

interface ColumnRuntime {
  element: HTMLElement | null;
  direction: 1 | -1;
}

/**
 * A responsive, scroll-driven parallax photo mosaic — columns of images that
 * drift up/down at alternating rates as the section scrolls through the
 * viewport, with no click required (an optional `onImageClick` is the only
 * interactive hook). Call with no arguments for a working demo — 15
 * generated placeholder photos across 3 columns.
 */
function parallaxScroll(props: ParallaxScrollProps = {}): DomphyElement<"section"> {
  const images = (props.images && props.images.length > 0 ? props.images : buildDefaultImages()).map(normalizeImage);
  const columnCount = Math.min(6, Math.max(1, Math.round(props.columns ?? 3)));
  const gap = props.gap ?? 4;
  const aspectRatio = props.aspectRatio ?? "3 / 4";
  const intensity = props.intensity ?? 200;
  const smoothing = Math.min(1, Math.max(0.01, props.smoothing ?? 0.15));
  const onImageClick = props.onImageClick;

  const columnImages: ParallaxScrollImage[][] = Array.from({ length: columnCount }, () => []);
  images.forEach((image, index) => columnImages[index % columnCount].push(image));

  const runtimes: ColumnRuntime[] = columnImages.map((_unused, index) => ({
    element: null,
    direction: index % 2 === 0 ? -1 : 1,
  }));

  function imageElement(image: ParallaxScrollImage, columnIndex: number, imageIndex: number): DomphyElement<"img"> {
    const clickable = typeof onImageClick === "function";
    return {
      img: null,
      src: image.src,
      alt: image.alt ?? "",
      loading: "lazy",
      _key: `parallax-image-${columnIndex}-${imageIndex}`,
      // A purely decorative photo tile with no text of its own — exempt from
      // the missing-color contract, same idiom as `iphone.ts`'s bare `<img>`
      // screen media (no themed text ever lives on an `<img>`).
      _doctorDisable: "missing-color",
      ...(clickable ? { onClick: () => onImageClick!(image, columnIndex + imageIndex * columnCount), role: "button", tabindex: 0 } : {}),
      style: {
        display: "block",
        width: "100%",
        aspectRatio,
        objectFit: "cover",
        borderRadius: themeSpacing(3),
        boxShadow: (listener: Listener) => `0 ${themeSpacing(2)} ${themeSpacing(6)} ${themeColor(listener, "shift-17")}`,
        ...(clickable ? { cursor: "pointer" } : {}),
      } as StyleObject,
    } as DomphyElement<"img">;
  }

  function columnElement(columnIndex: number): DomphyElement<"div"> {
    return {
      div: columnImages[columnIndex].map((image, imageIndex) => imageElement(image, columnIndex, imageIndex)),
      _key: `parallax-column-${columnIndex}`,
      _onMount: (node: ElementNode) => {
        runtimes[columnIndex].element = node.domElement as HTMLElement;
      },
      _onRemove: () => {
        runtimes[columnIndex].element = null;
      },
      style: {
        display: "flex",
        flexDirection: "column",
        gap: themeSpacing(gap),
        willChange: "transform",
      } as StyleObject,
    };
  }

  return {
    section: [
      {
        div: Array.from({ length: columnCount }, (_unused, index) => columnElement(index)),
        style: {
          display: "grid",
          gridTemplateColumns: "1fr",
          gap: themeSpacing(gap),
          "@media (min-width: 40em)": {
            gridTemplateColumns: columnCount >= 2 ? "repeat(2, 1fr)" : "1fr",
          },
          "@media (min-width: 64em)": {
            gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
          },
        } as StyleObject,
      } as DomphyElement<"div">,
    ],
    dataTone: "shift-16",
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return;
      const sectionElement = node.domElement as HTMLElement;

      let currentProgress = computeSectionScrollFraction(sectionElement);
      let targetProgress = currentProgress;
      let isAnimating = false;
      let animationFrameHandle = 0;

      function paint(progress: number): void {
        for (const runtime of runtimes) {
          if (!runtime.element) continue;
          const offset = runtime.direction * intensity * progress;
          runtime.element.style.transform = `translateY(${offset.toFixed(1)}px)`;
        }
      }
      paint(currentProgress);

      function step(): void {
        // Belt-and-suspenders stop condition: some hosts (e.g. a test harness
        // that wipes the DOM directly instead of going through the framework's
        // removal lifecycle) never fire the "Remove" hook below. Bailing here
        // once the node is detached prevents the window scroll/resize
        // listeners from resurrecting this loop forever.
        if (!sectionElement.isConnected) return;
        currentProgress += (targetProgress - currentProgress) * smoothing;
        if (Math.abs(targetProgress - currentProgress) < 0.001) {
          currentProgress = targetProgress;
          paint(currentProgress);
          isAnimating = false;
          return;
        }
        paint(currentProgress);
        animationFrameHandle = window.requestAnimationFrame(step);
      }

      function handleScroll(): void {
        targetProgress = computeSectionScrollFraction(sectionElement);
        if (!isAnimating) {
          isAnimating = true;
          animationFrameHandle = window.requestAnimationFrame(step);
        }
      }

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

      node.addHook("Remove", () => {
        window.removeEventListener("scroll", handleScroll);
        window.removeEventListener("resize", handleScroll);
        if (animationFrameHandle) window.cancelAnimationFrame(animationFrameHandle);
      });
    },
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      padding: themeSpacing(8),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { parallaxScroll };

← Back to Aceternity UI catalog