Motion
Pixel Transition
A card that swaps its content through a burst of randomly ordered pixels on hover, or on tap on touch screens.

Morni
Hover it — tap on touch screens — and the pixels swap the sketch for the name
import PixelTransition from "@/components/ui/pixel-transition";
export default function PixelTransitionDemo() {
return (
<div className="w-full min-w-0 space-y-3">
<div className="grid place-items-center">
<PixelTransition
gridSize={10}
pixelColor="#ceff00"
animationStepDuration={0.4}
aspectRatio="75%"
className="border-kine-surface! bg-kine-bg!"
firstContent={
<img
src="/kine/sketch/morni-sketch-web-design-kuwait.webp"
alt="Sketch of the Morni site"
className="size-full object-cover"
/>
}
secondContent={
<div className="grid size-full place-items-center bg-kine-bg">
<p className="font-kine-display text-3xl uppercase text-kine-primary">Morni</p>
</div>
}
/>
</div>
<p className="text-center text-xs text-faint">
Hover it — tap on touch screens — and the pixels swap the sketch for the name
</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/pixel-transition.tsx 'use client' import React, { useRef, useEffect, useState, type CSSProperties } from 'react' import { gsap } from 'gsap' interface PixelTransitionProps { firstContent: React.ReactNode | string secondContent: React.ReactNode | string gridSize?: number pixelColor?: string animationStepDuration?: number once?: boolean className?: string style?: CSSProperties aspectRatio?: string } const PixelTransition: React.FC<PixelTransitionProps> = ({ firstContent, secondContent, gridSize = 7, pixelColor = 'currentColor', animationStepDuration = 0.3, once = false, aspectRatio = '100%', className = '', style = {}, }) => { const containerRef = useRef<HTMLDivElement | null>(null) const pixelGridRef = useRef<HTMLDivElement | null>(null) const activeRef = useRef<HTMLDivElement | null>(null) const delayedCallRef = useRef<gsap.core.Tween | null>(null) const [isActive, setIsActive] = useState<boolean>(false) // Read on mount rather than during render: the island is server-rendered // first, where there is no window to ask. const [isTouchDevice, setIsTouchDevice] = useState(false) useEffect(() => { setIsTouchDevice( 'ontouchstart' in window || navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches, ) }, []) useEffect(() => { const pixelGridEl = pixelGridRef.current if (!pixelGridEl) return pixelGridEl.innerHTML = '' for (let row = 0; row < gridSize; row++) { for (let col = 0; col < gridSize; col++) { const pixel = document.createElement('div') pixel.classList.add('pixelated-image-card__pixel') pixel.classList.add('absolute', 'hidden') pixel.style.backgroundColor = pixelColor const size = 100 / gridSize pixel.style.width = `${size}%` pixel.style.height = `${size}%` pixel.style.left = `${col * size}%` pixel.style.top = `${row * size}%` pixelGridEl.appendChild(pixel) } } }, [gridSize, pixelColor]) const animatePixels = (activate: boolean): void => { setIsActive(activate) const pixelGridEl = pixelGridRef.current const activeEl = activeRef.current if (!pixelGridEl || !activeEl) return const pixels = pixelGridEl.querySelectorAll<HTMLDivElement>('.pixelated-image-card__pixel') if (!pixels.length) return gsap.killTweensOf(pixels) if (delayedCallRef.current) { delayedCallRef.current.kill() } gsap.set(pixels, { display: 'none' }) const totalPixels = pixels.length const staggerDuration = animationStepDuration / totalPixels gsap.to(pixels, { display: 'block', duration: 0, stagger: { each: staggerDuration, from: 'random', }, }) delayedCallRef.current = gsap.delayedCall(animationStepDuration, () => { activeEl.style.display = activate ? 'block' : 'none' activeEl.style.pointerEvents = activate ? 'none' : '' }) gsap.to(pixels, { display: 'none', duration: 0, delay: animationStepDuration, stagger: { each: staggerDuration, from: 'random', }, }) } const handleEnter = (): void => { if (!isActive) animatePixels(true) } const handleLeave = (): void => { if (isActive && !once) animatePixels(false) } const handleClick = (): void => { if (!isActive) animatePixels(true) else if (isActive && !once) animatePixels(false) } return ( <div ref={containerRef} className={` ${className} bg-[#222] text-white rounded-[15px] border-2 border-white w-[300px] max-w-full relative overflow-hidden `} style={style} onMouseEnter={!isTouchDevice ? handleEnter : undefined} onMouseLeave={!isTouchDevice ? handleLeave : undefined} onClick={isTouchDevice ? handleClick : undefined} onFocus={!isTouchDevice ? handleEnter : undefined} onBlur={!isTouchDevice ? handleLeave : undefined} tabIndex={0} > <div style={{ paddingTop: aspectRatio }} /> <div className="absolute inset-0 w-full h-full" aria-hidden={isActive}> {firstContent} </div> <div ref={activeRef} className="absolute inset-0 w-full h-full z-[2]" style={{ display: 'none' }} aria-hidden={!isActive} > {secondContent} </div> <div ref={pixelGridRef} className="absolute inset-0 w-full h-full pointer-events-none z-[3]" /> </div> ) } export default PixelTransition
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| firstContent | ReactNode | — | Shown at rest. |
| secondContent | ReactNode | — | Shown once the pixels have swept across. |
| gridSize | number | 7 | Pixels along each side. |
| pixelColor | string | "currentColor" | Colour of the pixels. |
| animationStepDuration | number | 0.3 | Seconds for the pixels to fill in, and again to clear. |
| once | boolean | false | Stay on the second content instead of swapping back. |
| aspectRatio | string | "100%" | Height as a share of the width. |