Textflag Cursor Effect

An interactive React component that adds a dynamic bubble effect, visually tracking cursor movement in real time.

1
'use client';
2
import { useTheme } from 'next-themes';
3
import React from 'react';
4
import TextFlagCursor from './TextFlagCursor';
5
6
function index() {
7
const { theme } = useTheme();
8
9
return (
10
<div>
11
<TextFlagCursor
12
text='Hello World'
13
color={theme === 'dark' ? '#FFFFFF' : '#000000'}
14
font='monospace'
15
textSize={12}
16
/>
17
</div>
18
);
19
}
20
21
export default index;
22

textflag-cursor

1
// @ts-nocheck
2
'use client';
3
4
import { useEffect, useRef } from 'react';
5
6
interface TextFlagOptions {
7
text?: string;
8
color?: string;
9
font?: string;
10
textSize?: number;
11
gap?: number;
12
element?: HTMLElement;
13
size?: number;
14
}
15
16
export const TextFlagCursor: React.FC<TextFlagOptions> = (options) => {
17
const cursorRef = useRef<{ destroy: () => void } | null>(null);
18
19
useEffect(() => {
20
let cursorOptions = options || {};
21
let hasWrapperEl = options && options.element;
22
let element = hasWrapperEl || document.body;
23
24
let text = cursorOptions.text ? ' ' + options.text : ' Your Text Here';
25
let color = options?.color || '#000000';
26
let font = cursorOptions.font || 'monospace';
27
let textSize = cursorOptions.textSize || 12;
28
let fontFamily = textSize + 'px ' + font;
29
let gap = cursorOptions.gap || textSize + 2;
30
let angle = 0;
31
let radiusX = 2;
32
let radiusY = 5;
33
let charArray = [];
34
35
let width = window.innerWidth;
36
let height = window.innerHeight;
37
let cursor = { x: width / 2, y: width / 2 };
38
39
for (let i = 0; i < text.length; i++) {
40
charArray[i] = { letter: text.charAt(i), x: width / 2, y: width / 2 };
41
}
42
43
let canvas: HTMLCanvasElement,
44
context: CanvasRenderingContext2D | null,
45
animationFrame: number;
46
47
// const size = options?.size || 3;
48
// let cursorsInitted = false;
49
50
const prefersReducedMotion = window.matchMedia(
51
'(prefers-reduced-motion: reduce)'
52
);
53
54
function init() {
55
if (prefersReducedMotion.matches) {
56
console.log(
57
'This browser has prefers reduced motion turned on, so the cursor did not init'
58
);
59
return false;
60
}
61
62
canvas = document.createElement('canvas');
63
context = canvas.getContext('2d');
64
canvas.style.top = '0px';
65
canvas.style.left = '0px';
66
canvas.style.pointerEvents = 'none';
67
68
if (hasWrapperEl) {
69
canvas.style.position = 'absolute';
70
element.appendChild(canvas);
71
canvas.width = element.clientWidth;
72
canvas.height = element.clientHeight;
73
} else {
74
canvas.style.position = 'fixed';
75
document.body.appendChild(canvas);
76
canvas.width = width;
77
canvas.height = height;
78
}
79
80
bindEvents();
81
loop();
82
}
83
84
function bindEvents() {
85
element.addEventListener('mousemove', onMouseMove);
86
window.addEventListener('resize', onWindowResize);
87
}
88
89
function onWindowResize() {
90
width = window.innerWidth;
91
height = window.innerHeight;
92
93
if (hasWrapperEl) {
94
canvas.width = element.clientWidth;
95
canvas.height = element.clientHeight;
96
} else {
97
canvas.width = width;
98
canvas.height = height;
99
}
100
}
101
102
function onMouseMove(e: MouseEvent) {
103
if (hasWrapperEl) {
104
const boundingRect = element.getBoundingClientRect();
105
cursor.x = e.clientX - boundingRect.left;
106
cursor.y = e.clientY - boundingRect.top;
107
} else {
108
cursor.x = e.clientX;
109
cursor.y = e.clientY;
110
}
111
}
112
113
function updateParticles() {
114
if (!context) return;
115
context.clearRect(0, 0, width, height);
116
117
angle += 0.15;
118
let locX = radiusX * Math.cos(angle);
119
let locY = radiusY * Math.sin(angle);
120
121
for (let i = charArray.length - 1; i > 0; i--) {
122
charArray[i].x = charArray[i - 1].x + gap;
123
charArray[i].y = charArray[i - 1].y;
124
125
context.fillStyle = color;
126
context.font = fontFamily;
127
context.fillText(charArray[i].letter, charArray[i].x, charArray[i].y);
128
}
129
130
let x1 = charArray[0].x;
131
let y1 = charArray[0].y;
132
x1 += (cursor.x - x1) / 5 + locX + 2;
133
y1 += (cursor.y - y1) / 5 + locY;
134
charArray[0].x = x1;
135
charArray[0].y = y1;
136
}
137
138
function loop() {
139
updateParticles();
140
animationFrame = requestAnimationFrame(loop);
141
}
142
143
function destroy() {
144
canvas.remove();
145
cancelAnimationFrame(animationFrame);
146
element.removeEventListener('mousemove', onMouseMove);
147
window.addEventListener('resize', onWindowResize);
148
}
149
150
const handleReducedMotionChange = () => {
151
if (prefersReducedMotion.matches) {
152
destroy();
153
} else {
154
init();
155
}
156
};
157
158
prefersReducedMotion.addEventListener('change', handleReducedMotionChange);
159
init();
160
161
cursorRef.current = { destroy };
162
163
return () => {
164
if (cursorRef.current) {
165
cursorRef.current.destroy();
166
}
167
prefersReducedMotion.removeEventListener(
168
'change',
169
handleReducedMotionChange
170
);
171
};
172
}, [options]);
173
174
return null;
175
};
176
177
export default TextFlagCursor;

Props

PropTypeDefaultDescription
classnamestringOptional CSS class for styling the main vignette container.
childrenReact.ReactNodeThe content to display inside the vignette effect.
radiusstring24pxThe radius for the vignette effect.
insetstring20pxThe inset value for the vignette effect.
transitionLengthstring44pxThe length of the transition effect applied to the vignette.
blurstring6pxThe blur amount for the vignette effect.
blurclassnamestringOptional CSS class for styling the blur effect container.