# Image Parallax

> Pictures drift behind their frames, adding depth to every scroll.

Image Parallax is an image animation ready for your AI assistant, React or plain JavaScript, with no dependencies. Also called parallax or scroll parallax.

While the page scrolls, the image inside a frame slides a little slower than everything around it, as if seen through a window. Its edges never show, the work stops offscreen, and reduced motion keeps the picture still.

- Page: https://foundry44.dev/components/image-parallax/
- Category: [Images](https://foundry44.dev/library/images/)
- Interactions: scroll
- Dependencies: none
- Licence: [MIT, free for any project](https://foundry44.dev/license/)

## Settings

| Attribute | Default | What it is |
| --- | --- | --- |
| `data-f44-speed` | `0.1` | How far the image drifts, as a fraction of the frame size on each side. Negative drifts the other way. |
| `data-f44-smoothing` | `0.2` | How closely the image follows the scroll each frame, 0–1. 1 is locked to it, lower glides behind. |
| `data-f44-axis` | `y` | Drift direction: "y" moves the image vertically, "x" sideways while the page scrolls down. |

## AI prompt

# Foundry44 · Image Parallax

Image moves slower than its frame while scrolling.

Add this component to my project. Pick the setup below that matches my project and follow only that one. When it uses the code at the end, keep its logic as written (it is self-contained, with no dependencies) and adapt only the integration: JavaScript instead of TypeScript if the project has none, file names, imports, how it starts.

## Markup it expects

The element marked `data-f44="image-parallax"`, with this structure inside. Texts, images and the number of items are the demo's: use mine. The `data-f44-*` attributes set options for the ready-made script only; with the code, pass the same options to `imageParallax()` or as props.

```html
<div data-f44="image-parallax" data-f44-speed="0.12">
  <img src="image-1.jpg" alt="Cliffs over the sea at golden hour">
</div>
```

## Options

| Option | Attribute | Type | Default | Description |
|---|---|---|---|---|
| `speed` | `data-f44-speed` | `number` | `0.1` | How far the image drifts, as a fraction of the frame size on each side. Negative drifts the other way. |
| `smoothing` | `data-f44-smoothing` | `number` | `0.2` | How closely the image follows the scroll each frame, 0–1. 1 is locked to it, lower glides behind. |
| `axis` | `data-f44-axis` | `"x" | "y"` | `y` | Drift direction: "y" moves the image vertically, "x" sideways while the page scrolls down. |

## Setup

- **Plain HTML, no build step**: don't paste the code below. Load the ready-made script once, at the end of `<body>`: `<script src="https://foundry44.dev/demos/image-parallax/image-parallax.js" defer></script>` (or download that file and serve it with the page). It starts itself on every `data-f44="image-parallax"` element and reads the `data-f44-*` attributes above.
- **React / Next.js with shadcn/ui** (the project has a `components.json`; otherwise, see the next line): `npx shadcn@latest add https://foundry44.dev/r/image-parallax.json` writes `lib/image-parallax-core.ts` and `components/ui/image-parallax.tsx`, then use `<ImageParallax />` with the options as props.
- **React / Next.js from npm** (no shadcn/ui, or the user prefers a package): `npm install @foundry44/react` (or the project's package manager), then `import { ImageParallax } from "@foundry44/react"` with the options as props. With a bundler and no React: `@foundry44/core`, then `import { imageParallax } from "@foundry44/core/image-parallax"`.
- **React / Next.js by hand**: save the code below as `lib/image-parallax-core.ts` and import it from there (`@/lib/image-parallax-core` where that alias exists). Don't name it `image-parallax.ts` next to a `ImageParallax.tsx`: on macOS and Windows the two names collide. In a component marked `"use client"`, call `imageParallax(ref.current, options)` in a `useEffect` and return `instance.destroy` as its cleanup; call `instance.update(options)` when options change.
- **Any other stack**: create on mount with `imageParallax(element, options)`, `update(options)` when they change, `destroy()` on unmount.

Only the ready-made script finds `data-f44` elements and starts them by itself. Pasted code does not: call `imageParallax(element)` on each element.

## Pitfalls

- Respects prefers-reduced-motion: keep that behaviour.
- The image must be the element's first child: the element is the frame and gets `overflow: hidden`.
- The image is enlarged by twice `speed` to hide its edges (1.2× by default): use a source with enough resolution.
- Size the image to fill the frame, e.g. `width: 100%; height: 100%; object-fit: cover`.
- With reduced motion the image stays still, at its normal size.

## Code: `image-parallax-core.ts`

```ts
// Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT)
/**
 * Foundry44 — image-parallax
 * The image inside a frame drifts more slowly than the page while you scroll,
 * so the frame seems to be a window onto something further away. The image is
 * enlarged just enough that its edges never show inside the frame.
 *
 * Self-contained: no imports, so this file can be copied as-is.
 */

export interface ImageParallaxOptions {
  /** How far the image drifts, as a fraction of the frame size on each side. Negative drifts the other way. @range -0.5 0.5 0.01 */
  speed?: number;
  /** How closely the image follows the scroll each frame, 0–1. 1 is locked to it, lower glides behind. @range 0.02 1 0.01 */
  smoothing?: number;
  /** Drift direction: "y" moves the image vertically, "x" sideways while the page scrolls down. */
  axis?: "x" | "y";
}

export interface ImageParallaxInstance {
  update(options: ImageParallaxOptions): void;
  destroy(): void;
}

export const imageParallaxDefaults: Required<ImageParallaxOptions> = {
  speed: 0.1,
  smoothing: 0.2,
  axis: "y",
};

const REST = 0.05;

export function imageParallax(el: HTMLElement, options: ImageParallaxOptions = {}): ImageParallaxInstance {
  let opts = { ...imageParallaxDefaults, ...options };

  const noop: ImageParallaxInstance = { update() {}, destroy() {} };
  if (typeof window === "undefined") return noop;
  // The effect is pure movement: with reduced motion the image just stays still.
  if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return noop;
  const image = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : null;
  if (!image) return noop;

  const original = {
    overflow: el.style.overflow,
    transform: image.style.transform,
    willChange: image.style.willChange,
  };
  el.style.overflow = "hidden";
  image.style.willChange = "transform";

  let current = 0;
  let target = 0;
  let frame = 0;
  let observer: IntersectionObserver | null = null;

  // -1 when the frame's top meets the bottom of the viewport, 1 when its bottom meets the top.
  const measure = () => {
    const rect = el.getBoundingClientRect();
    const viewport = window.innerHeight || 1;
    const travel = (viewport + rect.height) / 2 || 1;
    const progress = (viewport / 2 - (rect.top + rect.height / 2)) / travel;
    const size = opts.axis === "x" ? rect.width : rect.height;
    target = Math.min(Math.max(progress, -1), 1) * opts.speed * size;
  };

  const render = () => {
    // Scaled from the center by twice the drift, so neither edge ever comes into the frame.
    const scale = 1 + 2 * Math.abs(opts.speed);
    const offset = opts.axis === "x" ? `${current}px, 0` : `0, ${current}px`;
    image.style.transform = `translate3d(${offset}, 0) scale(${scale})`;
  };

  const tick = () => {
    const amount = Math.min(Math.max(opts.smoothing, 0.01), 1);
    current += (target - current) * amount;
    if (Math.abs(target - current) < REST) {
      current = target;
      render();
      frame = 0;
      return;
    }
    render();
    frame = requestAnimationFrame(tick);
  };

  const onScroll = () => {
    measure();
    if (!frame) frame = requestAnimationFrame(tick);
  };

  const listen = () => {
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll, { passive: true });
    onScroll();
  };

  const unlisten = () => {
    window.removeEventListener("scroll", onScroll);
    window.removeEventListener("resize", onScroll);
    if (frame) cancelAnimationFrame(frame);
    frame = 0;
  };

  // Start at the right offset, so the first scroll doesn't make the image jump.
  measure();
  current = target;
  render();

  if (typeof IntersectionObserver === "undefined") {
    listen();
  } else {
    // Scroll work only while the frame is on screen.
    observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) listen();
        else unlisten();
      }
    });
    observer.observe(el);
  }

  return {
    update(next) {
      opts = { ...opts, ...next };
      measure();
      current = target;
      render();
    },
    destroy() {
      observer?.disconnect();
      unlisten();
      el.style.overflow = original.overflow;
      image.style.transform = original.transform;
      image.style.willChange = original.willChange;
    },
  };
}
```
