Hypnotic Rings: Crafting a Mouse-Following Concentric Circles Animation in React: ContricReaction
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:
- Follows your mouse pointer with a slight, configurable delay (so it feels graceful, not robotic).
- Rotates each ring in an alternating dance—one clockwise, the next counterclockwise, and so on.
- 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”)
Prop | Type | Default | What 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:
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? LowerfollowDelay
. 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 ismaxSize
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
size
calculation:maxSize
is 100 px by default.- If
circleCount
is 5, then sizes: 100 px, 80 px, 60 px, 40 px, 20 px.
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).
rotationDirection
:- Ensures your eye is forced back and forth: ring 0 → clockwise; ring 1 → counter; ring 2 → clockwise, etc.
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 return
—before 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
isfalse
, 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:
- Covers the entire viewport (so rings can appear anywhere).
- Uses
pointer-events: none
to ensure the animation doesn’t block clicks/hover on underlying elements. - 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 outerdiv
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 yourhandleMouseMove
in a throttle (e.g., usinglodash.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 acanvas
-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)
Comments
No comments yet.
Sign in to comment.