Hypnotic Rings: Crafting a Mouse-Following Concentric Circles Animation in React: ContricReaction

Mohneesh NaiduMohneesh Naidu

Why I Built ContricReaction (or, “I Love a Good Show-Off Effect”)

A little about me: I’m Mohneesh, a self-proclaimed code-chef who loves to cook up fresh UI experiences in the mornings and whip up code at night. Just like I layer flavors in the kitchen, I layer animations in React—balancing taste (er, performance), style, and a dash of “wow.” ContricReaction was born when I thought, “Why should cursors be boring?” If you feel me, keep reading!

Concept Overview: Circles That Follow, Spin, and “Breath”

At its core, ContricReaction does three things:

  1. Follows your mouse pointer with a slight, configurable delay (so it feels graceful, not robotic).
  2. Rotates each ring in an alternating dance—one clockwise, the next counterclockwise, and so on.
  3. Pulses (scales up and back down) so the animation feels alive, not just a static spin.

The result? A hypnotic array of concentric circles that swirl around your cursor, transforming a simple hover into an experience.

Project Setup: Roll Out the React Magic

I’m assuming you already have a Next.js/React environment set up (if not, you can quickly scaffold one with npx create-next-app my-app). This component is a “client component,” meaning it needs to run in the browser—hence the 'use client' directive at the top.

Installation

To use the ContricReaction component in your project, you can install it via npm:

npm install contric-reaction

Create a file called ContricReaction.tsx in your components folder (or wherever you keep React components). Here’s the TypeScript boilerplate:

'use client';

import { useState, useEffect, useRef, CSSProperties } from 'react';

interface ContricReactionProps {
  circleCount?: number;
  maxSize?: number;
  color?: string;
  rotationSpeed?: number;
  followPointer?: boolean;
  followDelay?: number;
  enablePulse?: boolean;
}

const ContricReaction: React.FC<ContricReactionProps> = ({
  circleCount = 5,
  maxSize = 100,
  color = 'rgba(0, 0, 255, 0.3)',
  rotationSpeed = 1,
  followPointer = true,
  followDelay = 100,
  enablePulse = true,
}) => {
  // implementation goes here...
};

export default ContricReaction;

What All Those Props Mean (Spoiler: They’re Your “Spice Controls”)

PropTypeDefaultWhat It Does
circleCountnumber5How many concentric rings you want. Fewer rings = simpler, more = more drama.
maxSizenumber100Diameter (in px) of the outermost ring. Inner rings automatically scale down.
colorstring'rgba(0, 0, 255, 0.3)'Border color/opacity of the rings. Change this to match your theme (e.g., brand blue).
rotationSpeednumber1Adjusts how fast the rings spin. Higher = faster twirl; lower = graceful whirl.
followPointerbooleantrueIf false, circles stay put. (Maybe you want a static bonfire effect instead?)
followDelaynumber100Delay (ms) before each ring “chases” your cursor. Increase for a lazier follower.
enablePulsebooleantrueIf false, rings only spin—no growing/shrinking.

1. Tracking the Mouse Position (Because, Without That, It’s Just Rings in Space)

To make the rings actually chase your pointer, we need to know where your mouse is, and we need to do it with a touch of delay. Here’s the gist:

const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [isVisible, setIsVisible] = useState(false);

useEffect(() => {
  // Fires every time the mouse moves
  const handleMouseMove = (event: MouseEvent) => {
    if (followPointer) {
      // Delay the update so rings “trail” the mouse
      setTimeout(() => {
        setMousePosition({ x: event.clientX, y: event.clientY });
      }, followDelay);
    }
    // Only do this once—set isVisible to true on first movement
    if (!isVisible) {
      setIsVisible(true);
    }
  };

  // Hide circles when mouse leaves the window
  const handleMouseLeave = () => {
    setIsVisible(false);
  };

  window.addEventListener('mousemove', handleMouseMove);
  document.body.addEventListener('mouseleave', handleMouseLeave);

  return () => {
    window.removeEventListener('mousemove', handleMouseMove);
    document.body.removeEventListener('mouseleave', handleMouseLeave);
  };
}, [followPointer, followDelay, isVisible]);

