← Easeout Lab
Free component

Curved image wall

Screens on a 3D ring. Drag or swipe to spin it with momentum, use the arrow keys to step, and it drifts on its own when you let go.

Frontwell homepage, redesigned
Parley homepage, redesigned
Frontwell homepage, before the redesign
Parley homepage, before the redesign
Frontwell homepage, redesigned
Parley homepage, redesigned
Frontwell homepage, before the redesign
Parley homepage, before the redesign
npx shadcn addhttps://easeoutstudio.com/r/curved-wall.json

Or copy the file below. It needspnpm add motion

components/curved-wall.tsx
"use client"

// Curved image wall by Easeout (easeoutstudio.com). Free to use, MIT.
// Install: npx shadcn@latest add https://easeoutstudio.com/r/curved-wall.json
// One file. React + motion/react + Tailwind CSS. Drag (or swipe) to spin; it keeps momentum, then drifts.
// Arrow keys step one card at a time. Pauses while hovered, off screen or in a hidden tab, and stays still
// for people who prefer reduced motion. The soft edges are a mask, so it looks right on any background.
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"
import { useEffect, useRef, useState } from "react"

type Props = {
  images: { src: string; alt: string }[]
  cardWidth?: number // px, the largest a card gets; it shrinks to fit narrow containers
  speed?: number // degrees per second while idle
  label?: string // what the wall shows, for screen readers
}

const EASE = [0.16, 1, 0.3, 1] as const

export function CurvedWall({ images, cardWidth = 420, speed = 6, label = "Image wall" }: Props) {
  const box = useRef<HTMLDivElement>(null)
  const [width, setWidth] = useState(cardWidth)
  const n = images.length
  const step = 360 / n
  const radius = Math.round(width / (2 * Math.tan(Math.PI / n)))
  const angle = useMotionValue(0)
  const transform = useTransform(angle, (a) => `translateZ(${-radius}px) rotateY(${a}deg)`)
  const reduce = useReducedMotion()
  const busy = useRef(false) // dragging or animating: the drift waits
  const paused = useRef(false) // hovered, off screen or tab hidden

  // cards fit the container: never wider than ~58% of it
  useEffect(() => {
    const el = box.current
    if (!el) return
    const ro = new ResizeObserver(([e]) => setWidth(Math.min(cardWidth, Math.round(e.contentRect.width * 0.58))))
    ro.observe(el)
    return () => ro.disconnect()
  }, [cardWidth])

  // idle drift, only while visible
  useEffect(() => {
    if (reduce) return
    const el = box.current
    let visible = true
    const io = new IntersectionObserver(([e]) => (visible = e.isIntersecting))
    if (el) io.observe(el)
    let raf = 0
    let last = performance.now()
    const tick = (now: number) => {
      const dt = Math.min(64, now - last) / 1000
      last = now
      if (visible && !busy.current && !paused.current && document.visibilityState === "visible") angle.set(angle.get() - dt * speed)
      raf = requestAnimationFrame(tick)
    }
    raf = requestAnimationFrame(tick)
    return () => {
      cancelAnimationFrame(raf)
      io.disconnect()
    }
  }, [angle, speed, reduce])

  // ease to the nearest card face (keyboard), then hand back to the drift
  const go = (dir: number) => {
    busy.current = true
    const target = Math.round(angle.get() / step) * step + dir * step
    animate(angle, target, reduce ? { duration: 0 } : { type: "spring", stiffness: 140, damping: 22 }).then(() => (busy.current = false))
  }

  return (
    <div
      ref={box}
      role="group"
      aria-roledescription="carousel"
      aria-label={`${label}. Drag or use the arrow keys to spin.`}
      tabIndex={0}
      onKeyDown={(e) => {
        if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return
        e.preventDefault()
        go(e.key === "ArrowLeft" ? 1 : -1)
      }}
      onPointerEnter={(e) => e.pointerType === "mouse" && (paused.current = true)}
      onPointerLeave={() => (paused.current = false)}
      className="relative w-full rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2"
      style={{ height: `min(60vh, ${Math.round(width * 1.15)}px)` }}
    >
      {/* the stage: perspective for the 3D ring, and a mask for the soft edges (so the focus ring above stays crisp) */}
      <div
        className="absolute inset-0 cursor-grab touch-pan-y select-none overflow-hidden rounded-[inherit] active:cursor-grabbing"
        style={{
          perspective: 1600,
          maskImage: "linear-gradient(90deg, transparent, #000 18%, #000 82%, transparent)",
          WebkitMaskImage: "linear-gradient(90deg, transparent, #000 18%, #000 82%, transparent)",
        }}
      >
        <motion.div
          className="absolute inset-0"
          onPanStart={() => {
            busy.current = true
            angle.stop()
          }}
          onPan={(_, info) => angle.set(angle.get() + info.delta.x * 0.18)}
          onPanEnd={(_, info) => {
            // fling with momentum (capped), then hand back to the idle drift
            const fling = reduce ? 0 : Math.max(-120, Math.min(120, info.velocity.x * 0.04))
            animate(angle, angle.get() + fling, { duration: reduce ? 0 : 1.2, ease: EASE }).then(() => (busy.current = false))
          }}
        >
          <motion.div className="absolute left-1/2 top-1/2" style={{ transformStyle: "preserve-3d", transform, width: 0, height: 0 }}>
            {images.map((img, i) => (
              <figure
                key={`${img.src}-${i}`}
                className="absolute m-0 overflow-hidden rounded-2xl bg-neutral-900 shadow-[0_30px_60px_-30px_rgba(0,0,0,.55)] ring-1 ring-white/10"
                style={{
                  width,
                  height: width * 0.625,
                  left: -width / 2,
                  top: (-width * 0.625) / 2,
                  transform: `rotateY(${i * step}deg) translateZ(${radius}px)`,
                  backfaceVisibility: "hidden",
                }}
              >
                {/* eslint-disable-next-line @next/next/no-img-element -- copy-paste friendly: a plain img works in any React app */}
                <img src={img.src} alt={img.alt} draggable={false} loading="lazy" decoding="async" className="h-full w-full object-cover object-top" />
              </figure>
            ))}
          </motion.div>
        </motion.div>
      </div>
    </div>
  )
}

Want a whole page built like this?