Domphy

parallaxHeroImages

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

Implementation notes

Pointer-driven, rAF-lerped per-image transforms (direct DOM style writes, smoothed rather than snapping, resetting to neutral on pointerleave), with 'default' and 'edge-focus' depth-mapping variants across two rows of up to 8 images. The spec's own researchNote says exact per-image depth-factor tiers aren't exposed publicly; implemented two straightforward tiers (close=1x, far=0.35x) mapped to 'edge' vs 'middle' screen position per row, flipped by variant, which is a reasonable documented choice rather than a verified exact match. className/imageClassName map to Domphy's style/imageStyle passthroughs.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Parallax Hero Images" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// hero section with up to 8 screenshot-style images scattered around a
// centered headline that drift at different speeds as the pointer moves,
// producing a layered parallax-depth illusion.
//
// Direct DOM style writes on every animation frame, the same continuous-
// pointer idiom `pixelatedCanvas.ts` uses for its own cursor-driven
// distortion: a `pointermove` listener only updates a *target* offset (raw
// cursor position normalized to the container's center, `[-1, 1]` on each
// axis); a `requestAnimationFrame` loop separately lerps each image's
// *current* offset toward `target * maxOffset * depthFactor` every frame, so
// motion reads as smooth/eased rather than snapping straight to the cursor —
// and, on `pointerleave`, the same loop eases every image back to a neutral
// resting position instead of resetting instantly.
//
// Each of the 8 fixed slots around the edges carries a `positionTier`
// ("edge" for the outermost left/right images in each row, "middle" for the
// two nearer the centered headline). `depthFactorFor()` maps that tier to a
// close/far depth multiplier, flipped by `variant`: `"default"` makes the
// middle-positioned images read as closest (per the spec); `"edge-focus"`
// flips it so the outermost images read as closest instead. The spec itself
// flags the exact per-tier factor values as unexposed in the public docs —
// picking two straightforward tiers (`close`/`far`) is a reasonable,
// documented implementer choice per its own researchNote.

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

export type ParallaxHeroImagesVariant = "default" | "edge-focus";

export interface ParallaxHeroImagesProps {
  /** Up to 8 image URLs placed around the hero's edges. Defaults to 8 generated placeholders. */
  images?: string[];
  /** Depth-mapping mode — which images read as "closest". Defaults to `"default"`. */
  variant?: ParallaxHeroImagesVariant;
  /** Centered headline. Defaults to a short demo line. */
  headline?: string;
  /** Supporting subtext beneath the headline. Defaults to a short demo line. */
  subtitle?: string;
  /** Maximum travel distance, in px, for the closest depth tier at full pointer excursion. Defaults to `40`. */
  maxOffset?: number;
  /** Lerp factor (0-1, higher = snappier) easing each image's displayed offset toward its target every frame. Defaults to `0.12`. */
  smoothing?: number;
  /** Passthrough style merged onto each image wrapper. */
  imageStyle?: StyleObject;
  /** Passthrough style merged onto the outer section. */
  style?: StyleObject;
}

type PositionTier = "edge" | "middle";

interface HeroImageSlot {
  top?: string;
  bottom?: string;
  left?: string;
  right?: string;
  widthPercent: number;
  restRotateDeg: number;
  tier: PositionTier;
}

// Two loose rows of 4, scattered around the edges with generous whitespace
// left for the centered headline — outermost slots per row are "edge",
// the two nearer the middle are "middle".
const SLOTS: HeroImageSlot[] = [
  { top: "4%", left: "2%", widthPercent: 20, restRotateDeg: -6, tier: "edge" },
  { top: "1%", left: "25%", widthPercent: 17, restRotateDeg: 3, tier: "middle" },
  { top: "1%", right: "25%", widthPercent: 17, restRotateDeg: -3, tier: "middle" },
  { top: "4%", right: "2%", widthPercent: 20, restRotateDeg: 6, tier: "edge" },
  { bottom: "5%", left: "4%", widthPercent: 19, restRotateDeg: 5, tier: "edge" },
  { bottom: "1%", left: "27%", widthPercent: 16, restRotateDeg: -4, tier: "middle" },
  { bottom: "1%", right: "27%", widthPercent: 16, restRotateDeg: 4, tier: "middle" },
  { bottom: "5%", right: "4%", widthPercent: 19, restRotateDeg: -5, tier: "edge" },
];

const CLOSE_DEPTH_FACTOR = 1;
const FAR_DEPTH_FACTOR = 0.35;

function depthFactorFor(tier: PositionTier, variant: ParallaxHeroImagesVariant): number {
  if (variant === "edge-focus") return tier === "edge" ? CLOSE_DEPTH_FACTOR : FAR_DEPTH_FACTOR;
  return tier === "middle" ? CLOSE_DEPTH_FACTOR : FAR_DEPTH_FACTOR;
}

function buildDefaultImages(): string[] {
  return Array.from(
    { length: 8 },
    (_unused, index) => `https://picsum.photos/seed/domphy-parallax-hero-${index + 1}/480/320`,
  );
}

/**
 * A hero section with up to 8 screenshot-style images scattered around a
 * centered headline that drift at different speeds as the pointer moves.
 * Call with no arguments for a working demo — 8 generated placeholder
 * mockups around a short demo headline.
 */
