AnimationHero
MagicRings
WebGL rings that expand in two colours, with optional mouse parallax and click burst.
npx bezel-add add magic-ringspackages/ui/src/animation/MagicRings.tsx · 384 lines
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ");
}
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() {
vec2 p = (gl_FragCoord.xy - 0.5 * uResolution.xy) / uResolution.y;
float px = 1.0 / uResolution.y;
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 type MagicRingsProps = {
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;
className?: string;
};
type Runtime = Required<
Pick<
MagicRingsProps,
| "color"
| "colorTwo"
| "speed"
| "ringCount"
| "attenuation"
| "lineThickness"
| "baseRadius"
| "radiusStep"
| "scaleRate"
| "opacity"
| "noiseAmount"
| "rotation"
| "ringGap"
| "fadeIn"
| "fadeOut"
| "followMouse"
| "mouseInfluence"
| "hoverScale"
| "parallax"
| "clickBurst"
>
>;
/** Soft expanding dual-color WebGL rings — optional mouse parallax + click burst. */
export function MagicRings({
color = "#912c22",
colorTwo = "#c9a04a",
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,
}: MagicRingsProps) {
const mountRef = useRef<HTMLDivElement>(null);
const propsRef = useRef<Runtime | null>(null);
const mouseRef = useRef([0, 0]);
const smoothMouseRef = useRef([0, 0]);
const hoverAmountRef = useRef(0);
const isHoveredRef = useRef(false);
const burstRef = useRef(0);
propsRef.current = {
color,
colorTwo,
speed,
ringCount,
attenuation,
lineThickness,
baseRadius,
radiusStep,
scaleRate,
opacity,
noiseAmount,
rotation,
ringGap,
fadeIn,
fadeOut,
followMouse,
mouseInfluence,
hoverScale,
parallax,
clickBurst,
};
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ alpha: true, antialias: 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;
if (w <= 0 || h <= 0) return;
const dpr = Math.min(window.devicePixelRatio, 2);
renderer.setPixelRatio(dpr);
renderer.setSize(w, h, false);
const buffer = new THREE.Vector2();
renderer.getDrawingBufferSize(buffer);
uniforms.uResolution.value.copy(buffer);
};
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 = 0;
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.parentNode === mount) mount.removeChild(renderer.domElement);
renderer.dispose();
material.dispose();
quad.geometry.dispose();
};
}, []);
return (
<>
<style>{`
.magic-rings-container {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
}
.magic-rings-container--interactive { pointer-events: auto; }
.magic-rings-container canvas {
display: block;
position: absolute;
top: 0; left: 0;
width: 100% !important;
height: 100% !important;
}
`}</style>
<div
ref={mountRef}
className={cn(
"magic-rings-container",
(followMouse || clickBurst) && "magic-rings-container--interactive",
className,
)}
style={blur > 0 ? { filter: `blur(${blur}px)` } : undefined}
aria-hidden
/>
</>
);
}
/** Soft gold preset tuned for full-screen loaders. */
export const LOADER_MAGIC_RINGS = {
color: "#c9a04a",
colorTwo: "#e8d5a3",
ringCount: 8,
speed: 0.72,
attenuation: 7.4,
lineThickness: 1.2,
baseRadius: 0.06,
radiusStep: 0.11,
scaleRate: 0.3,
opacity: 0.22,
blur: 0.4,
noiseAmount: 0.015,
rotation: 0,
ringGap: 1.28,
fadeIn: 0.5,
fadeOut: 0.48,
followMouse: false,
mouseInfluence: 0,
parallax: 0,
hoverScale: 1,
clickBurst: false,
} as const;