effects
Magic Rings
An elegant, interactive shader component rendering nested expanding magic wave rings.
shaderringsinteractivecanvaswaveswebgl
▶Preview
Hover & Click Interactive Shader
Adjust Shader Uniform Props
Accent Color
Gradient Color
Ring Count6
Wave Speed1
Line Thickness2
Noise Grain0.1
🖲Usage
example-usage.tsx
import { MagicRings } from "@/components/kanso/magic-rings"
export default function MagicRingsDemo() {
return (
<div className="w-full h-[300px] rounded-xl overflow-hidden border border-zinc-200 dark:border-zinc-800 relative bg-zinc-950">
<MagicRings
color="#fc42ff"
colorTwo="#42fcff"
speed={1}
ringCount={6}
followMouse={true}
clickBurst={true}
/>
</div>
)
}↓Installation
1
Create folder & copy source
Create a folder named kanso inside your project's components directory (i.e. components/kanso/). Copy the source code shown in the next section, and paste it into a file named magic-rings.tsx inside it.
2
Install dependencies
pnpm add three3
Required helper files
Ensure your project has the following helper files configured:
- →
lib/utils
<>Source Code
magic-rings.tsx
'use client';
import * as React from 'react';
import * as THREE from 'three';
import { cn } from '@/lib/utils';
const vertexShader = `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
precision highp float;
uniform float uTime, uAttenuation, uLineThickness;
uniform float uBaseRadius, uRadiusStep, uScaleRate;
uniform float uOpacity, uNoiseAmount, uRotation, uRingGap;
uniform float uFadeIn, uFadeOut;
uniform float uMouseInfluence, uHoverAmount, uHoverScale, uParallax, uBurst;
uniform vec2 uResolution, uMouse;
uniform vec3 uColor, uColorTwo;
uniform int uRingCount;
const float HP = 1.5707963;
const float CYCLE = 3.45;
float fade(float t) {
return t < uFadeIn ? smoothstep(0.0, uFadeIn, t) : 1.0 - smoothstep(uFadeOut, CYCLE - 0.2, t);
}
float ring(vec2 p, float ri, float cut, float t0, float px) {
float t = mod(uTime + t0, CYCLE);
float r = ri + t / CYCLE * uScaleRate;
float d = abs(length(p) - r);
float a = atan(abs(p.y), abs(p.x)) / HP;
float th = max(1.0 - a, 0.5) * px * uLineThickness;
float h = (1.0 - smoothstep(th, th * 1.5, d)) + 1.0;
d += pow(cut * a, 3.0) * r;
return h * exp(-uAttenuation * d) * fade(t);
}
void main() {
float px = 1.0 / min(uResolution.x, uResolution.y);
vec2 p = (gl_FragCoord.xy - 0.5 * uResolution.xy) * px;
float cr = cos(uRotation), sr = sin(uRotation);
p = mat2(cr, -sr, sr, cr) * p;
p -= uMouse * uMouseInfluence;
float sc = mix(1.0, uHoverScale, uHoverAmount) + uBurst * 0.3;
p /= sc;
vec3 c = vec3(0.0);
float rcf = max(float(uRingCount) - 1.0, 1.0);
for (int i = 0; i < 10; i++) {
if (i >= uRingCount) break;
float fi = float(i);
vec2 pr = p - fi * uParallax * uMouse;
vec3 rc = mix(uColor, uColorTwo, fi / rcf);
c = mix(c, rc, vec3(ring(pr, uBaseRadius + fi * uRadiusStep, pow(uRingGap, fi), i == 0 ? 0.0 : 2.95 * fi, px)));
}
c *= 1.0 + uBurst * 2.0;
float n = fract(sin(dot(gl_FragCoord.xy + uTime * 100.0, vec2(12.9898, 78.233))) * 43758.5453);
c += (n - 0.5) * uNoiseAmount;
gl_FragColor = vec4(c, max(c.r, max(c.g, c.b)) * uOpacity);
}
`;
export interface MagicRingsProps extends React.HTMLAttributes<HTMLDivElement> {
color?: string;
colorTwo?: string;
speed?: number;
ringCount?: number;
attenuation?: number;
lineThickness?: number;
baseRadius?: number;
radiusStep?: number;
scaleRate?: number;
opacity?: number;
blur?: number;
noiseAmount?: number;
rotation?: number;
ringGap?: number;
fadeIn?: number;
fadeOut?: number;
followMouse?: boolean;
mouseInfluence?: number;
hoverScale?: number;
parallax?: number;
clickBurst?: boolean;
}
function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
return (value: T | null) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(value);
} else if (ref != null) {
(ref as React.MutableRefObject<T | null>).current = value;
}
});
};
}
interface MagicRingsSettings {
color: string;
colorTwo: string;
speed: number;
ringCount: number;
attenuation: number;
lineThickness: number;
baseRadius: number;
radiusStep: number;
scaleRate: number;
opacity: number;
blur: number;
noiseAmount: number;
rotation: number;
ringGap: number;
fadeIn: number;
fadeOut: number;
followMouse: boolean;
mouseInfluence: number;
hoverScale: number;
parallax: number;
clickBurst: boolean;
}
const MagicRings = React.forwardRef<HTMLDivElement, MagicRingsProps>(
(
{
color = '#fc42ff',
colorTwo = '#42fcff',
speed = 1,
ringCount = 6,
attenuation = 10,
lineThickness = 2,
baseRadius = 0.35,
radiusStep = 0.1,
scaleRate = 0.1,
opacity = 1,
blur = 0,
noiseAmount = 0.1,
rotation = 0,
ringGap = 1.5,
fadeIn = 0.7,
fadeOut = 0.5,
followMouse = false,
mouseInfluence = 0.2,
hoverScale = 1.2,
parallax = 0.05,
clickBurst = false,
className,
style,
...props
},
ref
) => {
const mountRef = React.useRef<HTMLDivElement | null>(null);
const propsRef = React.useRef<MagicRingsSettings | null>(null);
const mouseRef = React.useRef([0, 0]);
const smoothMouseRef = React.useRef([0, 0]);
const hoverAmountRef = React.useRef(0);
const isHoveredRef = React.useRef(false);
const burstRef = React.useRef(0);
propsRef.current = {
color,
colorTwo,
speed,
ringCount,
attenuation,
lineThickness,
baseRadius,
radiusStep,
scaleRate,
opacity,
blur,
noiseAmount,
rotation,
ringGap,
fadeIn,
fadeOut,
followMouse,
mouseInfluence,
hoverScale,
parallax,
clickBurst,
};
React.useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ alpha: true });
} catch {
return;
}
if (!renderer.capabilities.isWebGL2) {
renderer.dispose();
return;
}
renderer.setClearColor(0x000000, 0);
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(
-0.5,
0.5,
0.5,
-0.5,
0.1,
10
);
camera.position.z = 1;
const uniforms = {
uTime: { value: 0 },
uAttenuation: { value: 0 },
uResolution: { value: new THREE.Vector2() },
uColor: { value: new THREE.Color() },
uColorTwo: { value: new THREE.Color() },
uLineThickness: { value: 0 },
uBaseRadius: { value: 0 },
uRadiusStep: { value: 0 },
uScaleRate: { value: 0 },
uRingCount: { value: 0 },
uOpacity: { value: 1 },
uNoiseAmount: { value: 0 },
uRotation: { value: 0 },
uRingGap: { value: 1.6 },
uFadeIn: { value: 0.5 },
uFadeOut: { value: 0.75 },
uMouse: { value: new THREE.Vector2() },
uMouseInfluence: { value: 0 },
uHoverAmount: { value: 0 },
uHoverScale: { value: 1 },
uParallax: { value: 0 },
uBurst: { value: 0 },
};
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms,
transparent: true,
});
const quad = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
scene.add(quad);
const resize = () => {
const w = mount.clientWidth;
const h = mount.clientHeight;
const dpr = Math.min(window.devicePixelRatio, 2);
renderer.setSize(w, h);
renderer.setPixelRatio(dpr);
uniforms.uResolution.value.set(w * dpr, h * dpr);
};
resize();
window.addEventListener('resize', resize);
const ro = new ResizeObserver(resize);
ro.observe(mount);
const onMouseMove = (e: MouseEvent) => {
const rect = mount.getBoundingClientRect();
mouseRef.current[0] = (e.clientX - rect.left) / rect.width - 0.5;
mouseRef.current[1] = -((e.clientY - rect.top) / rect.height - 0.5);
};
const onMouseEnter = () => {
isHoveredRef.current = true;
};
const onMouseLeave = () => {
isHoveredRef.current = false;
mouseRef.current[0] = 0;
mouseRef.current[1] = 0;
};
const onClick = () => {
burstRef.current = 1;
};
mount.addEventListener('mousemove', onMouseMove);
mount.addEventListener('mouseenter', onMouseEnter);
mount.addEventListener('mouseleave', onMouseLeave);
mount.addEventListener('click', onClick);
let frameId: number;
const animate = (t: number) => {
frameId = requestAnimationFrame(animate);
const p = propsRef.current;
if (!p) return;
smoothMouseRef.current[0] +=
(mouseRef.current[0] - smoothMouseRef.current[0]) * 0.08;
smoothMouseRef.current[1] +=
(mouseRef.current[1] - smoothMouseRef.current[1]) * 0.08;
hoverAmountRef.current +=
((isHoveredRef.current ? 1 : 0) - hoverAmountRef.current) * 0.08;
burstRef.current *= 0.95;
if (burstRef.current < 0.001) burstRef.current = 0;
uniforms.uTime.value = t * 0.001 * p.speed;
uniforms.uAttenuation.value = p.attenuation;
uniforms.uColor.value.set(p.color);
uniforms.uColorTwo.value.set(p.colorTwo);
uniforms.uLineThickness.value = p.lineThickness;
uniforms.uBaseRadius.value = p.baseRadius;
uniforms.uRadiusStep.value = p.radiusStep;
uniforms.uScaleRate.value = p.scaleRate;
uniforms.uRingCount.value = p.ringCount;
uniforms.uOpacity.value = p.opacity;
uniforms.uNoiseAmount.value = p.noiseAmount;
uniforms.uRotation.value = (p.rotation * Math.PI) / 180;
uniforms.uRingGap.value = p.ringGap;
uniforms.uFadeIn.value = p.fadeIn;
uniforms.uFadeOut.value = p.fadeOut;
uniforms.uMouse.value.set(
smoothMouseRef.current[0],
smoothMouseRef.current[1]
);
uniforms.uMouseInfluence.value = p.followMouse ? p.mouseInfluence : 0;
uniforms.uHoverAmount.value = hoverAmountRef.current;
uniforms.uHoverScale.value = p.hoverScale;
uniforms.uParallax.value = p.parallax;
uniforms.uBurst.value = p.clickBurst ? burstRef.current : 0;
renderer.render(scene, camera);
};
frameId = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(frameId);
window.removeEventListener('resize', resize);
ro.disconnect();
mount.removeEventListener('mousemove', onMouseMove);
mount.removeEventListener('mouseenter', onMouseEnter);
mount.removeEventListener('mouseleave', onMouseLeave);
mount.removeEventListener('click', onClick);
if (renderer.domElement && mount.contains(renderer.domElement)) {
mount.removeChild(renderer.domElement);
}
renderer.dispose();
material.dispose();
};
}, []);
return (
<div
ref={mergeRefs(ref, mountRef)}
className={cn('w-full h-full', className)}
style={{
...(blur > 0 ? { filter: `blur(${blur}px)` } : {}),
...style,
}}
{...props}
/>
);
}
);
MagicRings.displayName = 'MagicRings';
export { MagicRings };