Character Cursor Effect
A React component that tracks cursor movement and creates characters that radiate outward, generating a dynamic and expanding effect.
A React component that tracks cursor movement and creates characters that radiate outward, generating a dynamic and expanding effect.
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 };20zIndex?: number;21font?: string;22characterLifeSpanFunction?: () => number;23initialCharacterVelocityFunction?: () => { x: number; y: number };24characterVelocityChangeFunctions?: {25x_func: (age: number, lifeSpan: number) => number;26y_func: (age: number, lifeSpan: number) => number;27};28characterScalingFunction?: (age: number, lifeSpan: number) => number;29characterNewRotationDegreesFunction?: (30age: number,31lifeSpan: number32) => number;33wrapperElement?: HTMLElement;34}3536const CharacterCursor: React.FC<CharacterCursorProps> = ({37characters = ['h', 'e', 'l', 'l', 'o'],38colors = ['#6622CC', '#A755C2', '#B07C9E', '#B59194', '#D2A1B8'],39cursorOffset = { x: 0, y: 0 },40font = '15px serif',41characterLifeSpanFunction = () => Math.floor(Math.random() * 60 + 80),42initialCharacterVelocityFunction = () => ({43x: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5,44y: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5,45}),46characterVelocityChangeFunctions = {47x_func: () => (Math.random() < 0.5 ? -1 : 1) / 30,48y_func: () => (Math.random() < 0.5 ? -1 : 1) / 15,49},50characterScalingFunction = (age, lifeSpan) =>51Math.max(((lifeSpan - age) / lifeSpan) * 2, 0),52characterNewRotationDegreesFunction = (age, lifeSpan) => (lifeSpan - age) / 5,53wrapperElement,54zIndex,55}) => {56const canvasRef = useRef<HTMLCanvasElement | null>(null);57const particlesRef = useRef<Particle[]>([]);58const cursorRef = useRef({ x: 0, y: 0 });59const animationFrameRef = useRef<number | null>(null);60const canvImagesRef = useRef<HTMLCanvasElement[]>([]);6162useEffect(() => {63const prefersReducedMotion = window.matchMedia(64'(prefers-reduced-motion: reduce)'65);66let canvas: HTMLCanvasElement | null = null;67let context: CanvasRenderingContext2D | null = null;68let width = window.innerWidth;69let height = window.innerHeight;7071const randomPositiveOrNegativeOne = () => (Math.random() < 0.5 ? -1 : 1);7273class Particle {74rotationSign: number;75age: number;76initialLifeSpan: number;77lifeSpan: number;78velocity: { x: number; y: number };79position: { x: number; y: number };80canv: HTMLCanvasElement;8182constructor(x: number, y: number, canvasItem: HTMLCanvasElement) {83const lifeSpan = characterLifeSpanFunction();84this.rotationSign = randomPositiveOrNegativeOne();85this.age = 0;86this.initialLifeSpan = lifeSpan;87this.lifeSpan = lifeSpan;88this.velocity = initialCharacterVelocityFunction();89this.position = {90x: x + cursorOffset.x,91y: y + cursorOffset.y,92};93this.canv = canvasItem;94}9596update(context: CanvasRenderingContext2D) {97this.position.x += this.velocity.x;98this.position.y += this.velocity.y;99this.lifeSpan--;100this.age++;101102this.velocity.x += characterVelocityChangeFunctions.x_func(103this.age,104this.initialLifeSpan105);106this.velocity.y += characterVelocityChangeFunctions.y_func(107this.age,108this.initialLifeSpan109);110111const scale = characterScalingFunction(this.age, this.initialLifeSpan);112113const degrees =114this.rotationSign *115characterNewRotationDegreesFunction(this.age, this.initialLifeSpan);116const radians = degrees * 0.0174533;117118context.translate(this.position.x, this.position.y);119context.rotate(radians);120121context.drawImage(122this.canv,123(-this.canv.width / 2) * scale,124-this.canv.height / 2,125this.canv.width * scale,126this.canv.height * scale127);128129context.rotate(-radians);130context.translate(-this.position.x, -this.position.y);131}132}133134const init = () => {135if (prefersReducedMotion.matches) {136console.log(137'This browser has prefers reduced motion turned on, so the cursor did not init'138);139return false;140}141142canvas = canvasRef.current;143if (!canvas) return;144145context = canvas.getContext('2d');146if (!context) return;147148canvas.style.top = '0px';149canvas.style.left = '0px';150canvas.style.pointerEvents = 'none';151canvas.style.zIndex = zIndex ? zIndex.toString() : '';152153if (wrapperElement) {154canvas.style.position = 'absolute';155wrapperElement.appendChild(canvas);156canvas.width = wrapperElement.clientWidth;157canvas.height = wrapperElement.clientHeight;158} else {159canvas.style.position = 'fixed';160document.body.appendChild(canvas);161canvas.width = width;162canvas.height = height;163}164165context.font = font;166context.textBaseline = 'middle';167context.textAlign = 'center';168169characters.forEach((char) => {170let measurements = context.measureText(char);171let bgCanvas = document.createElement('canvas');172let bgContext = bgCanvas.getContext('2d');173174if (bgContext) {175bgCanvas.width = measurements.width;176bgCanvas.height = measurements.actualBoundingBoxAscent * 2.5;177178bgContext.textAlign = 'center';179bgContext.font = font;180bgContext.textBaseline = 'middle';181var randomColor = colors[Math.floor(Math.random() * colors.length)];182bgContext.fillStyle = randomColor;183184bgContext.fillText(185char,186bgCanvas.width / 2,187measurements.actualBoundingBoxAscent188);189190canvImagesRef.current.push(bgCanvas);191}192});193194bindEvents();195loop();196};197198const bindEvents = () => {199const element = wrapperElement || document.body;200element.addEventListener('mousemove', onMouseMove);201element.addEventListener('touchmove', onTouchMove, { passive: true });202element.addEventListener('touchstart', onTouchMove, { passive: true });203window.addEventListener('resize', onWindowResize);204};205206const onWindowResize = () => {207width = window.innerWidth;208height = window.innerHeight;209210if (!canvasRef.current) return;211212if (wrapperElement) {213canvasRef.current.width = wrapperElement.clientWidth;214canvasRef.current.height = wrapperElement.clientHeight;215} else {216canvasRef.current.width = width;217canvasRef.current.height = height;218}219};220221const onTouchMove = (e: TouchEvent) => {222if (e.touches.length > 0) {223for (let i = 0; i < e.touches.length; i++) {224addParticle(225e.touches[i].clientX,226e.touches[i].clientY,227canvImagesRef.current[228Math.floor(Math.random() * canvImagesRef.current.length)229]230);231}232}233};234235const onMouseMove = (e: MouseEvent) => {236if (wrapperElement) {237const boundingRect = wrapperElement.getBoundingClientRect();238cursorRef.current.x = e.clientX - boundingRect.left;239cursorRef.current.y = e.clientY - boundingRect.top;240} else {241cursorRef.current.x = e.clientX;242cursorRef.current.y = e.clientY;243}244245addParticle(246cursorRef.current.x,247cursorRef.current.y,248canvImagesRef.current[Math.floor(Math.random() * characters.length)]249);250};251252const addParticle = (x: number, y: number, img: HTMLCanvasElement) => {253particlesRef.current.push(new Particle(x, y, img));254};255256const updateParticles = () => {257if (!canvas || !context) return;258259if (particlesRef.current.length === 0) {260return;261}262263context.clearRect(0, 0, canvas.width, canvas.height);264265// Update266for (let i = 0; i < particlesRef.current.length; i++) {267particlesRef.current[i].update(context);268}269270// Remove dead particles271for (let i = particlesRef.current.length - 1; i >= 0; i--) {272if (particlesRef.current[i].lifeSpan < 0) {273particlesRef.current.splice(i, 1);274}275}276277if (particlesRef.current.length === 0) {278context.clearRect(0, 0, canvas.width, canvas.height);279}280};281282const loop = () => {283updateParticles();284animationFrameRef.current = requestAnimationFrame(loop);285};286287init();288289return () => {290if (canvas) {291canvas.remove();292}293if (animationFrameRef.current) {294cancelAnimationFrame(animationFrameRef.current);295}296const element = wrapperElement || document.body;297element.removeEventListener('mousemove', onMouseMove);298element.removeEventListener('touchmove', onTouchMove);299element.removeEventListener('touchstart', onTouchMove);300window.removeEventListener('resize', onWindowResize);301};302}, [303characters,304colors,305cursorOffset,306font,307characterLifeSpanFunction,308initialCharacterVelocityFunction,309characterVelocityChangeFunctions,310characterScalingFunction,311characterNewRotationDegreesFunction,312wrapperElement,313]);314315return <canvas ref={canvasRef} />;316};317318export default CharacterCursor;319
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. |