CythBlog.

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

ramen
CertainlyMohneesh

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*Follows your mouse pointer* with a slight, configurable delay (so it feels graceful, not robotic).
  2. Rotates each ring*Rotates each ring* in an alternating dance—one clockwise, the next counterclockwise, and so on.
  3. Pulses*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.

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

tsx
1'use client';
2
3import { useState, useEffect, useRef, CSSProperties } from 'react';
4
5interface ContricReactionProps {
6  circleCount?: number;
7  maxSize?: number;
8  color?: string;
9  rotationSpeed?: number;
10  followPointer?: boolean;
11  followDelay?: number;
12  enablePulse?: boolean;
13}
14
15const ContricReaction: React.FC<ContricReactionProps> = ({
16  circleCount = 5,
17  maxSize = 100,
18  color = 'rgba(0, 0, 255, 0.3)',
19  rotationSpeed = 1,
20  followPointer = true,
21  followDelay = 100,
22  enablePulse = true,
23}) => {
24  // implementation goes here...
25};
26
27export default ContricReaction;
28

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

PropTypeDefaultWhat It Does
`circleCount`number`5`How many concentric rings you want. Fewer rings = simpler, more = more drama.
`maxSize`number`100`Diameter (in px) of the outermost ring. Inner rings automatically scale down.
`color`string`'rgba(0, 0, 255, 0.3)'`Border color/opacity of the rings. Change this to match your theme (e.g., brand blue).
`rotationSpeed`number`1`Adjusts how fast the rings spin. Higher = faster twirl; lower = graceful whirl.
`followPointer`boolean`true`If `false`, circles stay put. (Maybe you want a static bonfire effect instead?)
`followDelay`number`100`Delay (ms) before each ring “chases” your cursor. Increase for a lazier follower.
`enablePulse`boolean`true`If `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:

tsx
1const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
2const [isVisible, setIsVisible] = useState(false);
3
4useEffect(() => {
5  // Fires every time the mouse moves
6  const handleMouseMove = (event: MouseEvent) => {
7    if (followPointer) {
8      // Delay the update so rings “trail” the mouse
9      setTimeout(() => {
10        setMousePosition({ x: event.clientX, y: event.clientY });
11      }, followDelay);
12    }
13    // Only do this once—set isVisible to true on first movement
14    if (!isVisible) {
15      setIsVisible(true);
16    }
17  };
18
19  // Hide circles when mouse leaves the window
20  const handleMouseLeave = () => {
21    setIsVisible(false);
22  };
23
24  window.addEventListener('mousemove', handleMouseMove);
25  document.body.addEventListener('mouseleave', handleMouseLeave);
26
27  return () => {
28    window.removeEventListener('mousemove', handleMouseMove);
29    document.body.removeEventListener('mouseleave', handleMouseLeave);
30  };
31}, [followPointer, followDelay, isVisible]);
32

Why this matters:*Why this matters:*

  • `setTimeout`*`setTimeout`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`*`isVisible`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* (size): The outer ring is maxSize px. Each subsequent ring is a fraction smaller.
  • Opacity*Opacity* (opacity): Outer rings are more opaque; inner ones fade out for a depth effect.
  • Rotation direction*Rotation direction* (rotationDirection): Alternate—0th: clockwise; 1st: counter-clockwise; 2nd: clockwise; etc.
  • Rotation speed*Rotation speed* (rotationDuration): Slightly faster for inner rings, layered complexity.
tsx
1const renderCircles = () => {
2  const circles = [];
3
4  for (let i = 0; i < circleCount; i++) {
5    // 1. Determine size (outer ring = maxSize; inner rings get progressively smaller)
6    const size = maxSize - (i * (maxSize / circleCount));
7
8    // 2. Determine opacity (outer ring darker, inner rings fade out)
9    const opacity = 0.8 - (i * (0.6 / circleCount));
10
11    // 3. Alternate rotation direction: 1 = normal, -1 = reverse (CSS handles “reverse” keyword)
12    const rotationDirection = i % 2 === 0 ? 1 : -1;
13
14    // 4. Rotation duration tweaks: inner rings spin a bit faster (divide by rotationSpeed)
15    const rotationDuration = (10 - (i * (5 / circleCount))) / rotationSpeed;
16
17    // 5. Build the inline CSS object for each ring
18    const circleStyle: CSSProperties = {
19      position: 'absolute',
20      width: `${size}px`,
21      height: `${size}px`,
22      borderRadius: '50%',
23      border: `2px solid ${color}`,
24      backgroundColor: 'transparent',
25      opacity: opacity,
26      transform: 'translate(-50%, -50%)',
27      animation: `${
28        enablePulse ? 'pulse' : ''
29      } ${rotationDuration}s infinite ${
30        rotationDirection > 0 ? 'linear' : 'reverse'
31      }`,
32    };
33
34    circles.push(
35      <div key={i} className="circle" style={circleStyle} />
36    );
37  }
38
39  return circles;
40};
41

