Skip to content
jswnthui

Motion

Project Card

A square case-study card: hovering grows a rounded square out from the cursor that swaps the artwork for a laptop shot, with the name and a View button fading in.

Hover a card — the laptop shot grows out from wherever you came in

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/project-card.tsx
    'use client'
    
    import { useRef } from 'react'
    import type { PointerEvent as ReactPointerEvent } from 'react'
    import gsap from 'gsap'
    import type { Project } from '@/lib/kinematics-projects'
    
    const DEFAULT_CARD_SIZE = 420
    const REVEAL_RADIUS = 32
    
    interface ProjectCardProps {
      project: Project
      index: number
      side?: 'left' | 'right'
      style?: React.CSSProperties
      /** Real rendered width/height — the hover-reveal clip-path math needs this
       * exact value (not just a CSS override via `style`), since it undoes the
       * scroll-driven scale() transform by dividing the card's live
       * getBoundingClientRect() width by this "natural" size. Defaults to 420
       * for the original fixed-size usage. */
      cardWidth?: number
      cardHeight?: number
    }
    
    export default function ProjectCard({
      project,
      index,
      side = 'right',
      style,
      cardWidth = DEFAULT_CARD_SIZE,
      cardHeight = DEFAULT_CARD_SIZE,
    }: ProjectCardProps) {
      const cardRef = useRef<HTMLAnchorElement>(null)
      const revealRef = useRef<HTMLDivElement>(null)
    
      const revealState = useRef({ x: 0, y: 0, half: 0 })
    
      const applyRevealClip = () => {
        const reveal = revealRef.current
        const state = revealState.current
        if (!reveal || !state) return
        const { x, y, half } = state
        reveal.style.clipPath = `inset(${y - half}px ${cardWidth - x - half}px ${cardHeight - y - half}px ${x - half}px round ${REVEAL_RADIUS}px)`
      }
    
      const handleCardPointerEnter = (e: ReactPointerEvent<HTMLAnchorElement>) => {
        const card = cardRef.current
        const state = revealState.current
        if (!card || !state) return
    
        const rect = card.getBoundingClientRect()
        const scale = rect.width / cardWidth
        const x = (e.clientX - rect.left) / scale
        const y = (e.clientY - rect.top) / scale
    
        state.x = x
        state.y = y
        state.half = 0
        applyRevealClip()
    
        const maxHalf = Math.max(x, cardWidth - x, y, cardHeight - y, 0)
        gsap.killTweensOf(state)
        gsap.to(state, { half: maxHalf, duration: 0.55, ease: 'power3.out', onUpdate: () => applyRevealClip() })
      }
    
      const handleCardPointerLeave = () => {
        const state = revealState.current
        if (!state) return
        gsap.killTweensOf(state)
        gsap.to(state, { half: 0, duration: 0.3, ease: 'power2.in', onUpdate: () => applyRevealClip() })
      }
    
      const projectHref = project.slug ? `/work/${project.slug}` : '#'
    
      return (
        <a
          href={projectHref}
          ref={cardRef}
          onPointerEnter={handleCardPointerEnter}
          onPointerLeave={handleCardPointerLeave}
          aria-label={`View ${project.name} case study`}
          className="group relative block shrink-0 cursor-pointer overflow-hidden rounded-2xl border border-[#2b2e34] bg-[#16181b]"
          style={{
            width: cardWidth,
            height: cardHeight,
            transformOrigin: 'center center',
            ...style,
          }}
        >
          {/* Main card artwork */}
          <div className="absolute inset-[2.5%] overflow-hidden rounded-lg">
            <img
              src={project.image}
              alt={`${project.name} project artwork — web design Kuwait`}
              className="absolute inset-0 size-full object-cover"
            />
          </div>
    
          {/* Hover reveal — laptop mockup */}
          <div
            ref={revealRef}
            className="pointer-events-none absolute inset-0"
            style={{ clipPath: 'inset(50% 50% 50% 50%)' }}
          >
            <img
              src={project.laptopImage}
              alt={`${project.name} project shown on laptop — web design Kuwait`}
              className="absolute inset-0 size-full object-cover"
            />
          </div>
    
          {/* Category / name / CTA — hidden until hover */}
          <div className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between bg-black/50 p-6 opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100">
            <span className="font-sans text-xs font-bold uppercase tracking-widest text-kine-primary">
              {project.category}
            </span>
    
            <div className="flex items-center justify-between gap-4">
              <span className="font-kine-display text-2xl text-kine-accent">{project.name}</span>
              <span className="shrink-0 rounded-full border border-kine-primary px-4 py-2 font-sans text-xs font-bold uppercase tracking-widest text-kine-primary transition-colors duration-300 group-hover:bg-kine-primary group-hover:text-kine-bg">
                View
              </span>
            </div>
          </div>
        </a>
      )
    }
    lib/kinematics-projects.ts
    export interface Project {
      id: string
      name: string
      category: 'Web Design' | 'Web Development' | 'SaaS' | 'Ecommerce'
      image: string
      laptopImage: string
      slug?: string
      featured?: boolean
      // Case study content (/work/[slug])
      tagline?: string
      year?: string
      services?: string[]
      techStack?: string[]
      challenge?: string
      solution?: string
      seoDescription?: string
    }
    
    // All projects available for display
    export const PROJECTS: Project[] = [
      {
        id: '1',
        name: 'Morni',
        category: 'Web Design',
        image: '/kine/work-images/morni-web-design-kuwait-case-study.webp',
        laptopImage: '/kine/work-images/laptop/morni-laptop-mockup-web-design-kuwait.webp',
        slug: 'morni',
        featured: true,
        tagline: 'A slow-cooked brand story for a Kuwaiti kitchen',
        year: '2025',
        services: ['Art Direction', 'Web Design', 'Motion Design'],
        techStack: ['Next.js', 'GSAP', 'Lenis', 'Tailwind CSS'],
        challenge:
          'Morni needed a digital presence as rich as its saffron-layered dishes — but food sites in the region default to static menus and stock photography. The brand wanted visitors to feel the warmth of the kitchen before reading a single word.',
        solution:
          'We built a scroll-driven narrative where dishes assemble as you move, paired with typography inspired by hand-lettered Arabic menus. Every animation runs at 60fps, and the full experience loads in under a second on Kuwait mobile networks.',
        seoDescription:
          'Morni case study — web design Kuwait project by Kinematic Studio. A scroll-driven brand site for a Kuwaiti kitchen, built with Next.js and GSAP at 60fps.',
      },
      {
        id: '2',
        name: 'Dreamsoftplay',
        category: 'Web Development',
        image: '/kine/work-images/dreamsoftplay-web-development-kuwait-case-study.webp',
        laptopImage: '/kine/work-images/laptop/dreamsoftplay-laptop-mockup-web-development-kuwait.webp',
        slug: 'dreamsoftplay',
        featured: true,
        tagline: 'Minimal interiors, maximal interaction',
        year: '2025',
        services: ['Web Development', 'Interaction Design', 'Performance'],
        techStack: ['Next.js', 'Framer Motion', 'Lenis', 'Tailwind CSS'],
        challenge:
          'An interior design studio selling minimalism needed a site that practiced what it preached — restrained, airy layouts that still felt alive. Most furniture sites bury their photography under cluttered UI and slow page loads.',
        solution:
          'We let the photography lead: full-bleed room scenes with parallax depth, product reveals timed to scroll, and a navigation that stays invisible until needed. The result is a site that feels like walking through a showroom.',
        seoDescription:
          'Dreamsoftplay case study — web development Kuwait project by Kinematic Studio. A minimal, motion-first interior design site built with Next.js and Framer Motion.',
      },
      {
        id: '3',
        name: 'NexusOS',
        category: 'SaaS',
        image: '/kine/work-images/nexusos-saas-web-design-kuwait.webp',
        laptopImage: '/kine/work-images/laptop/nexusos-laptop-mockup-saas-kuwait.webp',
        slug: 'nexusos',
        tagline: 'Running schools at the speed of AI',
        year: '2024',
        services: ['Product Design', 'Web Development', 'SaaS Platform'],
        techStack: ['Next.js', 'TypeScript', 'PostgreSQL', 'Tailwind CSS'],
        challenge:
          'NexusOS is an all-in-one education operating system — assignments, results, and circulars across multi-tenant institutions. The marketing site had to explain a complex platform in seconds while looking nothing like typical enterprise software.',
        solution:
          'We designed a landing experience that demos the product as you scroll: live-looking dashboards animate into view, feature cards respond to the cursor, and the copy is ruthlessly compressed. Sign-up conversion became the only metric that mattered.',
        seoDescription:
          'NexusOS case study — Next.js agency Kuwait project by Kinematic Studio. A SaaS marketing site for an AI-powered education platform with animated product demos.',
      },
      {
        id: '4',
        name: 'Sands & Souls',
        category: 'Ecommerce',
        image: '/kine/work-images/sands-souls-ecommerce-web-design-kuwait.webp',
        laptopImage: '/kine/work-images/laptop/sands-souls-laptop-mockup-ecommerce-kuwait.webp',
        slug: 'sands-souls',
        tagline: 'Sneaker culture, Kuwait edition',
        year: '2024',
        services: ['Ecommerce Design', 'Web Development', 'Brand Motion'],
        techStack: ['Next.js', 'GSAP', 'Stripe', 'Tailwind CSS'],
        challenge:
          'A streetwear retailer competing with global sneaker platforms needed a store that felt like a drop, not a catalog. Local ecommerce in Kuwait means fighting slow checkouts, generic templates, and zero brand energy.',
        solution:
          'We built product pages with oversized typography and kinetic hover states, a cart that never interrupts browsing, and a checkout tuned for KWD payments. The store loads instantly and feels like the culture it sells.',
        seoDescription:
          'Sands & Souls case study — ecommerce web design Kuwait project by Kinematic Studio. A kinetic sneaker store built with Next.js, GSAP, and Stripe.',
      },
      {
        id: '5',
        name: 'Tentist',
        category: 'Web Design',
        image: '/kine/work-images/tentist-web-design-kuwait-case-study.webp',
        laptopImage: '/kine/work-images/laptop/tentist-laptop-mockup-web-design-kuwait.webp',
        slug: 'tentist',
        tagline: 'Desert camping, designed for the web',
        year: '2024',
        services: ['Web Design', 'Motion Design', 'Booking UX'],
        techStack: ['Next.js', 'GSAP', 'Lenis', 'Tailwind CSS'],
        challenge:
          'Tentist books luxury desert camps — an experience business stuck with a brochure website. Photos of dunes at golden hour deserved more than a static grid, and the booking flow lost customers at every step.',
        solution:
          'We rebuilt the site around immersion: horizon-wide hero imagery, scroll-triggered transitions that mimic moving through the desert, and a booking flow cut to three steps. The site now sells the feeling before the reservation.',
        seoDescription:
          'Tentist case study — interactive web design Kuwait project by Kinematic Studio. An immersive desert camping booking site with scroll-driven transitions.',
      },
      {
        id: '6',
        name: 'Orbitcorp',
        category: 'Web Design',
        image: '/kine/work-images/orbitcorp-web-design-kuwait-case-study.webp',
        laptopImage: '/kine/work-images/laptop/orbitcorp-laptop-mockup-web-design-kuwait.webp',
        slug: 'orbitcorp',
        tagline: 'A corporate site that escaped orbit',
        year: '2023',
        services: ['Web Design', 'Brand Identity', 'Development'],
        techStack: ['Next.js', 'Framer Motion', 'Tailwind CSS'],
        challenge:
          'Orbitcorp wanted to shed the beige corporate template — dense text blocks, stocky headshots, dated sliders — without losing the credibility a B2B audience expects. Bold, but never unserious.',
        solution:
          'We paired a restrained grid with confident motion: sections that settle into place, numbers that count up on arrival, and a dark palette that photographs well in pitch decks. Corporate, finally with a pulse.',
        seoDescription:
          'Orbitcorp case study — website designers in Kuwait project by Kinematic Studio. A modern B2B corporate site with confident motion built on Next.js.',
      },
    ]
    
    /** Look up a project by its slug; returns undefined for unknown slugs. */
    export function getProjectBySlug(slug: string): Project | undefined {
      return PROJECTS.find((p) => p.slug === slug)
    }
    
    /** The project after this one in PROJECTS order, wrapping around — for "next project" previews. */
    export function getNextProject(slug: string): Project {
      const index = PROJECTS.findIndex((p) => p.slug === slug)
      return PROJECTS[(index + 1) % PROJECTS.length]
    }
    
    export const CATEGORIES = ['All', ...new Set(PROJECTS.map((p) => p.category))]
    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
projectProject—Needs `name`, `category`, `image` and `laptopImage`; `slug` links it.
cardWidthnumber420Rendered width in px — the reveal's clip maths reads it.
cardHeightnumber420Rendered height in px.
styleCSSProperties—Merged onto the card, e.g. a scroll-driven transform.