Section
Sketch Reveal Hero
A carousel of project sketches under "Make it move." — the first scroll grows the finished site out of the sketch from the cursor, VS Code panels slide in beside it, and more scroll expands it to fill the screen.
Kinematic Studio is a web agency in Kuwait building fast, animation-driven websites with Next.js, GSAP, and Framer Motion.
Web Agency in KuwaitMake it move.

Open it in a new page — the first scroll grows the finished site out of the sketch from your cursor
import SketchRevealHero from "@/components/ui/sketch-reveal-hero";
export default function SketchRevealHeroDemo({ fullscreen }: { fullscreen?: boolean }) {
if (fullscreen) return <SketchRevealHero />;
return (
<div className="w-full min-w-0 space-y-3">
{/* The reveal and expand run on the page's own scroll, so the card
shows the static version. */}
<div className="w-full overflow-hidden rounded-card border border-line">
<SketchRevealHero pinned={false} />
</div>
<p className="text-center text-xs text-faint">
Open it in a new page — the first scroll grows the finished site out of the sketch from your cursor
</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/sketch-reveal-hero.tsx 'use client' import { useEffect, useRef, useState } from 'react' import gsap from 'gsap' import ImageCarousel, { IMAGES } from '@/components/ui/image-carousel' import { ExplorerFileTree, SourceControlPanel } from '@/components/ui/kinematics/ide-panels' import { onScroll } from '@/lib/on-scroll' import { useIsMobile } from '@/lib/use-is-mobile' // The bg image's oversized native display size/offset (see the <img> in // the JSX below) — shared with updateShrinkScrub so both the initial layout // and the shrink-to-fit scrub agree on where "fully oversized" starts from. const BG_NATIVE_WIDTH = 5477 const BG_NATIVE_HEIGHT = 3057 const BG_OFFSET_Y = 400 // How the pin's extra scroll room is split between the two manual scrubs: // first the preview expands to fill the screen (EXPAND_VH), then — once // that's fully done — the bg image shrinks from its oversized native size // down to an exact viewport fit (SHRINK_VH). const EXPAND_VH = 120 const SHRINK_VH = 100 /** Blocks wheel and touch scrolling for `ms` — a beat for the panels to land * before a fast scroll can blow through them. */ function holdScroll(ms: number) { const block = (e: Event) => e.preventDefault() const options = { passive: false, capture: true } as const window.addEventListener('wheel', block, options) window.addEventListener('touchmove', block, options) window.setTimeout(() => { window.removeEventListener('wheel', block, options) window.removeEventListener('touchmove', block, options) }, ms) } interface SketchRevealHeroProps { /** Pin the hero and play the reveal and expand as the page scrolls. Off, it renders the static version it also uses on mobile — for placing it somewhere that is not the page's own scroll. */ pinned?: boolean } /** Kinematics' home hero: a carousel of project sketches under "Make it * move.", which the first scroll turns into the finished site — a square * grows from the cursor to reveal it, VS Code panels slide in beside it, * then further scroll expands it to fill the screen. */ export default function SketchRevealHero({ pinned = true }: SketchRevealHeroProps) { const pinRef = useRef<HTMLDivElement>(null) const introRef = useRef<HTMLParagraphElement>(null) const headingRef = useRef<HTMLHeadingElement>(null) const carouselWrapRef = useRef<HTMLDivElement>(null) const previewRef = useRef<HTMLDivElement>(null) const detailsBgRef = useRef<HTMLImageElement>(null) const explorerRef = useRef<HTMLDivElement>(null) const scmRef = useRef<HTMLDivElement>(null) const [centerIndex, setCenterIndex] = useState(0) const isMobile = useIsMobile() // Tracked continuously so the reveal square always grows from wherever // the cursor actually is the instant the transition fires — no lag // waiting to discover the cursor position after the fact. const cursorPosRef = useRef({ x: 0, y: 0 }) // Only the initial reveal (carousel/heading -> preview + panels) is an // automated one-shot trigger. Once `revealed` is true, everything past // that point — panels fading out, the preview growing to 1429x778 — is // driven directly (1:1, no easing/animation) by the user's own manual // scroll position within the remaining pin range, not auto-played. const revealedRef = useRef(false) const isAnimatingRef = useRef(false) // The preview box's natural (pre-expansion) size, captured the instant // the reveal finishes — the scrub below lerps between this and 1429x778 // using plain offsetWidth/offsetHeight (unaffected by the immersion-zoom // `scale` transform, unlike getBoundingClientRect). const previewNaturalSizeRef = useRef({ width: 0, height: 0 }) useEffect(() => { cursorPosRef.current = { x: window.innerWidth / 2, y: window.innerHeight / 2 } const handlePointerMove = (e: PointerEvent) => { cursorPosRef.current = { x: e.clientX, y: e.clientY } } window.addEventListener('pointermove', handlePointerMove) return () => window.removeEventListener('pointermove', handlePointerMove) }, []) // Centering established through GSAP's own tracked transform (xPercent/ // yPercent) rather than Tailwind's -translate-x-1/2 classes — the scale // tween below touches this same element, and GSAP rewrites an element's // entire `transform` string from its own tracked properties whenever it // touches any of them, silently discarding any Tailwind/inline transform // set outside of it otherwise. useEffect(() => { if (!previewRef.current) return gsap.set(previewRef.current, { xPercent: -50, yPercent: -50, scale: 1 }) }, []) // Same reasoning as above — vertical centering lives in GSAP's tracked // yPercent (not a Tailwind -translate-y-1/2 class) since the slide-in // tween below touches `x` on this same element. useEffect(() => { if (!explorerRef.current) return gsap.set(explorerRef.current, { yPercent: -50, x: -24, opacity: 0 }) }, []) useEffect(() => { if (!scmRef.current) return gsap.set(scmRef.current, { yPercent: -50, x: 24, opacity: 0 }) }, []) // A square grows from the cursor's position until it fully covers the // fixed-size preview box, revealing the finished-project image inside it. // Every other hero element (heading, carousel — sketch images, side // slides, nav buttons, all of it) disappears instantly and simultaneously // the moment the transition starts, so nothing lingers or fades // separately — only the growing/shrinking square itself is animated. const playTransition = (direction: 'forward' | 'reverse') => { const intro = introRef.current const heading = headingRef.current const carousel = carouselWrapRef.current const preview = previewRef.current const detailsBg = detailsBgRef.current const explorer = explorerRef.current const scm = scmRef.current if (!intro || !heading || !carousel || !preview || !detailsBg || !explorer || !scm) return const showPreview = direction === 'forward' isAnimatingRef.current = true // Instant, not faded — the intro sentence/carousel/heading are simply // gone the moment the square starts growing (forward), or already back // the moment it starts shrinking (reverse, where the square itself still // hides them until it clears). gsap.set(intro, { opacity: showPreview ? 0 : 1 }) gsap.set(heading, { opacity: showPreview ? 0 : 1 }) gsap.set(carousel, { opacity: showPreview ? 0 : 1 }) // Reset any in-progress immersion zoom (see below) so every reveal // starts from a clean, unzoomed baseline. gsap.killTweensOf(preview) gsap.set(preview, { scale: 1 }) if (showPreview) gsap.set(preview, { opacity: 1 }) // The background layer stays hidden until the reveal completes (below), // same as the explorer/scm/terminal — on reverse it's hidden instantly. gsap.killTweensOf(detailsBg) if (!showPreview) gsap.set(detailsBg, { opacity: 0 }) // The explorer sidebar only ever appears once the reveal has fully // finished (below) — on reverse it's hidden immediately, in step with // the heading/carousel snapping back. gsap.killTweensOf(explorer) if (!showPreview) gsap.set(explorer, { opacity: 0, x: -24 }) gsap.killTweensOf(scm) if (!showPreview) gsap.set(scm, { opacity: 0, x: 24 }) // Clip geometry is relative to the preview box's own rect, not the // whole hero — the reveal only ever happens inside that fixed 666x362 // area, wherever the cursor happens to be relative to it (can be // negative/overshoot outside the box's own edges, which is fine; inset // just clips beyond them). const { left, top, width, height } = preview.getBoundingClientRect() const x = cursorPosRef.current.x - left const y = cursorPosRef.current.y - top const maxHalf = Math.max(x, width - x, y, height - y, 0) const proxy = { half: showPreview ? 0 : maxHalf } const applyClip = () => { preview.style.clipPath = `inset(${y - proxy.half}px ${width - x - proxy.half}px ${height - y - proxy.half}px ${x - proxy.half}px)` } applyClip() // Reverse plays noticeably faster — a quick snap back to the sketch // reads better than mirroring the slower, more deliberate reveal. gsap.to(proxy, { half: showPreview ? maxHalf : 0, duration: showPreview ? 0.55 : 0.3, ease: showPreview ? 'power3.out' : 'power2.in', onUpdate: applyClip, onComplete: () => { if (!showPreview) { gsap.set(preview, { opacity: 0 }) } else { // Once the box has fully opened, ease the image slightly larger — // a subtle push-in that reads as the image drawing closer. gsap.to(preview, { scale: 1.06, duration: 0.9, ease: 'power2.out' }) // The explorer sidebar slides/fades in beside it, once the reveal // itself is done rather than fighting for attention during it. gsap.to(explorer, { opacity: 1, x: 0, duration: 0.6, ease: 'power2.out' }) gsap.to(scm, { opacity: 1, x: 0, duration: 0.6, ease: 'power2.out' }) // The background photo stays hidden here — it only appears during // the manual expand scrub below, once the panels have faded out. // Natural (pre-expansion) size for the manual scrub below — // offsetWidth/Height ignore the scale tween just started above. previewNaturalSizeRef.current = { width: preview.offsetWidth, height: preview.offsetHeight } // A deliberate breakpoint: pause scrolling itself (not just the // handler) for a beat once the panels have landed, so a fast // scroll gesture can't blow straight through the VS Code section // into the expand scrub before the user has actually seen it. holdScroll(700) window.setTimeout(() => { isAnimatingRef.current = false }, 700) return } isAnimatingRef.current = false }, }) } // Once revealed, every further scroll tick directly sets the preview's // width/height (lerped toward the full viewport — 100vw x 100vh), the // panels' opacity (fading out over the scrub's first half), and the // background photo's opacity (switching on only once the preview has // fully filled the screen) from the pin's own scroll progress — a // real-time 1:1 scrub, not a triggered/eased animation. Scrolling back up // unwinds it exactly, and only once fully back at the start (progress <= // 0) does the reverse wipe (playTransition) take back over. const updateExpandScrub = (progress: number) => { const preview = previewRef.current const explorer = explorerRef.current const scm = scmRef.current const detailsBg = detailsBgRef.current const { width: nw, height: nh } = previewNaturalSizeRef.current if (!preview || !explorer || !scm || !detailsBg || !nw || !nh) return // A GSAP entrance tween (the panel slide-in, or the immersion zoom) may // still be finishing when manual scrubbing begins — kill any tweens on // the properties this scrub also writes so they don't fight each other. gsap.killTweensOf([explorer, scm, detailsBg]) preview.style.width = `${nw + (window.innerWidth - nw) * progress}px` preview.style.height = `${nh + (window.innerHeight - nh) * progress}px` const panelOpacity = 1 - Math.min(1, progress / 0.5) explorer.style.opacity = String(panelOpacity) scm.style.opacity = String(panelOpacity) // The instant the preview has fully filled the screen, it's swapped out // for the bg image with a hard cut (plain opacity flip, no transition/ // easing on either element) — the fastest possible handoff, not a fade. const atFullScreen = progress >= 1 preview.style.opacity = atFullScreen ? '0' : '1' detailsBg.style.opacity = atFullScreen ? '1' : '0' } // Runs only once the preview has fully filled the screen (expand progress // === 1) and the bg image has taken over — a second manual, un-eased // scrub that shrinks the bg from its oversized native display size/offset // down to an exact viewport fit (same resolution the preview reached: 100 // vw x 100vh, centered, no offset). Same 1:1 scroll-driven approach as // updateExpandScrub — no tweening of the actual size/position values. const updateShrinkScrub = (progress: number) => { const detailsBg = detailsBgRef.current if (!detailsBg) return gsap.killTweensOf(detailsBg) const width = BG_NATIVE_WIDTH + (window.innerWidth - BG_NATIVE_WIDTH) * progress const height = BG_NATIVE_HEIGHT + (window.innerHeight - BG_NATIVE_HEIGHT) * progress const offsetY = BG_OFFSET_Y * (1 - progress) detailsBg.style.width = `${width}px` detailsBg.style.height = `${height}px` detailsBg.style.transform = `translate(-50%, calc(-50% + ${offsetY}px))` } // Snaps the hero's reveal state back to its pre-scroll baseline — the // final state reached once scrolled back up to the very top. Guarded so // it's a no-op unless the hero was actually mid/post-reveal. const resetHeroReveal = () => { if (!revealedRef.current || isAnimatingRef.current) return revealedRef.current = false const preview = previewRef.current const detailsBg = detailsBgRef.current if (preview) gsap.set(preview, { clearProps: 'width,height' }) if (detailsBg) { detailsBg.style.width = `${BG_NATIVE_WIDTH}px` detailsBg.style.height = `${BG_NATIVE_HEIGHT}px` detailsBg.style.transform = `translate(-50%, calc(-50% + ${BG_OFFSET_Y}px))` } playTransition('reverse') } // Triggered by scroll DIRECTION, not absolute scroll position, for the // initial reveal only. Position-based triggers were asymmetric: forward // fired the instant `top` dipped below 0 (~1px of scroll), but reverse // required scrolling all the way back to that exact position — if the // user had scrolled any further in the meantime, they had to scroll all // the way back before anything happened, which read as a long lag. // Direction alone fires instantly both ways, on the very first tick. const lastScrollRef = useRef(0) useEffect(() => { const pin = pinRef.current if (!pin || !pinned) return // How far the page has scrolled past the pin's top — read off the pin // itself, so it works whichever element is doing the scrolling. const scrolledInto = () => -pin.getBoundingClientRect().top lastScrollRef.current = scrolledInto() const handleScroll = () => { const current = scrolledInto() const delta = current - lastScrollRef.current lastScrollRef.current = current if (delta === 0) return const rect = pin.getBoundingClientRect() const extraScroll = pin.offsetHeight - window.innerHeight const heroInView = rect.bottom > 0 && rect.top < window.innerHeight if (revealedRef.current) { if (isAnimatingRef.current) return // extraScroll (the pin's real, measured extra scroll room) is split // proportionally by the EXPAND_VH/SHRINK_VH ratio rather than // assuming exact vh-to-px math, so expandProgress hits exactly 1 // right as shrinkProgress leaves exactly 0 — no gap, no overlap. const expandPx = extraScroll * (EXPAND_VH / (EXPAND_VH + SHRINK_VH)) const shrinkPx = extraScroll - expandPx const scrolled = extraScroll > 0 ? Math.max(0, -rect.top) : 0 const expandProgress = Math.min(1, expandPx > 0 ? scrolled / expandPx : 1) const shrinkProgress = Math.min(1, Math.max(0, shrinkPx > 0 ? (scrolled - expandPx) / shrinkPx : 0)) updateExpandScrub(expandProgress) updateShrinkScrub(shrinkProgress) if (scrolled <= 0 && delta < 0) { resetHeroReveal() } return } if (isAnimatingRef.current) return if (delta > 0 && heroInView) { revealedRef.current = true playTransition('forward') } } return onScroll(handleScroll) // playTransition/updateExpandScrub read refs and are recreated every // render — intentionally omitted so this effect only re-subscribes when // the pin itself does. // eslint-disable-next-line react-hooks/exhaustive-deps }, [pinned, isMobile]) return ( <> {/* Mobile skips the pinned scroll-jack, the carousel's drag/tilt/ hover-reveal interaction, and the cursor-triggered clip-path reveal entirely — a static section showing the finished project shot and the Explorer panel straight away, in normal document flow. Desktop's interactive pin below is untouched. Starts `false` on first paint (see useIsMobile) and corrects on mount, same trade-off already accepted for usePrefersReducedMotion elsewhere in this app — a real device may show the desktop layout for one frame before switching over. */} {(isMobile || !pinned) && ( <section className="relative flex w-full flex-col items-center gap-8 overflow-hidden bg-[#f8f4f1] px-6 pb-16 pt-[96px]" > <p className="max-w-xl text-center font-kine-body text-sm text-kine-bg/70"> Kinematic Studio is a web agency in Kuwait building fast, animation-driven websites with Next.js, GSAP, and Framer Motion. </p> <h1 className="text-center font-kine-display text-6xl font-bold leading-none text-kine-bg"> <span className="mb-3 block font-kine-body text-base font-normal uppercase tracking-widest text-kine-bg/60"> Web Agency in Kuwait </span> Make it move. </h1> <div className="relative aspect-video w-full max-w-md overflow-hidden rounded-2xl"> <img src={IMAGES[0].reveal} alt={IMAGES[0].revealAlt} className="absolute inset-0 size-full object-cover" /> </div> </section> )} {!isMobile && pinned && ( <div ref={pinRef} className="relative h-[320vh] w-full"> <section className="sticky top-0 flex h-dvh w-full flex-col items-center justify-center gap-10 overflow-hidden bg-[#f8f4f1] px-6 pt-[64px]" > {/* Formerly a separate <DetailsSection> below the hero — now the hero's own background-most layer (z-0, behind everything else here), tracking whichever project is centered in the carousel. Hidden until the manual expand scrub reaches full size (see updateExpandScrub above) — on reverse it's hidden instantly with the other panels. `overflow-hidden` on the wrapper clips whatever spills past the viewport at the image's oversized native size; the image itself is centered both axes via absolute + inline transform (a plain CSS transform, safe here since GSAP never touches this element's transform, only its opacity). Width/height/transform are set inline (matching BG_NATIVE_WIDTH/HEIGHT/OFFSET_Y) rather than Tailwind arbitrary classes so updateShrinkScrub can animate from the exact same starting values via plain DOM style writes. */} <div className="absolute inset-0 z-0 overflow-hidden"> <img ref={detailsBgRef} src={IMAGES[centerIndex].bg} alt={IMAGES[centerIndex].bgAlt} width={BG_NATIVE_WIDTH} height={BG_NATIVE_HEIGHT} className="absolute left-1/2 top-1/2 max-w-none object-cover opacity-0" style={{ width: BG_NATIVE_WIDTH, height: BG_NATIVE_HEIGHT, transform: `translate(-50%, calc(-50% + ${BG_OFFSET_Y}px))`, }} /> </div> <div ref={carouselWrapRef} className="relative z-10 w-full"> <ImageCarousel onCenterChange={setCenterIndex} /> </div> {/* Real, plain-sentence intro — the single most valuable line on this page for an AI answer engine (ChatGPT/Perplexity/Google AI Overviews) to quote, since everything else here is a carousel of images with no sentence-level content. Also satisfies the "primary keyword in the first 25 words" SEO rule. */} <p ref={introRef} className="relative z-10 max-w-xl text-center font-kine-body text-sm text-kine-bg/70 md:text-base" > Kinematic Studio is a web agency in Kuwait building fast, animation-driven websites with Next.js, GSAP, and Framer Motion. </p> <h1 ref={headingRef} className="relative z-10 font-kine-display text-6xl font-bold leading-none text-kine-bg text-center md:text-8xl lg:text-9xl" > <span className="mb-3 block font-kine-body text-base font-normal uppercase tracking-widest text-kine-bg/60 md:text-lg"> Web Agency in Kuwait </span> Make it move. </h1> {/* The scroll-triggered reveal — sized to match the carousel's own center card (w-[82vw]/60vw/44vw, aspect-video — see components/ImageCarousel.tsx) so the preview lines up exactly where the sketch was. Clipped to a square that grows from the cursor's position (see playTransition above). Sits above the carousel/heading so it visually covers them as it grows. */} <div ref={previewRef} className="pointer-events-none absolute left-1/2 top-1/2 z-20 w-[82vw] aspect-video overflow-hidden rounded-2xl opacity-0 sm:w-[60vw] md:w-[44vw]" style={{ clipPath: 'inset(50% 50% 50% 50%)' }} > <img src={IMAGES[centerIndex].reveal} alt={IMAGES[centerIndex].revealAlt} className="absolute inset-0 size-full object-cover" /> </div> {/* VS Code Explorer-style sidebar — hidden until the reveal above fully finishes, then slides/fades in beside the preview image (see playTransition's onComplete). */} <div ref={explorerRef} className="pointer-events-none absolute left-6 top-1/2 z-20 hidden h-[80vh] w-[240px] flex-col overflow-hidden overflow-y-auto rounded-lg border border-white/10 bg-[#131417] font-sans text-sm text-kine-accent/70 opacity-0 lg:flex md:left-10" > <div className="px-4 py-3 text-xs font-bold uppercase tracking-widest text-kine-accent/50"> Explorer </div> <ExplorerFileTree /> </div> {/* Source Control panel — mirrors the explorer, on the right side of the preview image. Hidden until the reveal finishes, then slides/fades in from the right (see playTransition below). */} <div ref={scmRef} className="pointer-events-none absolute right-6 top-1/2 z-20 hidden h-[80vh] w-[240px] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#131417] font-sans text-sm text-kine-accent/70 opacity-0 lg:flex md:right-10" > <SourceControlPanel /> </div> </section> </div> )} </> ) }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> ) }components/ui/kinematics/ide-panels.tsx /* The VS Code panels that slide in beside the Sketch Reveal Hero's preview: an Explorer file tree and a Source Control changes list. Plain markup — the hero does all the moving. */ // Small color-coded glyphs standing in for a VS Code icon theme — no icon // font/library dependency, just per-extension colored text badges. export function FileIcon({ kind }: { kind: 'ts' | 'js' | 'json' | 'md' | 'info' | 'git' }) { const map: Record<typeof kind, { label: string; color: string }> = { ts: { label: 'TS', color: '#3b82f6' }, js: { label: 'JS', color: '#eab308' }, json: { label: '{}', color: '#eab308' }, md: { label: '↓', color: '#60a5fa' }, info: { label: 'ⓘ', color: '#60a5fa' }, git: { label: '◆', color: '#94a3b8' }, } const { label, color } = map[kind] return ( <span className="flex h-4 w-4 shrink-0 items-center justify-center text-[10px] font-bold leading-none" style={{ color }}> {label} </span> ) } export function ExplorerFolder({ name }: { name: string }) { return ( <div className="flex items-center gap-1.5 rounded px-2 py-1"> <span className="w-3 shrink-0 text-kine-accent/40">›</span> {name} </div> ) } export function ExplorerFile({ name, kind, active, }: { name: string kind: 'ts' | 'js' | 'json' | 'md' | 'info' | 'git' active?: boolean }) { return ( <div className={`flex items-center gap-2 rounded px-2 py-1 ${active ? 'bg-kine-primary/10 text-kine-primary' : ''}`}> <span className="w-3 shrink-0" /> <FileIcon kind={kind} /> {name} </div> ) } // VS Code's own "Source Control" panel — a changed-files list with a // staged/unstaged status letter per file, plus a commit-message mockup. const SCM_FILES: { name: string; status: 'M' | 'U' | 'A' }[] = [ { name: 'ImageCarousel.tsx', status: 'M' }, { name: 'page.tsx', status: 'M' }, { name: 'Header.tsx', status: 'M' }, { name: 'globals.css', status: 'M' }, { name: 'DepthImageCard.tsx', status: 'U' }, ] const SCM_STATUS_COLOR: Record<(typeof SCM_FILES)[number]['status'], string> = { M: '#e5b95c', U: '#4ade80', A: '#4ade80', } export function SourceControlPanel() { return ( <div className="flex h-full flex-col"> <div className="flex items-center justify-between px-4 py-3 text-xs font-bold uppercase tracking-widest text-kine-accent/50"> Source Control <span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] font-bold text-kine-accent/60">{SCM_FILES.length}</span> </div> <div className="mx-2 mb-2 rounded border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-kine-accent/40"> Message (Ctrl+Enter to commit) </div> <div className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 pb-4"> <div className="px-2 py-1 text-xs uppercase tracking-wide text-kine-accent/50">Changes</div> {SCM_FILES.map((file) => ( <div key={file.name} className="flex items-center justify-between rounded px-2 py-1"> <span className="truncate">{file.name}</span> <span className="text-xs font-bold" style={{ color: SCM_STATUS_COLOR[file.status] }}> {file.status} </span> </div> ))} </div> </div> ) } // The Explorer sidebar's file/folder listing — shared between the desktop // pin's slide-in panel and mobile's static equivalent (see the JSX below) // so the two never drift out of sync. export function ExplorerFileTree() { return ( <div className="flex flex-col gap-0.5 px-2 pb-4"> <div className="flex items-center gap-1.5 rounded px-2 py-1 uppercase tracking-wide text-kine-accent/80"> <span className="w-3 shrink-0 text-kine-accent/40">⌄</span> kine </div> <div className="ml-3 flex flex-col gap-0.5 border-l border-white/10 pl-3"> <ExplorerFolder name=".claude" /> <ExplorerFolder name=".next" /> <ExplorerFolder name="app" /> <ExplorerFolder name="components" /> <ExplorerFolder name="lib" /> <ExplorerFolder name="node_modules" /> <ExplorerFolder name="public" /> <ExplorerFile name=".gitignore" kind="git" /> <ExplorerFile name="AGENTS.md" kind="md" /> <ExplorerFile name="CLAUDE.md" kind="md" /> <ExplorerFile name="Colors and fonts.md" kind="md" /> <ExplorerFile name="components.json" kind="json" /> <ExplorerFile name="eslint.config.mjs" kind="js" /> <ExplorerFile name="kinematic.md" kind="md" /> <ExplorerFile name="next-env.d.ts" kind="ts" /> <ExplorerFile name="next.config.ts" kind="ts" active /> <ExplorerFile name="package-lock.json" kind="json" /> <ExplorerFile name="package.json" kind="json" /> <ExplorerFile name="postcss.config.mjs" kind="js" /> <ExplorerFile name="README.md" kind="info" /> <ExplorerFile name="tsconfig.json" kind="ts" /> <ExplorerFile name="tsconfig.tsbuildinfo" kind="ts" /> </div> </div> ) }lib/on-scroll.ts /** * Calls `handler` whenever anything scrolls — the window or any scroll * container on the page — and on resize. Returns the unsubscribe. * * The Kinematics components were written against a Lenis instance's scroll * event. They only ever use it as a tick and measure themselves with * getBoundingClientRect, so any scroll will do. Listening in the capture * phase catches scroll events from every element, since they do not bubble: * the same component works pinned to the page or inside a scrolling card. */ export function onScroll(handler: () => void) { const options = { capture: true, passive: true } as const; window.addEventListener("scroll", handler, options); window.addEventListener("resize", handler); return () => { window.removeEventListener("scroll", handler, options); window.removeEventListener("resize", handler); }; }lib/use-is-mobile.ts "use client"; import { useEffect, useState } from "react"; const QUERY = "(max-width: 767px)"; /** True below Tailwind's `md` breakpoint (767px). Starts `false` on the * server/first paint (no `matchMedia` there) and corrects on mount — the * same convention as usePrefersReducedMotion. For components that render a * genuinely different layout on mobile, not just a slowed-down one. */ export function useIsMobile() { const [isMobile, setIsMobile] = useState(false); useEffect(() => { const query = window.matchMedia(QUERY); setIsMobile(query.matches); const handleChange = (e: MediaQueryListEvent) => setIsMobile(e.matches); query.addEventListener("change", handleChange); return () => query.removeEventListener("change", handleChange); }, []); return isMobile; }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 |
|---|---|---|---|
| pinned | boolean | true | Pin and play the reveal with the scroll. Off, it renders the static version it also uses on mobile. |