function parallaxHeroImages(props: ParallaxHeroImagesProps = {}): DomphyElement<"section"> {
  const images = (props.images && props.images.length > 0 ? props.images : buildDefaultImages()).slice(0, 8);
  const variant = props.variant ?? "default";
  const headlineText = props.headline ?? "Built for teams who ship fast.";
  const subtitleText = props.subtitle ?? "Every screenshot on this page is a live product, not a mockup.";
  const maxOffset = Math.max(0, props.maxOffset ?? 40);
  const smoothing = Math.min(1, Math.max(0.01, props.smoothing ?? 0.12));

  const imageRefs: Array<HTMLElement | null> = new Array(images.length).fill(null);
  const currentX = new Float32Array(images.length);
  const currentY = new Float32Array(images.length);

  function imageWrapper(src: string, index: number): DomphyElement<"div"> {
    const slot = SLOTS[index % SLOTS.length];
    return {
      div: [
        {
          img: null,
          src,
          alt: "",
          loading: "lazy",
          _doctorDisable: "missing-color",
          style: { display: "block", width: "100%", aspectRatio: "3 / 2", objectFit: "cover" } as StyleObject,
        } as DomphyElement,
      ],
      _key: `parallax-hero-image-${index}`,
      _onMount: (node: ElementNode) => {
        imageRefs[index] = node.domElement as HTMLElement;
      },
      _onRemove: () => {
        imageRefs[index] = null;
      },
      style: {
        position: "absolute",
        top: slot.top,
        bottom: slot.bottom,
        left: slot.left,
        right: slot.right,
        width: `${slot.widthPercent}%`,
        overflow: "hidden",
        borderRadius: themeSpacing(3),
        willChange: "transform",
        transform: `rotate(${slot.restRotateDeg}deg)`,
        backgroundColor: (listener) => themeColor(listener, "inherit"),
        color: (listener) => themeColor(listener, "shift-9"),
        outline: (listener) => `1px solid ${themeColor(listener, "shift-4")}`,
        outlineOffset: "-1px",
        boxShadow: (listener) => `0 ${themeSpacing(2)} ${themeSpacing(6)} ${themeColor(listener, "shift-13")}`,
        ...(props.imageStyle ?? {}),
      } as StyleObject,
    };
  }

  const imageElements = images.map((src, index) => imageWrapper(src, index));

  return {
    section: [
      ...imageElements,
      {
        div: [
          { h1: headlineText, $: [heading()] } as DomphyElement,
          { p: subtitleText, $: [paragraph()] } as DomphyElement,
        ],
        style: {
          position: "relative",
          zIndex: 1,
          textAlign: "center",
          maxWidth: themeSpacing(140),
          marginInline: "auto",
        } as StyleObject,
      } as DomphyElement<"div">,
    ],
    dataTone: "shift-1",
    style: {
      position: "relative",
      overflow: "hidden",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      minHeight: themeSpacing(140),
      borderRadius: themeSpacing(4),
      padding: themeSpacing(10),
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined") return;
      const sectionElement = node.domElement as HTMLElement;

      let targetX = 0;
      let targetY = 0;
      let animationFrameId: number | null = null;
      let intersectionObserver: IntersectionObserver | null = null;

      function tick(): 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 section is detached prevents the loop from
        // leaking forever across unrelated later tests.
        if (!sectionElement.isConnected) return;
        let stillMoving = false;
        for (let index = 0; index < imageRefs.length; index += 1) {
          const element = imageRefs[index];
          if (!element) continue;
          const slot = SLOTS[index % SLOTS.length];
          const depthFactor = depthFactorFor(slot.tier, variant);
          const desiredX = targetX * maxOffset * depthFactor;
          const desiredY = targetY * maxOffset * depthFactor;
          currentX[index] += (desiredX - currentX[index]) * smoothing;
          currentY[index] += (desiredY - currentY[index]) * smoothing;
          if (Math.abs(desiredX - currentX[index]) > 0.05 || Math.abs(desiredY - currentY[index]) > 0.05) {
            stillMoving = true;
          }
          element.style.transform = `translate3d(${currentX[index].toFixed(2)}px, ${currentY[index].toFixed(2)}px, 0) rotate(${slot.restRotateDeg}deg)`;
        }
        animationFrameId = stillMoving || targetX !== 0 || targetY !== 0 ? window.requestAnimationFrame(tick) : null;
      }

      function ensureLoopRunning(): void {
        if (animationFrameId === null) animationFrameId = window.requestAnimationFrame(tick);
      }

      function handlePointerMove(event: PointerEvent): void {
        const rect = sectionElement.getBoundingClientRect();
        if (rect.width === 0 || rect.height === 0) return;
        targetX = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
        targetY = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
        ensureLoopRunning();
      }
      function handlePointerLeave(): void {
        targetX = 0;
        targetY = 0;
        ensureLoopRunning();
      }

      sectionElement.addEventListener("pointermove", handlePointerMove);
      sectionElement.addEventListener("pointerleave", handlePointerLeave);

      if (typeof IntersectionObserver === "function") {
        intersectionObserver = new IntersectionObserver((entries) => {
          for (const entry of entries) {
            if (!entry.isIntersecting) {
              targetX = 0;
              targetY = 0;
            }
          }
        });
        intersectionObserver.observe(sectionElement);
      }

      node.addHook("Remove", () => {
        sectionElement.removeEventListener("pointermove", handlePointerMove);
        sectionElement.removeEventListener("pointerleave", handlePointerLeave);
        intersectionObserver?.disconnect();
        if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
      });
    },
  };
}

export { parallaxHeroImages };

← Back to Aceternity UI catalog