Why this matters:

  • setTimeout: By delaying the update, we create a “lazy follower” vibe—circles don’t teleport to your cursor; they glide. Want them more eager? Lower followDelay. Chill vibe? Increase it to something like 200 ms.
  • isVisible: We only want the rings popping in when the user actually moves the mouse. As soon as the cursor leaves (for instance, you alt-tab away), we gently fade them out.

2. Cooking Up Concentric Circles (Layer by Layer)

Now, the pièce de résistance: generating multiple <div> elements that each represent one ring. We loop from 0 to circleCount - 1 and calculate:

  • Size (size): The outer ring is maxSize px. Each subsequent ring is a fraction smaller.
  • Opacity (opacity): Outer rings are more opaque; inner ones fade out for a depth effect.
  • Rotation direction (rotationDirection): Alternate—0th: clockwise; 1st: counter-clockwise; 2nd: clockwise; etc.
  • Rotation speed (rotationDuration): Slightly faster for inner rings, layered complexity.
const renderCircles = () => {
  const circles = [];

  for (let i = 0; i < circleCount; i++) {
    // 1. Determine size (outer ring = maxSize; inner rings get progressively smaller)
    const size = maxSize - (i * (maxSize / circleCount));

    // 2. Determine opacity (outer ring darker, inner rings fade out)
    const opacity = 0.8 - (i * (0.6 / circleCount));

    // 3. Alternate rotation direction: 1 = normal, -1 = reverse (CSS handles “reverse” keyword)
    const rotationDirection = i % 2 === 0 ? 1 : -1;

    // 4. Rotation duration tweaks: inner rings spin a bit faster (divide by rotationSpeed)
    const rotationDuration = (10 - (i * (5 / circleCount))) / rotationSpeed;

    // 5. Build the inline CSS object for each ring
    const circleStyle: CSSProperties = {
      position: 'absolute',
      width: `${size}px`,
      height: `${size}px`,
      borderRadius: '50%',
      border: `2px solid ${color}`,
      backgroundColor: 'transparent',
      opacity: opacity,
      transform: 'translate(-50%, -50%)',
      animation: `${
        enablePulse ? 'pulse' : ''
      } ${rotationDuration}s infinite ${
        rotationDirection > 0 ? 'linear' : 'reverse'
      }`,
    };

    circles.push(
      <div key={i} className="circle" style={circleStyle} />
    );
  }

  return circles;
};

Quick Breakdown

  1. size calculation:
    • maxSize is 100 px by default.
    • If circleCount is 5, then sizes: 100 px, 80 px, 60 px, 40 px, 20 px.
  2. opacity calculation:
    • Starts at 0.8 (nearly solid) for the outermost ring.
    • Drops by 0.6 / circleCount each step (so if 5 rings, subtraction is 0.12 per ring).
  3. rotationDirection:
    • Ensures your eye is forced back and forth: ring 0 → clockwise; ring 1 → counter; ring 2 → clockwise, etc.
  4. rotationDuration:
    • Outer ring: 10 s per revolution (very leisurely).
    • Inner rings: each one is slightly quicker (down to 5 s for the innermost).
    • You can squash or stretch this by tweaking rotationSpeed.

3. Breathing Life with CSS Animations

All that remains is to define the keyframes for our “pulse.” Basically, at 0%, we’re at scale(1), at 50% we’ve rotated 180° and scaled up to ~1.1 (if pulsing is on), and at 100% we’ve made a full 360° spinnit and returned to scale(1). The translate(-50%, -50%) keeps each ring perfectly centered on the cursor.

Inside your component’s returnbefore the circles—drop in a <style jsx global> block:

<style jsx global>{`
  @keyframes pulse {
    0% {
      transform: translate(-50%, -50%) rotate(0deg) scale(1);
    }
    50% {
      transform: translate(-50%, -50%) rotate(180deg) scale(${
        enablePulse ? 1.1 : 1
      });
    }
    100% {
      transform: translate(-50%, -50%) rotate(360deg) scale(1);
    }
  }
`}</style>

  • If enablePulse is false, scale stays at 1 throughout (so rings just spin, no “breathing”).
  • Otherwise, they gently swell to 1.1× size at mid-animation.

4. Wrapping Everything in a “Follow Container”