Quick Breakdown

  1. `size` calculation*`size` calculationsize calculation**:
  • maxSize is 100 px by default.
  • If circleCount is 5, then sizes: 100 px, 80 px, 60 px, 40 px, 20 px.
  1. `opacity` calculation*`opacity` calculationopacity 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).
  1. `rotationDirection`*`rotationDirection`rotationDirection**:
  • Ensures your eye is forced back and forth: ring 0 → clockwise; ring 1 → counter; ring 2 → clockwise, etc.
  1. `rotationDuration`*`rotationDuration`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*before* the circles—drop in a <style jsx global> block:

tsx
1<style jsx global>{`
2  @keyframes pulse {
3    0% {
4      transform: translate(-50%, -50%) rotate(0deg) scale(1);
5    }
6    50% {
7      transform: translate(-50%, -50%) rotate(180deg) scale(${
8        enablePulse ? 1.1 : 1
9      });
10    }
11    100% {
12      transform: translate(-50%, -50%) rotate(360deg) scale(1);
13    }
14  }
15`}</style>
16
  • 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:

tsx
1const containerStyle: CSSProperties = {
2  position: 'fixed',
3  top: 0,
4  left: 0,
5  width: '100%',
6  height: '100%',
7  pointerEvents: 'none',
8  zIndex: 9999,
9  overflow: 'hidden',
10};
11
12const circlesContainerStyle: CSSProperties = {
13  position: 'absolute',
14  top: mousePosition.y,
15  left: mousePosition.x,
16  opacity: isVisible ? 1 : 0,
17  transition: 'opacity 0.3s ease',
18};
19

And in your JSX:

tsx
1return (
2  <div style={containerStyle}>
3    {/* Our global keyframes */}
4    <style jsx global>{/* pulse keyframes */}</style>
5
6    {/* This div moves to where the mouse is */}
7    <div style={circlesContainerStyle}>
8      {renderCircles()}
9    </div>
10  </div>
11);
12
  • `position: fixed`*`position: fixed`position: fixed** on the outer div ensures it’s always “full-screen” and on top of everything (z-index 9999!).
  • `pointerEvents: 'none'`*`pointerEvents: 'none'`pointerEvents: 'none'** means clicks right through.
  • `opacity: isVisible ? 1 : 0`*`opacity: isVisible ? 1 : 0`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:

