Text animations
Sparkles Text
A client-side React component that adds a dynamic, animated sparkle effect to its children. It offers both a standard DOM and a high-performance Canvas rendering mode.
Installation
CLI
Installation via the CLI is coming soon. For now, please follow the manual installation instructions below.
Manual
- Install the following dependencies:
npm install @gsap/react gsap
yarn add @gsap/react gsap
pnpm add @gsap/react gsap
- Copy and paste the following code into your project.
'use client';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';
import React, { useMemo, useRef } from 'react';
import { cn } from '@/lib/utils';
interface SparkleData {
id: string;
color: string;
scale: number;
duration: number;
delay: number;
}
interface CanvasParticle {
x: number;
y: number;
vx: number;
vy: number;
opacity: number;
scale: number;
targetScale: number;
color: string;
}
/**
* Props for the SparklesText component.
*/
interface SparklesTextProps {
/** The content to be wrapped with the sparkle effect. */
children: React.ReactNode;
/** Optional class names to apply to the root container. */
className?: string;
/** The number of sparkles to render. */
sparklesCount?: number;
/** The two colors to randomly choose from for the sparkles. */
colors?: {
first: string;
second: string;
};
/** If true, sparkles will burst on mouse hover. */
interactive?: boolean;
/**
* If true, sparkles are masked to appear only on top of the text.
* @note This feature is only available in DOM rendering mode (`useCanvas={false}`).
*/
confineToText?: boolean;
/**
* If true, uses a `<canvas>` for rendering, which is more performant for high sparkle counts.
*/
useCanvas?: boolean;
}
const SparkleIcon = ({ color }: { color: string }) => (
<svg
className='pointer-events-none'
width='21'
height='21'
viewBox='0 0 21 21'
fill='none'
>
<path
d='M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z'
fill={color}
/>
</svg>
);
/**
* A client-side React component that adds a dynamic, animated sparkle effect to its children.
* It offers both a standard DOM and a high-performance Canvas rendering mode.
*/
export const SparklesText: React.FC<SparklesTextProps> = ({
children,
colors = { first: '#9E7AFF', second: '#FE8BBB' },
className,
sparklesCount = 12,
interactive = false,
confineToText = false,
useCanvas = false,
...props
}) => {
const containerRef = useRef<HTMLSpanElement>(null);
const sparklesContainerRef = useRef<HTMLSpanElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sparkles = useMemo<SparkleData[]>(() => {
return Array.from({ length: sparklesCount }, (_, i) => ({
id: `sparkle-${i}`,
color: Math.random() > 0.5 ? colors.first : colors.second,
scale: gsap.utils.random(0.3, 0.8, 0.1),
duration: gsap.utils.random(0.8, 1.8),
delay: gsap.utils.random(0, 2.5),
}));
}, [sparklesCount, colors.first, colors.second]);
useGSAP(
() => {
const mm = gsap.matchMedia();
mm.add('(prefers-reduced-motion: no-preference)', () => {
if (useCanvas) {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
const container = containerRef.current;
if (!canvas || !context || !container) return;
const dpr = window.devicePixelRatio || 1;
let ambientParticles: CanvasParticle[] = [];
let burstParticles: CanvasParticle[] = [];
const setupCanvas = () => {
const rect = container.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
context.scale(dpr, dpr);
};
const starPath = new Path2D(
'M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z'
);
const drawParticle = (p: CanvasParticle) => {
context.save();
context.translate(p.x, p.y);
context.scale(p.scale * 0.5, p.scale * 0.5);
context.fillStyle = p.color;
context.globalAlpha = p.opacity;
context.fill(starPath);
context.restore();
};
const createAmbientParticles = () => {
ambientParticles = [];
for (let i = 0; i < sparklesCount; i++) {
const p: CanvasParticle = {
x: gsap.utils.random(0, canvas.width / dpr),
y: gsap.utils.random(0, canvas.height / dpr),
vx: gsap.utils.random(-0.1, 0.1),
vy: gsap.utils.random(-0.1, 0.1),
opacity: 0,
scale: 0,
targetScale: gsap.utils.random(0.3, 0.8),
color: Math.random() > 0.5 ? colors.first : colors.second,
};
gsap
.timeline({
delay: gsap.utils.random(0, 3),
repeat: -1,
onRepeat: () => {
gsap.set(p, {
x: gsap.utils.random(0, canvas.width / dpr),
y: gsap.utils.random(0, canvas.height / dpr),
});
},
})
.to(p, {
opacity: 1,
scale: p.targetScale,
duration: gsap.utils.random(0.8, 1.5),
ease: 'power2.out',
yoyo: true,
repeat: 1,
});
ambientParticles.push(p);
}
};
const renderLoop = () => {
context.clearRect(0, 0, canvas.width, canvas.height);
ambientParticles.forEach((p) => {
p.x += p.vx;
p.y += p.vy;
drawParticle(p);
});
burstParticles.forEach((p) => drawParticle(p));
};
gsap.ticker.add(renderLoop);
if (interactive) {
const handleMouseMove = (e: MouseEvent) => {
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
for (let i = 0; i < 2; i++) {
const p: CanvasParticle = {
x,
y,
vx: 0,
vy: 0,
opacity: 1,
scale: 0.5,
targetScale: 0,
color: Math.random() > 0.5 ? colors.first : colors.second,
};
burstParticles.push(p);
gsap.to(p, {
x: `+=${gsap.utils.random(-30, 30)}`,
y: `+=${gsap.utils.random(-30, 30)}`,
opacity: 0,
scale: 0,
duration: 0.4,
ease: 'power1.out',
onComplete: () => {
burstParticles.splice(burstParticles.indexOf(p), 1);
},
});
}
};
const throttledMouseMove = (gsap.utils as any).throttle(
handleMouseMove,
75
);
container.addEventListener('mousemove', throttledMouseMove);
}
const debouncedResize = (gsap.utils as any).debounce(() => {
gsap.killTweensOf(ambientParticles);
setupCanvas();
createAmbientParticles();
}, 100);
window.addEventListener('resize', debouncedResize);
} else {
const sparkleElements =
gsap.utils.toArray<HTMLDivElement>('.sparkle-element');
sparkleElements.forEach((el, i) => {
const sparkleData = sparkles[i];
const timeline = gsap.timeline({
delay: sparkleData.delay,
onComplete: () => {
timeline
.set(el, {
xPercent: -50,
yPercent: -50,
top: `${gsap.utils.random(0, 100)}%`,
left: `${gsap.utils.random(0, 100)}%`,
scale: 0,
opacity: 0,
})
.restart();
},
});
timeline
.set(el, {
xPercent: -50,
yPercent: -50,
top: `${gsap.utils.random(0, 100)}%`,
left: `${gsap.utils.random(0, 100)}%`,
scale: 0,
opacity: 0,
})
.to(el, {
scale: sparkleData.scale,
opacity: 1,
rotate: gsap.utils.random(-60, 60),
duration: sparkleData.duration * 0.5,
ease: 'power2.out',
})
.to(
el,
{
x: `+=${gsap.utils.random(-10, 10)}`,
y: `+=${gsap.utils.random(-10, 10)}`,
duration: sparkleData.duration * 0.75,
},
'<'
)
.to(
el,
{
opacity: 0,
scale: 0,
duration: sparkleData.duration * 0.5,
ease: 'power2.in',
},
'>-0.2'
);
});
if (interactive) {
const container = containerRef.current;
if (!container) return;
const handleMouseMove = (e: MouseEvent) => {
const rect = container.getBoundingClientRect();
const burstEl = document.createElement('div');
burstEl.className =
'burst-sparkle pointer-events-none absolute z-50 top-0 left-0';
burstEl.innerHTML = `<svg class="pointer-events-none" width="21" height="21" viewBox="0 0 21 21" fill="none"><path d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z" fill="${
Math.random() > 0.5 ? colors.first : colors.second
}" /></svg>`;
container.appendChild(burstEl);
gsap.fromTo(
burstEl,
{
x: e.clientX - rect.left,
y: e.clientY - rect.top,
xPercent: -50,
yPercent: -50,
opacity: 1,
scale: 0.5,
rotate: gsap.utils.random(-180, 180),
},
{
x: `+=${gsap.utils.random(-30, 30)}`,
y: `+=${gsap.utils.random(-30, 30)}`,
opacity: 0,
scale: 0,
duration: 0.4,
ease: 'power1.out',
onComplete: () => {
burstEl.remove();
},
}
);
};
const throttledMouseMove = (gsap.utils as any).throttle(
handleMouseMove,
75
);
container.addEventListener('mousemove', throttledMouseMove);
}
}
});
},
{
scope: containerRef,
dependencies: [sparkles, interactive, useCanvas, confineToText],
}
);
return (
<span
ref={containerRef}
className={cn('relative inline-block', className)}
{...props}
>
<span className='invisible' aria-hidden='true'>
{children}
</span>
{useCanvas ? (
<>
<canvas
ref={canvasRef}
className='pointer-events-none absolute inset-0 z-10'
aria-hidden='true'
/>
<span className='relative z-20'>{children}</span>
</>
) : (
<span
ref={sparklesContainerRef}
aria-hidden='true'
className={cn(
'pointer-events-none absolute inset-0 z-10',
confineToText &&
'bg-clip-text text-transparent [-webkit-mask-image:linear-gradient(black,black)]'
)}
>
{sparkles.map((sparkle) => (
<div
key={sparkle.id}
className='sparkle-element pointer-events-none absolute z-20'
>
<SparkleIcon color={sparkle.color} />
</div>
))}
{children}
</span>
)}
</span>
);
};
Usage
Use the component to wrap any text or other elements you want to apply the sparkle effect to.
import { SparklesText } from '@/components/ui/sparkles-text';
export default function SparklesDemo() {
return (
<div className='flex flex-col items-center justify-center min-h-screen w-full bg-black text-white'>
<h1 className='text-5xl font-bold text-center mb-12'>
<SparklesText>Default Sparkles</SparklesText>
</h1>
<h1 className='text-5xl font-bold text-center'>
<SparklesText
interactive
confineToText
sparklesCount={25}
colors={{ first: '#4FC3F7', second: '#81C784' }}
>
Interactive & Confined
</SparklesText>
</h1>
</div>
);
}
Props
Prop | Type | Default | Description |
---|---|---|---|
children | React.ReactNode | Required | The content to be wrapped with the sparkle effect. |
className | string | Optional class names to apply to the root container. | |
sparklesCount | number | 12 | The number of sparkles to render. |
colors | { first: string; second: string; } | { first: '#9E7AFF', second: '#FE8BBB' } | The two colors to randomly choose from for the sparkles. |
interactive | boolean | false | If true, sparkles will burst on mouse hover. |
confineToText | boolean | false | If true, sparkles are masked to appear only on top of the text. (DOM mode only) |
useCanvas | boolean | false | If true, uses a <canvas> for rendering, which is more performant for high sparkle counts. |