Kanso UI LogoKanso UI
Components/magic rings
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 three
3

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 };

Props

Command Palette

Search for a command to run...