We need a parent <div> that:

  1. Covers the entire viewport (so rings can appear anywhere).
  2. Uses pointer-events: none to ensure the animation doesn’t block clicks/hover on underlying elements.
  3. Positions the “rendered circles” container at the current mousePosition (via inline style).

Here’s the snippet:

const containerStyle: CSSProperties = {
  position: 'fixed',
  top: 0,
  left: 0,
  width: '100%',
  height: '100%',
  pointerEvents: 'none',
  zIndex: 9999,
  overflow: 'hidden',
};

const circlesContainerStyle: CSSProperties = {
  position: 'absolute',
  top: mousePosition.y,
  left: mousePosition.x,
  opacity: isVisible ? 1 : 0,
  transition: 'opacity 0.3s ease',
};

And in your JSX:

return (
  <div style={containerStyle}>
    {/* Our global keyframes */}
    <style jsx global>{/* pulse keyframes */}</style>

    {/* This div moves to where the mouse is */}
    <div style={circlesContainerStyle}>
      {renderCircles()}
    </div>
  </div>
);

  • position: fixed on the outer div ensures it’s always “full-screen” and on top of everything (z-index 9999!).
  • pointerEvents: 'none' means clicks right through.
  • opacity: isVisible ? 1 : 0 with a 0.3 s transition gives a subtle fade in/out when the mouse enters or leaves the window.

5. The Full ContricReaction Component (Copy & Paste Rock ’n’ Roll)

In case you just want all the code in one place, feast your eyes:

'use client';

import { useState, useEffect, useRef, CSSProperties } from 'react';

interface ContricReactionProps {
  circleCount?: number;
  maxSize?: number;
  color?: string;
  rotationSpeed?: number;
  followPointer?: boolean;
  followDelay?: number;
  enablePulse?: boolean;
}

const ContricReaction: React.FC<ContricReactionProps> = ({
  circleCount = 5,
  maxSize = 100,
  color = 'rgba(0, 0, 255, 0.3)',
  rotationSpeed = 1,
  followPointer = true,
  followDelay = 100,
  enablePulse = true,
}) => {
  const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const handleMouseMove = (event: MouseEvent) => {
      if (followPointer) {
        setTimeout(() => {
          setMousePosition({ x: event.clientX, y: event.clientY });
        }, followDelay);
      }
      if (!isVisible) {
        setIsVisible(true);
      }
    };

    const handleMouseLeave = () => {
      setIsVisible(false);
    };

    window.addEventListener('mousemove', handleMouseMove);
    document.body.addEventListener('mouseleave', handleMouseLeave);

    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
      document.body.removeEventListener('mouseleave', handleMouseLeave);
    };
  }, [followPointer, followDelay, isVisible]);

  const renderCircles = () => {
    const circles = [];

    for (let i = 0; i < circleCount; i++) {
      const size = maxSize - i * (maxSize / circleCount);
      const opacity = 0.8 - i * (0.6 / circleCount);
      const rotationDirection = i % 2 === 0 ? 1 : -1;
      const rotationDuration = (10 - i * (5 / circleCount)) / rotationSpeed;

      const circleStyle: CSSProperties = {
        position: 'absolute',
        width: `${size}px`,
        height: `${size}px`,
        borderRadius: '50%',
        border: `2px solid ${color}`,
        backgroundColor: 'transparent',
        opacity: opacity,
        transform: 'translate(-50%, -50%)',
        animation: `${
          enablePulse ? 'pulse' : ''
        } ${rotationDuration}s infinite ${
          rotationDirection > 0 ? 'linear' : 'reverse'
        }`,
      };

      circles.push(
        <div key={i} className="circle" style={circleStyle} />
      );
    }

    return circles;
  };

  const containerStyle: CSSProperties = {
    position: 'fixed',
    top: 0,
    left: 0,
    width: '100%',
    height: '100%',
    pointerEvents: 'none',
    zIndex: 9999,
    overflow: 'hidden',
  };

  const circlesContainerStyle: CSSProperties = {
    position: 'absolute',
    top: mousePosition.y,
    left: mousePosition.x,
    opacity: isVisible ? 1 : 0,
    transition: 'opacity 0.3s ease',
  };

  return (
    <div style={containerStyle}>
      <style jsx global>{`
        @keyframes pulse {
          0% {
            transform: translate(-50%, -50%) rotate(0deg) scale(1);
          }
          50% {
            transform: translate(-50%, -50%) rotate(180deg) scale(${
              enablePulse ? 1.1 : 1
            });
          }
          100% {
            transform: translate(-50%, -50%) rotate(360deg) scale(1);
          }
        }
      `}</style>
      <div style={circlesContainerStyle}>{renderCircles()}</div>
    </div>
  );
};

