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
import ProjectCard from "@/components/ui/project-card";
import { PROJECTS } from "@/lib/kinematics-projects";
export default function ProjectCardDemo() {
return (
<div className="w-full min-w-0 space-y-3">
<div className="flex flex-wrap justify-center gap-6 rounded-card border border-line bg-kine-bg p-6 md:p-10">
{PROJECTS.slice(0, 3).map((project, i) => (
<ProjectCard key={project.id} project={project} index={i} cardWidth={240} cardHeight={240} />
))}
</div>
<p className="text-center text-xs text-faint">
Hover a card — the laptop shot grows out from wherever you came in
</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/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
| Prop | Type | Default | Description |
|---|---|---|---|
| project | Project | — | Needs `name`, `category`, `image` and `laptopImage`; `slug` links it. |
| cardWidth | number | 420 | Rendered width in px — the reveal's clip maths reads it. |
| cardHeight | number | 420 | Rendered height in px. |
| style | CSSProperties | — | Merged onto the card, e.g. a scroll-driven transform. |





