Cursor Attractor and Gravity
Cursor Attractor and Gravity is a React and Tailwind CSS component from Fancy Components, licensed MIT. Copy it and paste it into your project.
Fancy Components
MIT
other
What it is
Cursor Attractor and Gravity is a React and Tailwind CSS component from Fancy Components, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i lodash matter-js svg-path-commander poly-decomp @types/matter-js
Licence
This component comes from Fancy Components and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Cursor Attractor and Gravity
"use client"
import {
createContext,
forwardRef,
ReactNode,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { calculatePosition } from "@/utils/calculate-position"
import { parsePathToVertices } from "@/utils/svg-path-to-vertices"
import { debounce } from "lodash"
import Matter, {
Bodies,
Common,
Engine,
Events,
Render,
Runner,
World,
Body,
} from "matter-js"
import { cn } from "@/lib/utils"
import { useMousePositionRef } from "@/hooks/use-mouse-position-ref"
type GravityProps = {
children: ReactNode
debug?: boolean
attractorPoint?: { x: number | string; y: number | string }
attractorStrength?: number
cursorStrength?: number
cursorFieldRadius?: number
resetOnResize?: boolean
addTopWall?: boolean
autoStart?: boolean
className?: string
}
type PhysicsBody = {
element: HTMLElement
body: Matter.Body
props: MatterBodyProps
}
type MatterBodyProps = {
children: ReactNode
matterBodyOptions?: Matter.IBodyDefinition
isDraggable?: boolean
bodyType?: "rectangle" | "circle" | "svg"
sampleLength?: number
x?: number | string
y?: number | string
angle?: number
className?: string
}
export type GravityRef = {
start: () => void
stop: () => void
reset: () => void
}
const GravityContext = createContext<{
registerElement: (
id: string,
element: HTMLElement,
props: MatterBodyProps
) => void
unregisterElement: (id: string) => void
} | null>(null)
export const MatterBody = ({
children,
className,
matterBodyOptions = {
friction: 0.1,
restitution: 0.1,
density: 0.001,
isStatic: false,
},
bodyType = "rectangle",
isDraggable = true,
sampleLength = 15,
x = 0,
y = 0,
angle = 0,
...props
}: MatterBodyProps) => {
const elementRef = useRef<HTMLDivElement>(null)
const idRef = useRef(Math.random().toString(36).substring(7))
const context = useContext(GravityContext)
useEffect(() => {
if (!elementRef.current || !context) return
context.registerElement(idRef.current, elementRef.current, {
children,
matterBodyOptions,
bodyType,
sampleLength,
isDraggable,
x,
y,
angle,
...props,
})
return () => context.unregisterElement(idRef.current)
}, [props, children, matterBodyOptions, isDraggable])
return (
<div
ref={elementRef}
className={cn(
"absolute",
className,
)}
>
{children}
</div>
)
}
const Gravity = forwardRef<GravityRef, GravityProps>(
(
{
children,
debug = false,
attractorPoint = { x: 0.5, y: 0.5 },
attractorStrength = 0.001,
cursorStrength = 0.0005,
cursorFieldRadius = 100,
resetOnResize = true,
addTopWall = true,
autoStart = true,
className,
...props
},
ref
) => {
const canvas = useRef<HTMLDivElement>(null)
const engine = useRef(Engine.create())
const render = useRef<Render>(undefined)
const runner = useRef<Runner>(undefined)
const bodiesMap = useRef(new Map<string, PhysicsBody>())
const frameId = useRef<number>(undefined)
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
const mouseRef = useMousePositionRef(canvas)
const isRunning = useRef(false)
// Register Matter.js body in the physics world
const registerElement = useCallback(
(id: string, element: HTMLElement, props: MatterBodyProps) => {
if (!canvas.current) return
const width = element.offsetWidth
const height = element.offsetHeight
const canvasRect = canvas.current!.getBoundingClientRect()
const angle = (props.angle || 0) * (Math.PI / 180)
const x = calculatePosition(props.x, canvasRect.width, width)
const y = calculatePosition(props.y, canvasRect.height, height)
let body
if (props.bodyType === "circle") {
const radius = Math.max(width, height) / 2
body = Bodies.circle(x, y, radius, {
...props.matterBodyOptions,
angle: angle,
render: {
fillStyle: debug ? "#888888" : "#00000000",
strokeStyle: debug ? "#333333" : "#00000000",
lineWidth: debug ? 3 : 0,
},
})
} else if (props.bodyType === "svg") {
const paths = element.querySelectorAll("path")
const vertexSets: Matter.Vector[][] = []
paths.forEach((path) => {
const d = path.getAttribute("d")
const p = parsePathToVertices(d!, props.sampleLength)
vertexSets.push(p)
})
body = Bodies.fromVertices(x, y, vertexSets, {
...props.matterBodyOptions,
angle: angle,
render: {
fillStyle: debug ? "#888888" : "#00000000",
strokeStyle: debug ? "#333333" : "#00000000",
lineWidth: debug ? 3 : 0,
},
})
} else {
body = Bodies.rectangle(x, y, width, height, {
...props.matterBodyOptions,
angle: angle,
render: {
fillStyle: debug ? "#888888" : "#00000000",
strokeStyle: debug ? "#333333" : "#00000000",
lineWidth: debug ? 3 : 0,
},
})
}
if (body) {
World.add(engine.current.world, [body])
bodiesMap.current.set(id, { element, body, props })
}
},
[debug]
)
// Unregister Matter.js body from the physics world
const unregisterElement = useCallback((id: string) => {
const body = bodiesMap.current.get(id)
if (body) {
World.remove(engine.current.world, body.body)
bodiesMap.current.delete(id)
}
}, [])
// Keep react elements in sync with the physics world
const updateElements = useCallback(() => {
bodiesMap.current.forEach(({ element, body }) => {
const { x, y } = body.position
const rotation = body.angle * (180 / Math.PI)
element.style.transform = `translate(${
x - element.offsetWidth / 2
}px, ${y - element.offsetHeight / 2}px) rotate(${rotation}deg)`
})
frameId.current = requestAnimationFrame(updateElements)
}, [])
const initializeRenderer = useCallback(() => {
if (!canvas.current) return
const height = canvas.current.offsetHeight
const width = canvas.current.offsetWidth
Common.setDecomp(require("poly-decomp"))
// Remove default gravity
engine.current.gravity.x = 0
engine.current.gravity.y = 0
render.current = Render.create({
element: canvas.current,
engine: engine.current,
options: {
width,
height,
wireframes: false,
background: "#00000000",
},
})
// Add walls
const walls = [
// Floor
Bodies.rectangle(width / 2, height + 10, width, 20, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
}),
// Right wall
Bodies.rectangle(width + 10, height / 2, 20, height, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
}),
// Left wall
Bodies.rectangle(-10, height / 2, 20, height, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
}),
]
const topWall = addTopWall
? Bodies.rectangle(width / 2, -10, width, 20, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
})
: null
if (topWall) {
walls.push(topWall)
}
World.add(engine.current.world, [...walls])
runner.current = Runner.create()
Render.run(render.current)
updateElements()
runner.current.enabled = false
if (autoStart) {
runner.current.enabled = true
startEngine()
}
// Add force application before update
Events.on(engine.current, "beforeUpdate", () => {
const bodies = engine.current.world.bodies.filter(
(body) => !body.isStatic
)
// Calculate attractor position in pixels
const attractorX = typeof attractorPoint.x === 'string'
? (width * parseFloat(attractorPoint.x) / 100)
: width * attractorPoint.x
const attractorY = typeof attractorPoint.y === 'string'
? (height * parseFloat(attractorPoint.y) / 100)
: height * attractorPoint.y
bodies.forEach((body) => {
// Apply attractor force
const dx = attractorX - body.position.x
const dy = attractorY - body.position.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > 0) {
const force = {
x: (dx / distance) * attractorStrength * body.mass,
y: (dy / distance) * attractorStrength * body.mass,
}
Body.applyForce(body, body.position, force)
}
// Apply cursor force if mouse is present
if (mouseRef.current?.x && mouseRef.current?.y && mouseRef.current.x > 0 && mouseRef.current.y > 0) {
const mdx = mouseRef.current.x - body.position.x
const mdy = mouseRef.current.y - body.position.y
const mouseDistance = Math.sqrt(mdx * mdx + mdy * mdy)
if (mouseDistance > 0 && mouseDistance < cursorFieldRadius) {
const mouseForce = {
x: (mdx / mouseDistance) * cursorStrength * body.mass,
y: (mdy / mouseDistance) * cursorStrength * body.mass,
}
Body.applyForce(body, body.position, mouseForce)
}
}
})
})
}, [updateElements, debug, autoStart, attractorPoint, attractorStrength, cursorStrength])
// Clear the Matter.js world
const clearRenderer = useCallback(() => {
if (frameId.current) {
cancelAnimationFrame(frameId.current)
}
if (render.current) {
Render.stop(render.current)
render.current.canvas.remove()
}
if (runner.current) {
Runner.stop(runner.current)
}
if (engine.current) {
World.clear(engine.current.world, false)
Engine.clear(engine.current)
}
bodiesMap.current.clear()
}, [])
const handleResize = useCallback(() => {
if (!canvas.current || !resetOnResize) return
const newWidth = canvas.current.offsetWidth
const newHeight = canvas.current.offsetHeight
setCanvasSize({ width: newWidth, height: newHeight })
// Clear and reinitialize
clearRenderer()
initializeRenderer()
}, [clearRenderer, initializeRenderer, resetOnResize])
const startEngine = useCallback(() => {
if (runner.current) {
runner.current.enabled = true
Runner.run(runner.current, engine.current)
}
if (render.current) {
Render.run(render.current)
}
frameId.current = requestAnimationFrame(updateElements)
isRunning.current = true
}, [updateElements, canvasSize])
const stopEngine = useCallback(() => {
if (!isRunning.current) return
if (runner.current) {
Runner.stop(runner.current)
}
if (render.current) {
Render.stop(render.current)
}
if (frameId.current) {
cancelAnimationFrame(frameId.current)
}
isRunning.current = false
}, [])
const reset = useCallback(() => {
stopEngine()
bodiesMap.current.forEach(({ element, body, props }) => {
body.angle = props.angle || 0
const x = calculatePosition(
props.x,
canvasSize.width,
element.offsetWidth
)
const y = calculatePosition(
props.y,
canvasSize.height,
element.offsetHeight
)
body.position.x = x
body.position.y = y
})
updateElements()
handleResize()
}, [])
useImperativeHandle(
ref,
() => ({
start: startEngine,
stop: stopEngine,
reset,
}),
[startEngine, stopEngine]
)
useEffect(() => {
if (!resetOnResize) return
const debouncedResize = debounce(handleResize, 500)
window.addEventListener("resize", debouncedResize)
return () => {
window.removeEventListener("resize", debouncedResize)
debouncedResize.cancel()
}
}, [handleResize, resetOnResize])
useEffect(() => {
initializeRenderer()
return clearRenderer
}, [initializeRenderer, clearRenderer])
return (
<GravityContext.Provider value={{ registerElement, unregisterElement }}>
<div
ref={canvas}
className={cn(className, "absolute top-0 left-0 w-full h-full")}
{...props}
>
{children}
</div>
</GravityContext.Provider>
)
}
)
Gravity.displayName = "Gravity"
export default Gravity