Skip to main content
Back to discovery

Preview supplied by Arlan Marat; live previews were recorded from Arlan's Vault when no published video file was available. Preview: platform recorded · rights cleared.

Image galaxy

Image galaxy is an animated canvas study published directly in the Vault gallery.

VSource LandVault

Why it stands out

Image galaxy is an animated canvas study published directly in the Vault gallery.

Prompt

Build this: a perspective field of small images drifting behind text: each image sits at a random position and a depth (z under CSS perspective), continuously auto-drifts vertically with parallax by depth, grows as it descends and shrinks going up, and wraps infinitely (exits one edge, re-enters the other). Far images are dimmed by a white overlay. Plain DOM transforms, no WebGL.

The complete, self-contained implementation follows, one file per block. It is framework-agnostic core logic — wire it into your own component and mount it on an element.

### image-galaxy/engine.ts
```ts
const lerp = (a: number, b: number, t: number) => (1 - t) * a + t * b;

const CONFIG = {
  speed: 1.1,
  ease: 0.16,
  scaleEase: 0.25,
  scrollMultiplier: 0.9,
  scaleMin: 0.5,
  scaleMax: 1.4,
};

const Z_POOL = [-200, -150, -100, -50, 0, 50, 100, 150, 200];

const SPEED_POOL = [0.55, 0.75, 1, 1.3, 1.6];

export interface GalaxyParticle {
  x: number;
  y: number;
  speed: number;
  z: number;
  opacity: number;
}

export function buildParticles(n: number): GalaxyParticle[] {

  const cols = Math.max(3, Math.round(Math.sqrt(n) * 1.4));
  const left = 8;
  const span = 100 - left * 2;
  const step = cols > 1 ? span / (cols - 1) : 0;
  const rows = Math.ceil(n / cols);

  const slots = Array.from({ length: n }, (_, i) => i).sort(
    () => 0.5 - Math.random(),
  );

  return Array.from({ length: n }, (_, i) => {
    const z = Z_POOL[i % Z_POOL.length];
    const opacity = z < 0 ? Math.max(0.6, 1 + z / 400) : 1;

    const slot = slots[i];
    const col = slot % cols;
    const row = Math.floor(slot / cols);

    const x = left + col * step + (Math.random() - 0.5) * step * 0.5;

    const y = ((row + 0.5) / rows) * 100 + (Math.random() - 0.5) * (100 / rows) * 0.6;

    return {
      x: Math.max(2, Math.min(96, x)),
      y: ((y % 100) + 100) % 100,
      speed: SPEED_POOL[i % SPEED_POOL.length],
      z,
      opacity,
    };
  });
}

interface Node {
  el: HTMLElement;
  overlay: HTMLElement | null;
  speed: number;
  z: number;
  extra: number;
  height: number;
  top: number;
  position: number;
  currentScale: number;
  isBefore: boolean;
  isAfter: boolean;
}

export class ImageGalaxy {
  private container: HTMLElement;
  private nodes: Node[] = [];
  private defs: GalaxyParticle[];

  private scroll = { current: 0, target: 0, last: 0 };
  private directionSign = 1;
  private direction: "up" | "down" = "down";
  private containerHeight = 0;
  private containerOffsetHeight = 0;

  private raf = 0;
  private running = false;
  private disposed = false;
  private lastTime = 0;

  constructor(
    container: HTMLElement,
    images: HTMLElement[],
    overlays: (HTMLElement | null)[],
    defs?: GalaxyParticle[],
  ) {
    this.container = container;
    this.defs = defs ?? buildParticles(images.length);
    this.nodes = images.map((el, i) => ({
      el,
      overlay: overlays[i] ?? null,
      speed: 1,
      z: 0,
      extra: 0,
      height: 0,
      top: 0,
      position: 0,
      currentScale: 1,
      isBefore: false,
      isAfter: false,
    }));
    this.layout();
  }

  layout() {
    const rect = this.container.getBoundingClientRect();
    const small = window.innerWidth < 700;
    this.nodes.forEach((node, i) => {
      const d = this.defs[i];
      if (!d) {
        node.el.style.display = "none";
        return;
      }
      if (node.overlay) node.overlay.style.opacity = `${1 - d.opacity}`;

      const left = small ? (i % 4) * 25 + (d.x % 20) : d.x;
      node.el.style.left = `${left}%`;
      node.el.style.top = `${d.y}%`;
      node.speed = d.speed;
      node.z = d.z;
      node.el.style.transform = "translate3d(0, 0px, 0)";
      const box = node.el.getBoundingClientRect();
      node.extra = 0;
      node.height = box.height;
      node.top = box.top - rect.top;
      node.position = 0;
      node.currentScale = 1;
    });
    this.containerHeight = this.container.clientHeight;
    this.containerOffsetHeight = this.containerHeight * 0.1;
    this.scroll = { current: 0, target: 0, last: 0 };
  }

  addScroll(velocity: number, direction: number) {
    const v = Math.max(-60, Math.min(60, velocity));
    this.scroll.target += v * CONFIG.scrollMultiplier;
    this.direction = direction > 0 ? "down" : "up";
    if (direction !== 0) this.directionSign = Math.sign(direction);
  }

  start() {
    if (this.running || this.disposed) return;
    this.running = true;
    this.lastTime = 0;
    this.raf = requestAnimationFrame(this.tick);
  }

  stop() {
    this.running = false;
    if (this.raf) cancelAnimationFrame(this.raf);
    this.raf = 0;
  }

  private tick = (now: number) => {
    if (!this.running) return;

    const dt = this.lastTime ? Math.min(2, (now - this.lastTime) / 16.667) : 1;
    this.lastTime = now;
    this.update(dt);
    this.raf = requestAnimationFrame(this.tick);
  };

  private update(dt: number) {
    this.scroll.target += CONFIG.speed * dt * this.directionSign;
    this.scroll.current = lerp(this.scroll.current, this.scroll.target, CONFIG.ease);
    this.direction = this.scroll.current < this.scroll.last ? "down" : "up";

    for (const t of this.nodes) {
      t.position = -this.scroll.current * t.speed - t.extra;
      const bottom = t.position + t.top + t.height;
      t.isBefore = bottom < -this.containerOffsetHeight;
      t.isAfter = bottom > this.containerHeight + this.containerOffsetHeight;

      if (this.direction === "up" && t.isBefore) {
        t.extra = t.extra - this.containerHeight - this.containerOffsetHeight;
        t.isBefore = false;
        t.isAfter = false;
      }
      if (this.direction === "down" && t.isAfter) {
        t.extra = t.extra + this.containerHeight;
        t.isBefore = false;
        t.isAfter = false;
      }

      const top = t.position + t.top;
      const f = Math.max(0, Math.min(1, top / this.containerHeight));
      const targetScale = CONFIG.scaleMin + f * (CONFIG.scaleMax - CONFIG.scaleMin);
      t.currentScale = lerp(t.currentScale, targetScale, CONFIG.scaleEase);

      t.el.style.transform = `translate3d(0, ${t.position}px, ${t.z}px) scale(${t.currentScale})`;
    }

    this.scroll.last = this.scroll.current;
  }

  destroy() {
    this.disposed = true;
    this.stop();
    this.nodes = [];
  }
}

```

Discovery vocabulary

Related by governed terms

Continue comparing

More from Vault