tsx
1'use client';
2
3import { useState, useEffect, useRef, CSSProperties } from 'react';
4
5interface ContricReactionProps {
6  circleCount?: number;
7  maxSize?: number;
8  color?: string;
9  rotationSpeed?: number;
10  followPointer?: boolean;
11  followDelay?: number;
12  enablePulse?: boolean;
13}
14
15const ContricReaction: React.FC<ContricReactionProps> = ({
16  circleCount = 5,
17  maxSize = 100,
18  color = 'rgba(0, 0, 255, 0.3)',
19  rotationSpeed = 1,
20  followPointer = true,
21  followDelay = 100,
22  enablePulse = true,
23}) => {
24  const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
25  const [isVisible, setIsVisible] = useState(false);
26
27  useEffect(() => {
28    const handleMouseMove = (event: MouseEvent) => {
29      if (followPointer) {
30        setTimeout(() => {
31          setMousePosition({ x: event.clientX, y: event.clientY });
32        }, followDelay);
33      }
34      if (!isVisible) {
35        setIsVisible(true);
36      }
37    };
38
39    const handleMouseLeave = () => {
40      setIsVisible(false);
41    };
42
43    window.addEventListener('mousemove', handleMouseMove);
44    document.body.addEventListener('mouseleave', handleMouseLeave);
45
46    return () => {
47      window.removeEventListener('mousemove', handleMouseMove);
48      document.body.removeEventListener('mouseleave', handleMouseLeave);
49    };
50  }, [followPointer, followDelay, isVisible]);
51
52  const renderCircles = () => {
53    const circles = [];
54
55    for (let i = 0; i < circleCount; i++) {
56      const size = maxSize - i * (maxSize / circleCount);
57      const opacity = 0.8 - i * (0.6 / circleCount);
58      const rotationDirection = i % 2 === 0 ? 1 : -1;
59      const rotationDuration = (10 - i * (5 / circleCount)) / rotationSpeed;
60
61      const circleStyle: CSSProperties = {
62        position: 'absolute',
63        width: `${size}px`,
64        height: `${size}px`,
65        borderRadius: '50%',
66        border: `2px solid ${color}`,
67        backgroundColor: 'transparent',
68        opacity: opacity,
69        transform: 'translate(-50%, -50%)',
70        animation: `${
71          enablePulse ? 'pulse' : ''
72        } ${rotationDuration}s infinite ${
73          rotationDirection > 0 ? 'linear' : 'reverse'
74        }`,
75      };
76
77      circles.push(
78        <div key={i} className="circle" style={circleStyle} />
79      );
80    }
81
82    return circles;
83  };
84
85  const containerStyle: CSSProperties = {
86    position: 'fixed',
87    top: 0,
88    left: 0,
89    width: '100%',
90    height: '100%',
91    pointerEvents: 'none',
92    zIndex: 9999,
93    overflow: 'hidden',
94  };
95
96  const circlesContainerStyle: CSSProperties = {
97    position: 'absolute',
98    top: mousePosition.y,
99    left: mousePosition.x,
100    opacity: isVisible ? 1 : 0,
101    transition: 'opacity 0.3s ease',
102  };
103
104  return (
105    <div style={containerStyle}>
106      <style jsx global>{`
107        @keyframes pulse {
108          0% {
109            transform: translate(-50%, -50%) rotate(0deg) scale(1);
110          }
111          50% {
112            transform: translate(-50%, -50%) rotate(180deg) scale(${
113              enablePulse ? 1.1 : 1
114            });
115          }
116          100% {
117            transform: translate(-50%, -50%) rotate(360deg) scale(1);
118          }
119        }
120      `}</style>
121      <div style={circlesContainerStyle}>{renderCircles()}</div>
122    </div>
123  );
124};
125
126export default ContricReaction;
127

6. How to Use ContricReaction in Your Page

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

tsx
1import ContricReaction from '@/components/ContricReaction';
2
3export default function MyPage() {
4  return (
5    <div>
6      {/* Drop the shimmering rings behind your content */}
7      <ContricReaction
8        circleCount={7}
9        maxSize={150}
10        color="rgba(59, 130, 246, 0.5)"
11        rotationSpeed={1.5}
12        followDelay={75}
13        enablePulse={true}
14      />
15
16      {/* YOUR PAGE CONTENT GOES HERE */}
17      <h1>Welcome to FarmWise (or whatever you wanna call it)</h1>
18      <p>Hover around and watch the magic unfold!</p>
19      {/* ... */}
20    </div>
21  );
22}
23

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*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*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*Reduced Motion Preferences*

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

tsx
1    const prefersReducedMotion =
2      typeof window !== 'undefined' &&
3      window.matchMedia('(prefers-reduced-motion: reduce)').matches;
4    
5    // Then you can do:
6    const effectiveEnablePulse = prefersReducedMotion ? false : enablePulse;
7    const effectiveRotationSpeed = prefersReducedMotion ? 0.5 : rotationSpeed;
8    

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

  • Pointer Events*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*Colors*: Try gradients by changing border: 2px solid ${color} into multiple layered divs or dynamic CSS variables.
  • Shapes*Shapes*: Swap circles for squares or blobs by changing borderRadius: '50%' to something like '10%' for a funky rounded-rectangle vibe.
  • Animation*Animation*: If “pulse” isn’t your jam, define a fresh keyframe—maybe a wobble or a rubber-band effect.
  • Interactivity*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*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*Accessibility matters*: Always respect users’ reduced-motion settings.
  • Performance matters*Performance matters*: Keep an eye on frame rates, especially on mobile.
  • Have fun*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)!

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


Latest Stories