import { useRef, useState, useEffect } from 'react';
import Matter from 'matter-js';
const FallingText = ({
text = '',
highlightWords = [],
trigger = 'auto',
backgroundColor = 'transparent',
wireframes = false,
gravity = 1,
mouseConstraintStiffness = 0.2,
fontSize = '1rem'
}) => {
const containerRef = useRef(null);
const textRef = useRef(null);
const canvasContainerRef = useRef(null);
const [effectStarted, setEffectStarted] = useState(false);
useEffect(() => {
if (!textRef.current) return;
const words = text.split(' ');
const newHTML = words
.map(word => {
const isHighlighted = highlightWords.some(hw => word.startsWith(hw));
return `<span
class="inline-block mx-[2px] select-none ${isHighlighted ? 'text-cyan-500 font-bold' : ''}"
>
${word}
</span>`;
})
.join(' ');
textRef.current.innerHTML = newHTML;
}, [text, highlightWords]);
useEffect(() => {
if (trigger === 'auto') {
setEffectStarted(true);
return;
}
if (trigger === 'scroll' && containerRef.current) {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setEffectStarted(true);
observer.disconnect();
}
},
{ threshold: 0.1 }
);
observer.observe(containerRef.current);
return () => observer.disconnect();
}
}, [trigger]);
useEffect(() => {
if (!effectStarted) return;
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter;
const containerRect = containerRef.current.getBoundingClientRect();
const width = containerRect.width;
const height = containerRect.height;
if (width <= 0 || height <= 0) return;
const engine = Engine.create();
engine.world.gravity.y = gravity;
const render = Render.create({
element: canvasContainerRef.current,
engine,
options: {
width,
height,
background: backgroundColor,
wireframes
}
});
const boundaryOptions = {
isStatic: true,
render: { fillStyle: 'transparent' }
};
const floor = Bodies.rectangle(width / 2, height + 25, width, 50, boundaryOptions);
const leftWall = Bodies.rectangle(-25, height / 2, 50, height, boundaryOptions);
const rightWall = Bodies.rectangle(width + 25, height / 2, 50, height, boundaryOptions);
const ceiling = Bodies.rectangle(width / 2, -25, width, 50, boundaryOptions);
const wordSpans = textRef.current.querySelectorAll('span');
const wordBodies = [...wordSpans].map(elem => {
const rect = elem.getBoundingClientRect();
const x = rect.left - containerRect.left + rect.width / 2;
const y = rect.top - containerRect.top + rect.height / 2;
const body = Bodies.rectangle(x, y, rect.width, rect.height, {
render: { fillStyle: 'transparent' },
restitution: 0.8,
frictionAir: 0.01,
friction: 0.2
});
Matter.Body.setVelocity(body, {
x: (Math.random() - 0.5) * 5,
y: 0
});
Matter.Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.05);
return { elem, body };
});
wordBodies.forEach(({ elem, body }) => {
elem.style.position = 'absolute';
elem.style.left = `${body.position.x - body.bounds.max.x + body.bounds.min.x / 2}px`;
elem.style.top = `${body.position.y - body.bounds.max.y + body.bounds.min.y / 2}px`;
elem.style.transform = 'none';
});
const mouse = Mouse.create(containerRef.current);
const mouseConstraint = MouseConstraint.create(engine, {
mouse,
constraint: {
stiffness: mouseConstraintStiffness,
render: { visible: false }
}
});
render.mouse = mouse;
World.add(engine.world, [floor, leftWall, rightWall, ceiling, mouseConstraint, ...wordBodies.map(wb => wb.body)]);
const runner = Runner.create();
Runner.run(runner, engine);
Render.run(render);
const updateLoop = () => {
wordBodies.forEach(({ body, elem }) => {
const { x, y } = body.position;
elem.style.left = `${x}px`;
elem.style.top = `${y}px`;
elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`;
});
Matter.Engine.update(engine);
requestAnimationFrame(updateLoop);
};
updateLoop();
return () => {
Render.stop(render);
Runner.stop(runner);
if (render.canvas && canvasContainerRef.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
canvasContainerRef.current.removeChild(render.canvas);
}
World.clear(engine.world);
Engine.clear(engine);
};
}, [effectStarted, gravity, wireframes, backgroundColor, mouseConstraintStiffness]);
const handleTrigger = () => {
if (!effectStarted && (trigger === 'click' || trigger === 'hover')) {
setEffectStarted(true);
}
};
return (
<div
ref={containerRef}
className="relative z-[1] w-full h-full cursor-pointer text-center pt-8 overflow-hidden"
onClick={trigger === 'click' ? handleTrigger : undefined}
onMouseEnter={trigger === 'hover' ? handleTrigger : undefined}
>
<div
ref={textRef}
className="inline-block"
style={{
fontSize,
lineHeight: 1.4
}}
/>
<div className="absolute top-0 left-0 z-0" ref={canvasContainerRef} />
</div>
);
};
export default FallingText;