Skip to content
Skip to Content
DocsButterfly Trail Cursor
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