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.






Hover the middle card to see the finished site through the square — the arrows step through
import ImageCarousel from "@/components/ui/image-carousel";
export default function ImageCarouselDemo({ fullscreen }: { fullscreen?: boolean }) {
if (fullscreen) {
return (
<div className="grid min-h-dvh w-full place-items-center bg-[#f8f4f1]">
<ImageCarousel />
</div>
);
}
return (
<div className="w-full min-w-0 space-y-3">
<div className="w-full overflow-hidden rounded-card border border-line bg-[#f8f4f1] py-10">
<ImageCarousel />
</div>
<p className="text-center text-xs text-faint">
Hover the middle card to see the finished site through the square — the arrows step through
</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install gsap2. Add the
cnhelper, 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. 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
| Prop | Type | Default | Description |
|---|---|---|---|
| onCenterChange | (index: number) => void | — | Called with the index of the slide that is now in the middle. |
| interactive | boolean | true | Off for decorative copies: no hover, no arrows. |