Code Talk: How the Pickleball Text Effect Was Created on My Website

Cover Image for Code Talk: How the Pickleball Text Effect Was Created on My Website
Mal Nushi
Mal Nushi

On the 'Pickleball' page on my personal website, I wanted to add a bouncing back 'n forth effect to mimic to the motion of Pickleball.

In this post, I want to go over how I made the component.

The Purpose

For every page on the 'sidequest' subpage on my website, I want to add something that is thematically relevant of the activity on the page. In this case, I needed to create a component that demonstrates the back 'n forth motion of pickleball.

Code

/**
 * PingPongEffect component
 * A client component that displays text with a ping-pong/pickleball effect on a specific letter
 * within a target word. The letter appears to bounce back and forth like a ping pong ball
 */

"use client";

import React, { useState, useEffect } from "react";

/**
 * Props for the PingPongEffect component
 * @property {string} text - The complete text content to display
 * @property {string} targetWord - The specific word containing the letter to animate
 * @property {string} targetLetter - The letter within the targetWord that should have the ping-pong effect
 * @property {string} [className] - Optional CSS class name for styling the container paragraph
 */
interface PingPongEffectProps {
  text: string;
  targetWord: string;
  targetLetter: string;
  className?: string;
}

/**
 * This renders text with a ping-pong effect applied to a specific letter within a word
 * The animation stops after 5 seconds
 * @param {Object} props - Component props
 * @param {string} props.text - The full text to display
 * @param {string} props.targetWord - The word containing the letter to animate
 * @param {string} props.targetLetter - The letter to apply the ping-pong animation to
 * @param {string} [props.className] - Optional CSS class for styling
 * @returns - The rendered text with a ping-pong effect on the specified letter
 */
export default function PingPongText({
  text,
  targetWord,
  targetLetter,
  className,
}: PingPongEffectProps): React.JSX.Element {
  // State to track whether animation is active
  const [isAnimating, setIsAnimating] = useState(true);

  // Effect to stop animation after 5 seconds
  useEffect(() => {
    const timer = setTimeout(() => {
      setIsAnimating(false);
    }, 5000);

    // Clean up timer if component unmounts
    return () => clearTimeout(timer);
  }, []);

  // If targetWord is not in text, just return the text
  if (!text.includes(targetWord)) {
    return <p className={className}>{text}</p>;
  }

  // Split the text by the targetWord
  const parts = text.split(targetWord);

  // Create an animated version of the target word
  const renderAnimatedWord = () => {
    // Find the position of the target letter in the word
    const letterPos = targetWord.indexOf(targetLetter);

    // If letter not found in word, return the word as is
    if (letterPos === -1) {
      return targetWord;
    }

    return (
      <>
        {targetWord.substring(0, letterPos)}
        <span
          className={isAnimating ? "pingpong-letter" : "pingpong-letter-static"}
        >
          {targetLetter}
        </span>
        {targetWord.substring(letterPos + 1)}
      </>
    );
  };

  return (
    <p className={className}>
      {parts.map((part, index) => (
        <React.Fragment key={index}>
          {part}
          {index < parts.length - 1 && renderAnimatedWord()}
        </React.Fragment>
      ))}
      <style jsx global>{`
        @keyframes pingPongBounce {
          0% {
            transform: translateY(0) translateX(0);
            border-radius: 50%;
          }
          25% {
            transform: translateY(-5px) translateX(6px);
          }
          50% {
            transform: translateY(0) translateX(0);
          }
          75% {
            transform: translateY(-5px) translateX(-6px);
          }
          100% {
            transform: translateY(0) translateX(0);
            border-radius: 50%;
          }
        }

        .pingpong-letter {
          display: inline-block;
          position: relative;
          color: red;
          animation: pingPongBounce 1.4s infinite ease-in-out;
          padding: 0 2px;
          font-weight: bold;
          border-radius: 50%;
          transition: color 1.4s ease-out, transform 4.4s ease-out;
        }

        .pingpong-letter-static {
          display: inline-block;
          position: relative;
          //   padding: 0 2px;
          //   font-weight: bold;
          color: inherit;
          border-radius: 50%;
          transition: color 1.4s ease-out, transform 4.4s ease-out;
          transform: translateY(0) translateX(0);
        }
      `}</style>
    </p>
  );
}

Full Disclaimer: I do recognize that the function is called PingPongEffectProps. Ping pong did come first in my life, and at the time I was thinking about ping pong in my head. Also, I always called pickleball—enlarged ping pong.

The API

The component accepts four props:

  1. text: The complete text content to display.
  2. targetWord: The specific word containing the letter to animate.
  3. targetLetter: The letter within the targetWord that should have the ping-pong effect.
  4. className: Optional CSS class name for styling the container paragraph.
/**
 * Props for the PingPongEffect component
 * @property {string} text - The complete text content to display
 * @property {string} targetWord - The specific word containing the letter to animate
 * @property {string} targetLetter - The letter within the targetWord that should have the ping-pong effect
 * @property {string} [className] - Optional CSS class name for styling the container paragraph
 */
interface PingPongEffectProps {
  text: string;
  targetWord: string;
  targetLetter: string;
  className?: string;
}

Usage

The component is dead-simple to use:

<PingPongText
  text={metadata.description?.toString() ?? ""}
  targetWord="courts"
  targetLetter="o"
  className="mb-8 text-neutral-600 dark:text-neutral-400"
/>

The Animation

The component captures the "ping pong" and "pickleball" feel from a CSS animation applied to one single letter. In the example below, you will see the keyframes:

@keyframes pingPongBounce {
  0% {transform: translateY(0) translateX(0); border-radius: 50%;}
  25% {transform: translateY(-5px) translateX(6px);}
  50% {transform: translateY(0) translateX(0);}
  75% {transform: translateY(-5px) translateX(-6px);}
  100% {transform: translateY(0) translateX(0); border-radius: 50%;}
}

This was a fun one to design, not going to lie. The smooth easing and short interval I put in place gives a cool side-to-side bounce. The animation runs for about 5 seconds after the component mounts and then fades out.

The animated and static styles look like this:

.pingpong-letter {
  display: inline-block;
  position: relative;
  color: red;
  animation: pingPongBounce 1.4s infinite ease-in-out;
  padding: 0 2px;
  font-weight: bold;
  border-radius: 50%;
  transition: color 1.4s ease-out, transform 4.4s ease-out;
}

.pingpong-letter-static {
  display: inline-block;
  position: relative;
  //   padding: 0 2px;
  //   font-weight: bold;
  color: inherit;
  border-radius: 50%;
  transition: color 1.4s ease-out, transform 4.4s ease-out;
  transform: translateY(0) translateX(0);
}

Component Design

This component was written in a monolithic style. All the logic, rendering, animation, and styling is kept within one file. This component is a one-time use component. I am not planning on using it again and most likely will not use the logic inside of there so there was no need to make this compositional.

At render time, the component:

  1. Splits the full text at every occurance of targetWord.
  2. Reconstructs the string and inserts an animated version of targetLetter within each occurrence.
  3. Uses a useEffect hook to deactivate the animation after about 5 seconds.

If the targetWord is not found in the text, it returns the plain text instead.