Character Cursor Effect
An interactive React component that adds a dynamic bubble effect, visually tracking cursor movement in real time.
An interactive React component that adds a dynamic bubble effect, visually tracking cursor movement in real time.
1// @ts-nocheck2'use client';3import React, { useEffect, useRef } from 'react';45interface Particle {6rotationSign: number;7age: number;8initialLifeSpan: number;9lifeSpan: number;10velocity: { x: number; y: number };11position: { x: number; y: number };12canv: HTMLCanvasElement;13update: (context: CanvasRenderingContext2D) => void;14}1516interface CharacterCursorProps {17characters?: string[];18colors?: string[];19cursorOffset?: { x: number; y: number };20font?: string;21characterLifeSpanFunction?: () => number;22initialCharacterVelocityFunction?: () => { x: number; y: number };23characterVelocityChangeFunctions?: {24x_func: (age: number, lifeSpan: number) => number;25y_func: (age: number, lifeSpan: number) => number;26};27characterScalingFunction?: (age: number, lifeSpan: number) => number;28characterNewRotationDegreesFunction?: (29age: number,30lifeSpan: number31) => number;32wrapperElement?: HTMLElement;33}3435const CharacterCursor: React.FC<CharacterCursorProps> = ({36characters = ['h', 'e', 'l', 'l', 'o'],37colors = ['#6622CC', '#A755C2', '#B07C9E', '#B59194', '#D2A1B8'],38cursorOffset = { x: 0, y: 0 },39font = '15px serif',40characterLifeSpanFunction = () => Math.floor(Math.random() * 60 + 80),41initialCharacterVelocityFunction = () => ({42x: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5,43y: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5,44}),45characterVelocityChangeFunctions = {46x_func: () => (Math.random() < 0.5 ? -1 : 1) / 30,47y_func: () => (Math.random() < 0.5 ? -1 : 1) / 15,48},49characterScalingFunction = (age, lifeSpan) =>50Math.max(((lifeSpan - age) / lifeSpan) * 2, 0),51characterNewRotationDegreesFunction = (age, lifeSpan) => (lifeSpan - age) / 5,52wrapperElement,53}) => {54const canvasRef = useRef<HTMLCanvasElement | null>(null);55const particlesRef = useRef<Particle[]>([]);56const cursorRef = useRef({ x: 0, y: 0 });57const animationFrameRef = useRef<number | null>(null);58const canvImagesRef = useRef<HTMLCanvasElement[]>([]);5960useEffect(() => {61const prefersReducedMotion = window.matchMedia(62'(prefers-reduced-motion: reduce)'63);64let canvas: HTMLCanvasElement | null = null;65let context: CanvasRenderingContext2D | null = null;66let width = window.innerWidth;67let height = window.innerHeight;6869const randomPositiveOrNegativeOne = () => (Math.random() < 0.5 ? -1 : 1);7071class Particle {72rotationSign: number;73age: number;74initialLifeSpan: number;75lifeSpan: number;76velocity: { x: number; y: number };77position: { x: number; y: number };78canv: HTMLCanvasElement;7980constructor(x: number, y: number, canvasItem: HTMLCanvasElement) {81const lifeSpan = characterLifeSpanFunction();82this.rotationSign = randomPositiveOrNegativeOne();83this.age = 0;84this.initialLifeSpan = lifeSpan;85this.lifeSpan = lifeSpan;86this.velocity = initialCharacterVelocityFunction();87this.position = {88x: x + cursorOffset.x,89y: y + cursorOffset.y,90};91this.canv = canvasItem;92}9394update(context: CanvasRenderingContext2D) {95this.position.x += this.velocity.x;96this.position.y += this.velocity.y;97this.lifeSpan--;98this.age++;99100this.velocity.x += characterVelocityChangeFunctions.x_func(101this.age,102this.initialLifeSpan103);104this.velocity.y += characterVelocityChangeFunctions.y_func(105this.age,106this.initialLifeSpan107);108109const scale = characterScalingFunction(this.age, this.initialLifeSpan);110111const degrees =112this.rotationSign *113characterNewRotationDegreesFunction(this.age, this.initialLifeSpan);114const radians = degrees * 0.0174533;115116context.translate(this.position.x, this.position.y);117context.rotate(radians);118119context.drawImage(120this.canv,121(-this.canv.width / 2) * scale,122-this.canv.height / 2,123this.canv.width * scale,124this.canv.height * scale125);126127context.rotate(-radians);128context.translate(-this.position.x, -this.position.y);129}130}131132const init = () => {133if (prefersReducedMotion.matches) {134console.log(135'This browser has prefers reduced motion turned on, so the cursor did not init'136);137return false;138}139140canvas = canvasRef.current;141if (!canvas) return;142143context = canvas.getContext('2d');144if (!context) return;145146canvas.style.top = '0px';147canvas.style.left = '0px';148canvas.style.pointerEvents = 'none';149150if (wrapperElement) {151canvas.style.position = 'absolute';152wrapperElement.appendChild(canvas);153canvas.width = wrapperElement.clientWidth;154canvas.height = wrapperElement.clientHeight;155} else {156canvas.style.position = 'fixed';157document.body.appendChild(canvas);158canvas.width = width;159canvas.height = height;160}161162context.font = font;163context.textBaseline = 'middle';164context.textAlign = 'center';165166characters.forEach((char) => {167let measurements = context.measureText(char);168let bgCanvas = document.createElement('canvas');169let bgContext = bgCanvas.getContext('2d');170171if (bgContext) {172bgCanvas.width = measurements.width;173bgCanvas.height = measurements.actualBoundingBoxAscent * 2.5;174175bgContext.textAlign = 'center';176bgContext.font = font;177bgContext.textBaseline = 'middle';178var randomColor = colors[Math.floor(Math.random() * colors.length)];179bgContext.fillStyle = randomColor;180181bgContext.fillText(182char,183bgCanvas.width / 2,184measurements.actualBoundingBoxAscent185);186187canvImagesRef.current.push(bgCanvas);188}189});190191bindEvents();192loop();193};194195const bindEvents = () => {196const element = wrapperElement || document.body;197element.addEventListener('mousemove', onMouseMove);198element.addEventListener('touchmove', onTouchMove, { passive: true });199element.addEventListener('touchstart', onTouchMove, { passive: true });200window.addEventListener('resize', onWindowResize);201};202203const onWindowResize = () => {204width = window.innerWidth;205height = window.innerHeight;206207if (!canvasRef.current) return;208209if (wrapperElement) {210canvasRef.current.width = wrapperElement.clientWidth;211canvasRef.current.height = wrapperElement.clientHeight;212} else {213canvasRef.current.width = width;214canvasRef.current.height = height;215}216};217218const onTouchMove = (e: TouchEvent) => {219if (e.touches.length > 0) {220for (let i = 0; i < e.touches.length; i++) {221addParticle(222e.touches[i].clientX,223e.touches[i].clientY,224canvImagesRef.current[225Math.floor(Math.random() * canvImagesRef.current.length)226]227);228}229}230};231232const onMouseMove = (e: MouseEvent) => {233if (wrapperElement) {234const boundingRect = wrapperElement.getBoundingClientRect();235cursorRef.current.x = e.clientX - boundingRect.left;236cursorRef.current.y = e.clientY - boundingRect.top;237} else {238cursorRef.current.x = e.clientX;239cursorRef.current.y = e.clientY;240}241242addParticle(243cursorRef.current.x,244cursorRef.current.y,245canvImagesRef.current[Math.floor(Math.random() * characters.length)]246);247};248249const addParticle = (x: number, y: number, img: HTMLCanvasElement) => {250particlesRef.current.push(new Particle(x, y, img));251};252253const updateParticles = () => {254if (!canvas || !context) return;255256if (particlesRef.current.length === 0) {257return;258}259260context.clearRect(0, 0, canvas.width, canvas.height);261262// Update263for (let i = 0; i < particlesRef.current.length; i++) {264particlesRef.current[i].update(context);265}266267// Remove dead particles268for (let i = particlesRef.current.length - 1; i >= 0; i--) {269if (particlesRef.current[i].lifeSpan < 0) {270particlesRef.current.splice(i, 1);271}272}273274if (particlesRef.current.length === 0) {275context.clearRect(0, 0, canvas.width, canvas.height);276}277};278279const loop = () => {280updateParticles();281animationFrameRef.current = requestAnimationFrame(loop);282};283284init();285286return () => {287if (canvas) {288canvas.remove();289}290if (animationFrameRef.current) {291cancelAnimationFrame(animationFrameRef.current);292}293const element = wrapperElement || document.body;294element.removeEventListener('mousemove', onMouseMove);295element.removeEventListener('touchmove', onTouchMove);296element.removeEventListener('touchstart', onTouchMove);297window.removeEventListener('resize', onWindowResize);298};299}, [300characters,301colors,302cursorOffset,303font,304characterLifeSpanFunction,305initialCharacterVelocityFunction,306characterVelocityChangeFunctions,307characterScalingFunction,308characterNewRotationDegreesFunction,309wrapperElement,310]);311312return <canvas ref={canvasRef} />;313};314315export default CharacterCursor;316
12## Props34| Prop | Type | Default | Description |5|---------------------------------|---------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|6| `characters` | `string[]` | `['h', 'e', 'l', 'l', 'o']` | Array of characters to display as the cursor effect. |7| `colors` | `string[]` | `['#6622CC', '#A755C2', '#B07C9E', '#B59194', '#D2A1B8']` | Array of colors for the characters. |8| `cursorOffset` | `{ x: number; y: number }` | `{ x: 0, y: 0 }` | Offset for the cursor position. |9| `font` | `string` | `'15px serif'` | Font style for the characters. |10| `characterLifeSpanFunction` | `() => number` | `() => Math.floor(Math.random() * 60 + 80)` | Function to determine the lifespan of each character in frames. |11| `initialCharacterVelocityFunction` | `() => { x: number; y: number }` | `() => ({ x: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5, y: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5 })` | Function to set the initial velocity of each character. |12| `characterVelocityChangeFunctions` | `{ x_func: (age: number, lifeSpan: number) => number; y_func: (age: number, lifeSpan: number) => number }` | `{ x_func: () => (Math.random() < 0.5 ? -1 : 1) / 30, y_func: () => (Math.random() < 0.5 ? -1 : 1) / 15 }` | Functions to update the velocity of each character over time for `x` and `y` axes. |13| `characterScalingFunction` | `(age: number, lifeSpan: number) => number` | `(age, lifeSpan) => Math.max(((lifeSpan - age) / lifeSpan) * 2, 0)` | Function to determine the scaling of each character based on its age and lifespan. |14| `characterNewRotationDegreesFunction` | `(age: number, lifeSpan: number) => number` | `(age, lifeSpan) => (lifeSpan - age) / 5` | Function to determine the rotation of each character in degrees based on its age and lifespan. |15| `wrapperElement` | `HTMLElement` | `undefined` | Element that wraps the canvas. Defaults to the full document body if not provided. |