Looping images

August 2025

A component that displays a looping sequence of images in a seamless circular pattern.

  • Animated circular movement of images
  • Duplicate first image inside last image container to create a continuous loop

Inspiration: RicoSupply

Example

Square 0
Square 1
Square 2
Square 3
Square 4
Square 5
Square 6
Square 7
Square 0

Code

"use client";

import { useEffect } from "react";
import { motion, useMotionValue, useTransform, animate } from "motion/react";
import Image from "next/image";

export function LoopingImages() {
  const lastIndex = images.length - 1;

  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100 p-4">
      <div className="relative w-[500px] h-[500px]">
        {/* Render all squares except the last one */}
        {Array.from({ length: images.length }).map((_, index) =>
          index === lastIndex ? null : <Square index={index} key={index} />
        )}

        {/* Render the last square with the duplicate first (index 0) square masked inside it */}
        <Square index={lastIndex}>
          <SquareWithOffset index={0} parentIndex={lastIndex} />
        </Square>
      </div>
    </div>
  );
}

function SquareWithOffset({
  index,
  parentIndex,
}: {
  index: number;
  parentIndex: number;
}) {
  const image = images[index];

  // For the specific case of the first square (index 0) inside the last square (index 7),
  // we want to position it at the same place as the original first square would be
  // This creates the illusion of continuity in the circle
  const firstSquareOffset = useMotionValue(0);

  useEffect(() => {
    // Create animation that goes from current value to 1
    const controls = animate(firstSquareOffset, 1, {
      repeat: Infinity,
      repeatType: "loop",
      repeatDelay: 1,
      ease: [0.42, 0, 0.58, 1],
      duration: 7,
    });
    return () => controls.stop();
  }, [firstSquareOffset]);

  // Transform the offset to x and y coordinates relative to the parent square
  const x = useTransform(firstSquareOffset, (offset) => {
    // Calculate the angle for both the first square and the last square
    const firstAngle = ((getPathOffset(index) + offset) % 1) * Math.PI * 2;
    const lastAngle = ((getPathOffset(parentIndex) + offset) % 1) * Math.PI * 2;

    // Calculate the x position difference
    return Math.cos(firstAngle) * 180 - Math.cos(lastAngle) * 180;
  });

  const y = useTransform(firstSquareOffset, (offset) => {
    // Calculate the angle for both the first square and the last square
    const firstAngle = ((getPathOffset(index) + offset) % 1) * Math.PI * 2;
    const lastAngle = ((getPathOffset(parentIndex) + offset) % 1) * Math.PI * 2;

    // Calculate the y position difference
    return Math.sin(firstAngle) * 180 - Math.sin(lastAngle) * 180;
  });

  return (
    <motion.div
      className="absolute inset-0 rounded-lg overflow-clip"
      style={{ x, y }}
    >
      <Image
        src={image}
        alt={`Square ${index}`}
        fill
        sizes="150px"
        priority
        className="object-cover"
        draggable={false}
      />
    </motion.div>
  );
}

function Square({
  index,
  children,
  className,
}: {
  index: number;
  children?: React.ReactNode;
  className?: string;
}) {
  const image = images[index];
  const pathOffset = useMotionValue(getPathOffset(index));

  // Animate the path offset
  useEffect(() => {
    // Create animation that goes from current value to current value + 1
    const controls = animate(pathOffset, pathOffset.get() + 1, {
      repeat: Infinity,
      repeatType: "loop",
      repeatDelay: 1,
      ease: [0.42, 0, 0.58, 1],
      duration: 7,
    });
    return () => controls.stop();
  }, [pathOffset]);

  // Transform the offset to x and y coordinates
  const x = useTransform(pathOffset, (offset) => {
    const angle = (offset % 1) * Math.PI * 2;
    return Math.cos(angle) * 180;
  });

  const y = useTransform(pathOffset, (offset) => {
    const angle = (offset % 1) * Math.PI * 2;
    return Math.sin(angle) * 180;
  });

  return (
    <motion.div
      key={index}
      className={`absolute rounded-lg overflow-clip w-[150px] h-[150px] ${className}`}
      style={{
        width: 150,
        height: 150,
        left: "calc(50% - 75px)",
        top: "calc(50% - 75px)",
        x,
        y,
      }}
      initial={{
        opacity: 0,
        scale: 0.9,
      }}
      animate={{
        opacity: 1,
        scale: 1,
      }}
      transition={{
        opacity: {
          duration: 1,
          delay: index * 0.12 + 0.35,
          ease: "easeOut",
        },
        scale: {
          duration: 1,
          delay: index * 0.12 + 0.35,
          ease: "easeOut",
        },
      }}
    >
      <Image
        src={image}
        alt={`Square ${index}`}
        fill
        sizes="150px"
        priority
        className="object-cover"
        draggable={false}
      />
      <motion.div
        className="absolute inset-0 rounded-lg overflow-clip"
        initial={{
          scale: 1.1,
        }}
        animate={{
          scale: 1,
        }}
        transition={{
          duration: 1,
          delay: index * 0.12 + 0.35,
          ease: "easeOut",
        }}
      >
        {children}
      </motion.div>
    </motion.div>
  );
}

// Helper function to get the path offset for a specific index
function getPathOffset(index: number) {
  return index / 8;
}

// Images for the squares
const images = [
  "https://dr.savee-cdn.com/things/6/7/a4cad53c9caf1b602b1460.webp",
  "https://dr.savee-cdn.com/things/6/6/a916d6265050d2c6615d0b.webp",
  "https://dr.savee-cdn.com/things/6/7/bcd2173c9caf1c3c39dcf9.webp",
  "https://dr.savee-cdn.com/things/6/7/be2b2c3c9caf1c4aecfd74.webp",
  "https://dr.savee-cdn.com/things/6/7/c07c103c9caf1c627865f0.webp",
  "https://dr.savee-cdn.com/things/6/7/bcbca53c9caf1c3b26ec02.webp",
  "https://dr.savee-cdn.com/things/6/7/c0a82c3c9caf1c648015d3.webp",
  "https://dr.savee-cdn.com/things/6/7/bc77763c9caf1c370b34ec.webp",
];