export default ContricReaction;

6. How to Use ContricReaction in Your Page

In your page (e.g., MyPage.tsx or any component), simply import and render:

import ContricReaction from '@/components/ContricReaction';

export default function MyPage() {
  return (
    <div>
      {/* Drop the shimmering rings behind your content */}
      <ContricReaction
        circleCount={7}
        maxSize={150}
        color="rgba(59, 130, 246, 0.5)"
        rotationSpeed={1.5}
        followDelay={75}
        enablePulse={true}
      />

      {/* YOUR PAGE CONTENT GOES HERE */}
      <h1>Welcome to FarmWise (or whatever you wanna call it)</h1>
      <p>Hover around and watch the magic unfold!</p>
      {/* ... */}
    </div>
  );
}

Want a chill, barely-there shimmer? Try circleCount={3}, maxSize={80}, color="rgba(0,0,0,0.1)", and enablePulse={false}. Instant zen.

7. Performance & Accessibility—Keeping It Friendly

  • Event Throttling

    Constant mousemove events can flood React with state updates. If you notice lag on low-powered devices, you can wrap your handleMouseMove in a throttle (e.g., using lodash.throttle) so it only fires at most once every, say, 16 ms (≈60 fps).

  • Reducing Re-renders

    Right now, every update to mousePosition causes a re-render of the component. That’s usually fine for a handful of rings, but if you push things too far (e.g., circleCount={20}), consider memoizing the circle styles or even using a canvas-based approach for ultimate performance.

  • Reduced Motion Preferences

    Some folks get nauseous with spinning animations. To honor their choice:

    const prefersReducedMotion =
      typeof window !== 'undefined' &&
      window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    
    // Then you can do:
    const effectiveEnablePulse = prefersReducedMotion ? false : enablePulse;
    const effectiveRotationSpeed = prefersReducedMotion ? 0.5 : rotationSpeed;
    
    

    Or skip rendering altogether by checking if (prefersReducedMotion) return null.

  • Pointer Events

    Notice we set pointerEvents: 'none'. That’s crucial so your clickable buttons and links aren’t intercepted by invisible circles. Always keep this in mind!

8. Tweaking for Your Project—Flavor Text

Feel free to customize:

  • Colors: Try gradients by changing border: 2px solid ${color} into multiple layered divs or dynamic CSS variables.
  • Shapes: Swap circles for squares or blobs by changing borderRadius: '50%' to something like '10%' for a funky rounded-rectangle vibe.
  • Animation: If “pulse” isn’t your jam, define a fresh keyframe—maybe a wobble or a rubber-band effect.
  • Interactivity: Want to show different circles when hovering certain elements? Use React context or props to toggle followPointer on and off based on hover state.

9. Final Thoughts (Because I’m a Wordy Code-Chef)

Congratulations—your cursor is now surrounded by hypnotic, rotating rings. You just turned a boring “hover” event into a theatrical performance. Whether you sprinkle this effect on a landing page, a call-to-action button, or your entire app, remember:

  • Less is more: Too many circles or too fast an animation can be overwhelming. Start minimal (e.g., 3 rings, slow spin) and dial up the drama if needed.
  • Accessibility matters: Always respect users’ reduced-motion settings.
  • Performance matters: Keep an eye on frame rates, especially on mobile.
  • Have fun: That’s why we code. For the joy of seeing those colorful rings swirl!

If you try this out, tag me on Twitter or shoot me a DM—let’s swap animation war stories. Until next time, happy coding (and happy swirling)!

See live on My Website

— Mohneesh (that code-chef who believes every cursor deserves a little pizzazz)

0 viewsSign in to like

Comments

No comments yet.

    Sign in to comment.