Skip to content
jswnthui

Motion

Pixel Transition

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

Sketch of the Morni site

Hover it — tap on touch screens — and the pixels swap the sketch for the name

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/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

PropTypeDefaultDescription
firstContentReactNode—Shown at rest.
secondContentReactNode—Shown once the pixels have swept across.
gridSizenumber7Pixels along each side.
pixelColorstring"currentColor"Colour of the pixels.
animationStepDurationnumber0.3Seconds for the pixels to fill in, and again to clear.
oncebooleanfalseStay on the second content instead of swapping back.
aspectRatiostring"100%"Height as a share of the width.