Butterfly Trail Cursor
Butterfly Trail Cursor
Animated butterflies lift away from the pointer with soft wing motion and fading trails.
Move the pointer across the preview to release butterflies.
Component preview
Butterfly Trail Cursor
Install using CLI
npx shadcn@latest add "https://www.obsidianui.dev/r/butterfly-trail-cursor.json"Usage
Code
"use client";
import { ButterflyTrailCursor } from "@/components/block/butterfly-trail-cursor";
export default function Demo() {
return <ButterflyTrailCursor height={400} text="ObsidianUI, in flight." />;
}Install Manually
1
Install dependencies
npm install @react-three/drei @react-three/fiber clsx motion tailwind-merge three
2
Copy the source code
Copy into components/block/butterfly-trail-cursor.jsx
'use client'
/* eslint-disable react-hooks/immutability -- Three.js scene objects are mutable render resources owned by this component. */
import { Suspense, useEffect, useMemo, useRef } from 'react'
import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber'
import { useGLTF } from '@react-three/drei'
import * as THREE from 'three'
import { useReducedMotion } from 'motion/react'
import { cn } from '@/lib/utils'
import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js'
const BUTTERFLY_LIFETIME = 2
const FADE_DURATION = 0.5
const MAX_BUTTERFLIES = 200
const SPAWN_THROTTLE = 120
function ButterflyPool({ matcapMaterial, gltfScene, gltfAnimations, motionEnabled }) {
const poolRef = useRef([])
const lastSpawnTime = useRef(0)
const mixersRef = useRef([])
const { gl, viewport, clock } = useThree()
const poolGroup = useMemo(() => new THREE.Group(), [])
useEffect(() => {
const group = poolGroup
for (let index = 0;index < MAX_BUTTERFLIES;index += 1) {
const clone = SkeletonUtils.clone(gltfScene)
const instanceMaterial = matcapMaterial.clone()
instanceMaterial.transparent = true
instanceMaterial.opacity = 1
clone.traverse((child) => {
if (child.isMesh || child.isSkinnedMesh) child.material = instanceMaterial
})
clone.scale.setScalar(0.0012)
clone.visible = false
clone.userData = {
active: false,
direction: new THREE.Vector3(),
createdAt: 0,
initialRotation: new THREE.Euler(),
material: instanceMaterial,
}
const mixer = new THREE.AnimationMixer(clone)
if (gltfAnimations.length > 0) {
const action = mixer.clipAction(gltfAnimations[0])
action.play()
clone.userData.mixer = mixer
clone.userData.action = action
}
mixersRef.current.push(mixer)
group.add(clone)
poolRef.current.push(clone)
}
const pool = poolRef.current
const mixers = mixersRef.current
return () => {
for (const mixer of mixers) { mixer.stopAllAction(); mixer.uncacheRoot(mixer.getRoot()) }
for (const butterfly of pool) { butterfly.userData.material.dispose(); group.remove(butterfly) }
poolRef.current = []
mixersRef.current = []
}
}, [gltfAnimations, gltfScene, matcapMaterial, poolGroup])
useFrame((state, delta) => {
if (!motionEnabled) return
const now = state.clock.elapsedTime
for (const butterfly of poolRef.current) {
const data = butterfly.userData
if (!data.active) continue
const age = now - data.createdAt
const fadeStart = BUTTERFLY_LIFETIME - FADE_DURATION
if (age > fadeStart) {
const opacity = Math.max(0, 1 - (age - fadeStart) / FADE_DURATION)
data.material.opacity = opacity
if (data.action) data.action.timeScale = (1.6 + Math.random() * 0.8) * opacity
}
if (age > BUTTERFLY_LIFETIME) {
butterfly.visible = false
data.active = false
data.material.opacity = 1
continue
}
butterfly.position.x += data.direction.x * delta * 2
butterfly.position.y += data.direction.y * delta * 2
butterfly.position.z += data.direction.z * delta * 0.5
butterfly.rotation.x = data.initialRotation.x + Math.sin(now * 3 + butterfly.position.x) * 0.1
butterfly.rotation.y = data.initialRotation.y
butterfly.rotation.z = data.initialRotation.z
data.mixer?.update(delta)
}
})
useEffect(() => {
if (!motionEnabled) return
const surface = gl.domElement
const handleMouseMove = (event) => {
const now = Date.now()
if (now - lastSpawnTime.current < SPAWN_THROTTLE) return
lastSpawnTime.current = now
const rect = surface.getBoundingClientRect()
const x = ((event.clientX - rect.left) / rect.width - 0.5) * viewport.width
const y = (0.5 - (event.clientY - rect.top) / rect.height) * viewport.height
const count = 3 + Math.floor(Math.random() * 3)
const createdAt = clock.elapsedTime
let spawned = 0
for (const butterfly of poolRef.current) {
if (spawned >= count) break
const data = butterfly.userData
if (data.active) continue
const angle = Math.random() * Math.PI * 2
butterfly.position.set(
x + (Math.random() - 0.5) * 1.5,
y + (Math.random() - 0.5) * 1.5,
(Math.random() - 0.5) * 0.5
)
data.direction.set(
Math.cos(angle) * 0.75,
0.3 + Math.random() * 0.5,
(Math.random() - 0.5) * 0.3
)
data.initialRotation.set(
(Math.random() - 0.5) * Math.PI * 0.3,
Math.random() * Math.PI * 2,
(Math.random() - 0.5) * Math.PI * 0.2
)
butterfly.rotation.copy(data.initialRotation)
data.createdAt = createdAt
data.active = true
data.material.opacity = 1
butterfly.visible = true
if (data.action) {
data.action.timeScale = 1.6 + Math.random() * 0.8
data.action.time = Math.random() * 2
}
spawned += 1
}
}
surface.addEventListener('mousemove', handleMouseMove)
return () => surface.removeEventListener('mousemove', handleMouseMove)
}, [clock, gl, motionEnabled, viewport])
return <primitive object={poolGroup} />
}
function ButterflyTrail({ motionEnabled }) {
const matcapTexture = useLoader(THREE.TextureLoader, 'https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/butterfly-trail-cursor/butterfly-trail-cursor-matcap.webp')
const { scene, animations } = useGLTF('https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/butterfly-trail-cursor/butterfly3.glb')
const matcapMaterial = useMemo(
() =>
new THREE.MeshMatcapMaterial({
matcap: matcapTexture,
side: THREE.DoubleSide,
}),
[matcapTexture]
)
useEffect(() => () => matcapMaterial.dispose(), [matcapMaterial])
return <ButterflyPool matcapMaterial={matcapMaterial} gltfScene={scene} gltfAnimations={animations} motionEnabled={motionEnabled} />
}
/** @param {{ className?: string, height?: import("react").CSSProperties["height"], style?: import("react").CSSProperties, text?: string }} props */
export function ButterflyTrailCursor({ className, height = 400, style, text = "ObsidianUI, in flight." } = {}) {
const motionEnabled = !useReducedMotion()
return (
<div className={cn("relative isolate w-full overflow-hidden bg-[#EAEAE9]", className)} style={{ height, containerType: "inline-size", ...style }}>
<Canvas frameloop={motionEnabled ? "always" : "demand"} camera={{ position: [0, 0, 5], fov: 75 }} gl={{ antialias: false, powerPreference: 'high-performance', alpha: false }} dpr={[1, 1]} performance={{ min: 0.5 }}>
<color attach="background" args={['#EAEAE9']} />
<Suspense fallback={null}><ButterflyTrail motionEnabled={motionEnabled} /></Suspense>
</Canvas>
<p className="pointer-events-none absolute left-1/2 top-1/2 w-full -translate-x-1/2 -translate-y-1/2 text-center font-serif text-[clamp(22px,4cqw,44px)] text-zinc-600">
{text}
</p>
</div>
)
}3
Add the supporting file
Copy into lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}Default demo media loads from ObsidianUI. Replace these URLs with your own assets for offline use.
Preview behavior
Move the pointer across the preview to release butterflies. The effect stays inside its container and respects reduced motion preferences.
Last updated on