Skip to content
jswnthui

Motion

Image Carousel

An endless filmstrip of project sketches: the middle card tilts toward the cursor, and a square window following it shows the finished site underneath.

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/image-carousel.tsx
    'use client'
    
    import { useEffect, useRef, useState, type PointerEvent } from 'react'
    import gsap from 'gsap'
    
    export const IMAGES = [
      {
        src: '/kine/sketch/kerna-sketch-web-design-kuwait.webp',
        alt: 'Kerna project sketch — web design Kuwait',
        reveal: '/kine/works/kerna-final-screenshot-web-design-kuwait.webp',
        revealAlt: 'Kerna project final screenshot — web design Kuwait',
        bg: '/kine/bg/kerna-background-web-design-kuwait.webp',
        bgAlt: 'Kerna project background — web design Kuwait',
      },
      {
        src: '/kine/sketch/morni-sketch-web-design-kuwait.webp',
        alt: 'Morni project sketch — web design Kuwait',
        reveal: '/kine/works/morni-final-screenshot-web-design-kuwait.webp',
        revealAlt: 'Morni project final screenshot — web design Kuwait',
        bg: '/kine/bg/morni-background-web-design-kuwait.webp',
        bgAlt: 'Morni project background — web design Kuwait',
      },
      {
        src: '/kine/sketch/sands-souls-sketch-web-design-kuwait.webp',
        alt: 'Sand & Souls project sketch — web design Kuwait',
        reveal: '/kine/works/sands-souls-final-screenshot-web-design-kuwait.webp',
        revealAlt: 'Sand & Souls project final screenshot — web design Kuwait',
        bg: '/kine/bg/sands-souls-background-web-design-kuwait.webp',
        bgAlt: 'Sand & Souls project background — web design Kuwait',
      },
    ]
    
    // The reveal/cursor square is sized relative to the center image itself —
    // its side is sqrt(0.5) (~70.7%) of the card's shorter side, so the square's
    // *area* covers 50% of the image — rather than a fixed pixel value, so it
    // scales naturally with the card across breakpoints instead of looking
    // oversized on narrow viewports or tiny on wide ones.
    const REVEAL_SIZE_RATIO = Math.SQRT1_2
    const REVEAL_RADIUS_RATIO = 0.125
    
    const N = IMAGES.length
    // Only center + one immediate neighbor on each side are ever meant to be
    // seen — exactly 3 images visible at once. One extra ring is still rendered
    // beyond that (RENDER_RADIUS), but SLOT_WIDTH is wide enough that it sits fully
    // outside the clipped track, invisible — it only exists so the slide that's
    // about to exit does so already off-screen instead of popping at the edge.
    const VISIBLE_RADIUS = 1
    const RENDER_RADIUS = VISIBLE_RADIUS + 1
    // A neighbor's own center lands exactly on the track's edge, so only its
    // inner half (facing the center card) stays visible — the rest is clipped.
    const SLOT_WIDTH = 50
    
    interface ImageCarouselProps {
      /** Fired with the IMAGES index of whichever slide is currently centered. */
      onCenterChange?: (index: number) => void
      /** False for decorative copies rendered elsewhere on the page — a scaled
       * preview tile, say — that exist purely to be looked at, never touched.
       * Without this, the center
       * card's own `pointer-events: auto` (needed for the real hero carousel's
       * hover effect) always wins over any ancestor's `pointer-events: none`,
       * since an element's own explicit value overrides an inherited one —
       * meaning a decorative copy sitting at the same screen position as the
       * real carousel could silently steal its hover, intermittently, only in
       * the exact area the phantom center card occupies. Defaults to true so
       * the real hero usage is unaffected. */
      interactive?: boolean
    }
    
    export default function ImageCarousel({ onCenterChange, interactive = true }: ImageCarouselProps) {
      // `step` is a plain, unbounded integer — one click = ±1, forever. Each
      // render only generates the small window of slides actually needed
      // (step - RENDER_RADIUS .. step + RENDER_RADIUS), so nothing ever has to
      // "wrap" or jump: a real slide already exists at every position a click
      // could reveal, sliding in continuously like a real infinite filmstrip —
      // [img3][img2][img1 center][img3][img2] and so on in both directions.
      const [step, setStep] = useState(0)
      // `outerRef` — the un-rotated slot wrapper (only ever translated) — is
      // the source of truth for mouse-relative geometry. `cardRef` (the tilt
      // target) gets rotated by GSAP, and getBoundingClientRect() on a rotated
      // element returns its projected screen-space box, which shrinks/shifts as
      // the rotation changes. Computing local mouse coordinates from THAT box
      // fed back into a clip-path that lives in the rotated element's own local
      // (unrotated) coordinate space is a mismatch — it's what caused the reveal
      // window to drift and stick near the corners as the tilt increased.
      const outerRef = useRef<HTMLDivElement>(null)
      const cardRef = useRef<HTMLDivElement>(null)
      const prevCenterElRef = useRef<HTMLDivElement | null>(null)
      const tiltX = useRef<gsap.QuickToFunc | null>(null)
      const tiltY = useRef<gsap.QuickToFunc | null>(null)
      const cursorRef = useRef<HTMLDivElement>(null)
      const cursorX = useRef<gsap.QuickToFunc | null>(null)
      const cursorY = useRef<gsap.QuickToFunc | null>(null)
      const hoverWrapRef = useRef<HTMLDivElement>(null)
      // Plain proxy object (not a DOM node) — the reveal-window position is
      // tweened on this, then applied to whatever the current hoverWrapRef is
      // inside onUpdate. Because it's decoupled from any specific element, it
      // never goes stale across a center-image change the way a quickTo bound
      // directly to a DOM node would (see the tilt reset effect below).
      const revealPos = useRef({ x: 0, y: 0 })
      const revealX = useRef<gsap.QuickToFunc | null>(null)
      const revealY = useRef<gsap.QuickToFunc | null>(null)
    
      const updateRevealClip = () => {
        const el = hoverWrapRef.current
        const outer = outerRef.current
        if (!el || !outer) return
        const { width, height } = outer.getBoundingClientRect()
        const size = Math.min(width, height) * REVEAL_SIZE_RATIO
        const half = size / 2
        const radius = size * REVEAL_RADIUS_RATIO
        const { x, y } = revealPos.current
        el.style.clipPath = `inset(${y - half}px ${width - x - half}px ${height - y - half}px ${x - half}px round ${radius}px)`
      }
    
      // Cursor square mirrors the reveal window's own size/radius so the two
      // stay visually identical — both derived from the same card dimensions.
      const syncCursorSize = () => {
        const outer = outerRef.current
        const cursor = cursorRef.current
        if (!outer || !cursor) return
        const { width, height } = outer.getBoundingClientRect()
        const size = Math.min(width, height) * REVEAL_SIZE_RATIO
        cursor.style.width = `${size}px`
        cursor.style.height = `${size}px`
        cursor.style.borderRadius = `${size * REVEAL_RADIUS_RATIO}px`
      }
    
      const goPrev = () => setStep((s) => s - 1)
      const goNext = () => setStep((s) => s + 1)
    
      useEffect(() => {
        onCenterChange?.(((step % N) + N) % N)
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [step])
    
      // Establishes centering (xPercent/yPercent) and the hidden starting state
      // entirely inside GSAP's own tracked transform — every quickTo/set call
      // below only touches x/y/scale/opacity, but GSAP recomposes the *whole*
      // transform string from all of its tracked properties each time, so
      // nothing here gets silently dropped once movement starts.
      useEffect(() => {
        if (!cursorRef.current) return
        gsap.set(cursorRef.current, { xPercent: -50, yPercent: -50, scale: 0, opacity: 0 })
      }, [])
    
      // The center card is a different DOM node after every click (each slide
      // keeps its own identity/key — see the filmstrip note above), but the
      // quickTo functions above are bound to whichever element they were first
      // created on. Without this, tilt would stay stuck on the original center
      // image forever. On every step change: snap the outgoing card's rotation
      // back to flat (it may still be mid-tilt if the slide changed via a nav
      // click rather than the mouse actually leaving it) and drop the cached
      // quickTo functions so the next hover rebinds them to the new center card.
      useEffect(() => {
        if (prevCenterElRef.current) {
          gsap.set(prevCenterElRef.current, { rotationX: 0, rotationY: 0 })
        }
        tiltX.current = null
        tiltY.current = null
        prevCenterElRef.current = cardRef.current
    
        // A nav-click swap has the same "mouse never actually left" problem for
        // the shared custom cursor square: if it was mid-hover (visible, scaled
        // in) when the arrow was clicked, no pointerleave fires to hide it, so
        // it's left floating over whatever card is now under it. Force it back
        // to hidden here too, same reasoning as the tilt reset above.
        if (cursorRef.current) {
          gsap.killTweensOf(cursorRef.current)
          gsap.set(cursorRef.current, { scale: 0, opacity: 0 })
        }
      }, [step])
    
      // Tilt-card: the corner the cursor is nearest to reads as "pressed" away
      // from the viewer — cursor top-right -> rotateX negative (top tips back)
      // + rotateY positive (right tips back), so that corner recedes/pushes down.
      // Also drags the custom square cursor along, in real viewport coordinates
      // (the cursor is fixed-positioned outside the track, unaffected by its
      // own tilt/position transforms).
      const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
        const el = cardRef.current
        const outer = outerRef.current
        if (!el || !outer) return
        const rect = outer.getBoundingClientRect()
        const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
        const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1
    
        if (!tiltX.current) tiltX.current = gsap.quickTo(el, 'rotationX', { duration: 0.5, ease: 'power3.out' })
        if (!tiltY.current) tiltY.current = gsap.quickTo(el, 'rotationY', { duration: 0.5, ease: 'power3.out' })
        tiltX.current(-ny * 14)
        tiltY.current(nx * 18)
    
        if (cursorRef.current) {
          if (!cursorX.current) cursorX.current = gsap.quickTo(cursorRef.current, 'x', { duration: 0.25, ease: 'power3.out' })
          if (!cursorY.current) cursorY.current = gsap.quickTo(cursorRef.current, 'y', { duration: 0.25, ease: 'power3.out' })
          cursorX.current(e.clientX)
          cursorY.current(e.clientY)
        }
    
        const localX = e.clientX - rect.left
        const localY = e.clientY - rect.top
        if (!revealX.current) revealX.current = gsap.quickTo(revealPos.current, 'x', { duration: 0.2, ease: 'power3.out', onUpdate: updateRevealClip })
        if (!revealY.current) revealY.current = gsap.quickTo(revealPos.current, 'y', { duration: 0.2, ease: 'power3.out', onUpdate: updateRevealClip })
        revealX.current(localX)
        revealY.current(localY)
      }
    
      const handlePointerEnter = (e: PointerEvent<HTMLDivElement>) => {
        syncCursorSize()
        if (cursorRef.current) {
          gsap.set(cursorRef.current, { x: e.clientX, y: e.clientY })
          gsap.to(cursorRef.current, { scale: 1, opacity: 1, duration: 0.45, ease: 'power3.out' })
        }
    
        // Snap the reveal window straight to the entry point (no tween-in from
        // wherever it last was) so it doesn't visibly sweep in from a corner
        // before the fade even starts. Mutates the existing proxy object in
        // place rather than replacing it — the cached quickTo functions below
        // are bound to this exact object reference (created once, on the first
        // ever hover); reassigning `revealPos.current` to a new object would
        // silently detach them, leaving the reveal square frozen from the
        // second hover onward.
        const outer = outerRef.current
        if (outer) {
          const rect = outer.getBoundingClientRect()
          revealPos.current.x = e.clientX - rect.left
          revealPos.current.y = e.clientY - rect.top
          updateRevealClip()
        }
        if (hoverWrapRef.current) {
          gsap.to(hoverWrapRef.current, { opacity: 1, duration: 0.45, ease: 'power3.out' })
        }
      }
    
      const handlePointerLeave = () => {
        tiltX.current?.(0)
        tiltY.current?.(0)
        if (cursorRef.current) {
          gsap.to(cursorRef.current, { scale: 0, opacity: 0, duration: 0.6, ease: 'power2.inOut' })
        }
        if (hoverWrapRef.current) {
          gsap.to(hoverWrapRef.current, { opacity: 0, duration: 0.6, ease: 'power2.inOut' })
        }
      }
    
      const slots = Array.from({ length: RENDER_RADIUS * 2 + 1 }, (_, idx) => step - RENDER_RADIUS + idx)
    
      return (
        <div className="relative w-full">
          {/* Sized in container units, against its own width rather than the
              viewport's, so it fits any container; full-width, the two agree.
              Only the track and buttons sit in the query container: containment
              makes a container the containing block for fixed children, and the
              cursor below has to stay fixed to the viewport. */}
          <div className="@container relative w-full">
            <div
              className="relative h-[46cqw] w-full overflow-hidden @min-[640px]:h-[30cqw] @min-[768px]:h-[26cqw]"
              style={{ perspective: 1400 }}
            >
              {slots.map((k) => {
                const image = IMAGES[((k % N) + N) % N]
                const isCenter = k === step
                const relative = k - step
                const translateCqw = relative * SLOT_WIDTH
    
                return (
                  // Outer: slot position only (React-driven, animates on `step`
                  // change). Inner: tilt only (GSAP-driven). Kept on separate
                  // elements — GSAP's rotationX/Y quickTo rewrites the whole
                  // `transform` string on whatever element it targets, so it must
                  // never share an element with this translate/slot positioning.
                  <div
                    key={k}
                    ref={isCenter ? outerRef : undefined}
                    className={`absolute left-1/2 top-1/2 aspect-video transition-transform duration-700 ease-out ${
                      isCenter ? 'w-[82cqw] @min-[640px]:w-[60cqw] @min-[768px]:w-[44cqw]' : 'w-[54cqw] @min-[640px]:w-[38cqw] @min-[768px]:w-[28cqw]'
                    }`}
                    style={{
                      transform: `translate(calc(-50% + ${translateCqw}cqw), -50%)`,
                      zIndex: isCenter ? 30 : 20 - Math.abs(relative),
                    }}
                  >
                    <div
                      ref={isCenter ? cardRef : undefined}
                      onPointerEnter={interactive && isCenter ? handlePointerEnter : undefined}
                      onPointerMove={interactive && isCenter ? handlePointerMove : undefined}
                      onPointerLeave={interactive && isCenter ? handlePointerLeave : undefined}
                      className={`relative h-full w-full overflow-hidden rounded-2xl ${interactive && isCenter ? 'cursor-none' : ''}`}
                      style={{
                        transformStyle: 'preserve-3d',
                        pointerEvents: interactive && isCenter ? 'auto' : 'none',
                      }}
                    >
                      <img
                        src={image.src}
                        alt={image.alt}
                        loading={Math.abs(k - step) > 1 ? 'lazy' : 'eager'}
                        draggable={false}
                        className="absolute inset-0 size-full object-cover"
                      />
    
                      {isCenter && (
                        // The "finished project" preview, revealed only inside a
                        // square window that tracks the custom cursor — same idea
                        // as the old hero's fluid-cursor reveal lens, just driven
                        // by clip-path instead of a shader mask.
                        <div ref={hoverWrapRef} className="pointer-events-none absolute inset-0 opacity-0">
                          <img
                            src={image.reveal}
                            alt={image.revealAlt}
                            draggable={false}
                            className="absolute inset-0 size-full object-cover"
                          />
                        </div>
                      )}
                    </div>
                  </div>
                )
              })}
            </div>
    
            {/* Positioned from the track's own center (left-1/2) and pushed out
                by exactly half the center image's width + a small gap — in cqw,
                matching the image's own cqw-based sizing — rather than anchored to
                the track's outer edges, so the buttons sit close to the image
                regardless of how wide the track itself is. Omitted entirely for
                decorative (non-interactive) copies — real <button>s always
                receive clicks/hover regardless of any ancestor's pointer-events,
                same reasoning as the center card above. */}
            {interactive && (
              <>
                <button
                  type="button"
                  aria-label="Previous image"
                  onClick={goPrev}
                  className="absolute left-1/2 top-1/2 z-40 flex h-8 w-8 -translate-y-1/2 translate-x-[calc(-50%-41cqw-14px)] items-center justify-center rounded-full border-2 border-kine-bg text-kine-bg transition-opacity duration-300 hover:opacity-70 @min-[640px]:h-10 @min-[640px]:w-10 @min-[640px]:translate-x-[calc(-50%-30cqw-24px)] @min-[768px]:h-12 @min-[768px]:w-12 @min-[768px]:translate-x-[calc(-50%-22cqw-32px)]"
                >
                  <svg className="h-2.5 w-2.5 @min-[640px]:h-3.5 @min-[640px]:w-3.5 @min-[768px]:h-[18px] @min-[768px]:w-[18px]" viewBox="0 0 16 16" fill="none">
                    <path d="M10 2L4 8L10 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                </button>
    
                <button
                  type="button"
                  aria-label="Next image"
                  onClick={goNext}
                  className="absolute left-1/2 top-1/2 z-40 flex h-8 w-8 -translate-y-1/2 translate-x-[calc(-50%+41cqw+14px)] items-center justify-center rounded-full border-2 border-kine-bg text-kine-bg transition-opacity duration-300 hover:opacity-70 @min-[640px]:h-10 @min-[640px]:w-10 @min-[640px]:translate-x-[calc(-50%+30cqw+24px)] @min-[768px]:h-12 @min-[768px]:w-12 @min-[768px]:translate-x-[calc(-50%+22cqw+32px)]"
                >
                  <svg className="h-2.5 w-2.5 @min-[640px]:h-3.5 @min-[640px]:w-3.5 @min-[768px]:h-[18px] @min-[768px]:w-[18px]" viewBox="0 0 16 16" fill="none">
                    <path d="M6 2L12 8L6 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                </button>
              </>
            )}
          </div>
    
          {/* Custom square cursor — rendered outside the track (which sets
              `perspective`, making it a containing block for fixed descendants)
              so `position: fixed` here tracks real viewport coordinates. Starts
              scaled to 0/invisible; grows in on pointer-enter, shrinks back out
              on leave. */}
          <div
            ref={cursorRef}
            className="pointer-events-none fixed left-0 top-0 z-50 border-[3px] border-white bg-white/10 mix-blend-difference"
          />
        </div>
      )
    }
    styles/kinematics.css
    /* Kinematic Studio's palette and type, for the components ported from its
       site. Namespaced as kine-* so they sit beside this library's own tokens —
       Kinematics calls its off-white "accent", which here is already the blue.
       Import this from the stylesheet that imports Tailwind, so the @theme
       block turns into utilities: bg-kine-bg, text-kine-primary/70,
       font-kine-display and so on. */
    @theme {
      --color-kine-bg: #0f1113;
      --color-kine-primary: #ceff00;
      --color-kine-surface: #2b2e34;
      --color-kine-accent: #e2e8f0;
    
      /* Audiowide for headlines, Geo for body copy. Geo breaks up at small
         sizes, so tiny labels use the plain sans instead. */
      --font-kine-display: "Audiowide", ui-sans-serif, system-ui, sans-serif;
      --font-kine-body: "Geo", ui-sans-serif, system-ui, sans-serif;
    
      /* Morni, the restaurant client in the Emoji Cursor demo. */
      --font-morni-brand: "Modak", ui-sans-serif, system-ui, sans-serif;
      --font-morni-body: "Poppins", ui-sans-serif, system-ui, sans-serif;
    }
    
    /* Manifesto emoji markers — the box grows from square to rectangle as scroll
       reaches it; once mostly grown, the emoji starts rolling, paused until then
       via animation-play-state set inline. */
    @keyframes manifesto-emoji-roll {
      to {
        transform: rotate(360deg);
      }
    }
    
    /* Expertise cards — small, always-looping visuals tying each card's
       animation to its own subject (motion, layout, load speed, ranking,
       language). CSS keyframes rather than GSAP, since they run for as long as
       the card is mounted. */
    @keyframes expertise-dash {
      to {
        stroke-dashoffset: -24;
      }
    }
    .expertise-flow-line {
      stroke-dasharray: 6 6;
      animation: expertise-dash 1s linear infinite;
    }
    
    @keyframes expertise-block-a {
      0%, 100% { width: 85%; }
      50% { width: 45%; }
    }
    @keyframes expertise-block-b {
      0%, 100% { width: 55%; }
      50% { width: 90%; }
    }
    @keyframes expertise-block-c {
      0%, 100% { width: 70%; }
      50% { width: 30%; }
    }
    .expertise-block-a { animation: expertise-block-a 3.2s ease-in-out infinite; }
    .expertise-block-b { animation: expertise-block-b 3.2s ease-in-out infinite 0.3s; }
    .expertise-block-c { animation: expertise-block-c 3.2s ease-in-out infinite 0.6s; }
    
    @keyframes expertise-fill {
      0% { width: 6%; opacity: 0.5; }
      70% { width: 100%; opacity: 1; }
      85% { width: 100%; opacity: 1; }
      100% { width: 6%; opacity: 0.5; }
    }
    .expertise-fill-bar {
      animation: expertise-fill 2.2s cubic-bezier(0.65, 0, 0.35, 1) infinite;
    }
    
    @keyframes expertise-rank-bar-1 { 0%, 100% { height: 30%; } 50% { height: 80%; } }
    @keyframes expertise-rank-bar-2 { 0%, 100% { height: 55%; } 50% { height: 25%; } }
    @keyframes expertise-rank-bar-3 { 0%, 100% { height: 40%; } 50% { height: 95%; } }
    @keyframes expertise-rank-bar-4 { 0%, 100% { height: 70%; } 50% { height: 50%; } }
    .expertise-rank-1 { animation: expertise-rank-bar-1 2.4s ease-in-out infinite; }
    .expertise-rank-2 { animation: expertise-rank-bar-2 2.4s ease-in-out infinite 0.15s; }
    .expertise-rank-3 { animation: expertise-rank-bar-3 2.4s ease-in-out infinite 0.3s; }
    .expertise-rank-4 { animation: expertise-rank-bar-4 2.4s ease-in-out infinite 0.45s; }
    
    @keyframes expertise-lang-cross {
      0%, 45% { opacity: 1; transform: translateY(0); }
      50%, 95% { opacity: 0; transform: translateY(-6px); }
      100% { opacity: 1; transform: translateY(0); }
    }
    @keyframes expertise-lang-cross-alt {
      0%, 45% { opacity: 0; transform: translateY(6px); }
      50%, 95% { opacity: 1; transform: translateY(0); }
      100% { opacity: 0; transform: translateY(6px); }
    }
    .expertise-lang-a { animation: expertise-lang-cross 3.6s ease-in-out infinite; }
    .expertise-lang-b { animation: expertise-lang-cross-alt 3.6s ease-in-out infinite; }

Props

PropTypeDefaultDescription
onCenterChange(index: number) => void—Called with the index of the slide that is now in the middle.
interactivebooleantrueOff for decorative copies: no hover, no arrows.