Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/www/content/docs/components/smooth-cursor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ Compatible with all modern browsers that support:
- `requestAnimationFrame`
- CSS transforms
- Pointer events
- Hover-capable fine pointers (mouse or trackpad)

Touch-first devices are ignored automatically to prevent the custom cursor from appearing after taps.

## Accessibility

Expand Down
77 changes: 63 additions & 14 deletions apps/www/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14087,7 +14087,7 @@ Description: A customizable, physics-based smooth cursor animation component wit
--- file: magicui/smooth-cursor.tsx ---
"use client"

import { FC, useEffect, useRef } from "react"
import { FC, useEffect, useRef, useState } from "react"
import { motion, useSpring } from "motion/react"

interface Position {
Expand All @@ -14105,6 +14105,12 @@ export interface SmoothCursorProps {
}
}

const DESKTOP_POINTER_QUERY = "(any-hover: hover) and (any-pointer: fine)"

function isTrackablePointer(pointerType: string) {
return pointerType !== "touch"
}

const DefaultCursorSVG: FC = () => {
return (
<svg
Expand Down Expand Up @@ -14181,6 +14187,8 @@ export function SmoothCursor({
const lastUpdateTime = useRef(Date.now())
const previousAngle = useRef(0)
const accumulatedRotation = useRef(0)
const [isEnabled, setIsEnabled] = useState(false)
const [isVisible, setIsVisible] = useState(false)

const cursorX = useSpring(0, springConfig)
const cursorY = useSpring(0, springConfig)
Expand All @@ -14196,6 +14204,30 @@ export function SmoothCursor({
})

useEffect(() => {
const mediaQuery = window.matchMedia(DESKTOP_POINTER_QUERY)

const updateEnabled = () => {
const nextIsEnabled = mediaQuery.matches
setIsEnabled(nextIsEnabled)

if (!nextIsEnabled) {
setIsVisible(false)
}
}

updateEnabled()
mediaQuery.addEventListener("change", updateEnabled)

return () => {
mediaQuery.removeEventListener("change", updateEnabled)
}
}, [])

useEffect(() => {
if (!isEnabled) {
return
}

let timeout: ReturnType<typeof setTimeout> | null = null

const updateVelocity = (currentPos: Position) => {
Expand All @@ -14213,7 +14245,13 @@ export function SmoothCursor({
lastMousePos.current = currentPos
}

const smoothMouseMove = (e: MouseEvent) => {
const smoothPointerMove = (e: PointerEvent) => {
if (!isTrackablePointer(e.pointerType)) {
return
}

setIsVisible(true)

const currentPos = { x: e.clientX, y: e.clientY }
updateVelocity(currentPos)

Expand Down Expand Up @@ -14248,28 +14286,38 @@ export function SmoothCursor({
}
}

let rafId: number
const throttledMouseMove = (e: MouseEvent) => {
let rafId = 0
const throttledPointerMove = (e: PointerEvent) => {
if (!isTrackablePointer(e.pointerType)) {
return
}

if (rafId) return

rafId = requestAnimationFrame(() => {
smoothMouseMove(e)
smoothPointerMove(e)
rafId = 0
})
}

document.body.style.cursor = "none"
window.addEventListener("mousemove", throttledMouseMove)
window.addEventListener("pointermove", throttledPointerMove, {
passive: true,
})

return () => {
window.removeEventListener("mousemove", throttledMouseMove)
window.removeEventListener("pointermove", throttledPointerMove)
document.body.style.cursor = "auto"
if (rafId) cancelAnimationFrame(rafId)
if (timeout !== null) {
clearTimeout(timeout)
}
}
}, [cursorX, cursorY, rotation, scale])
}, [cursorX, cursorY, rotation, scale, isEnabled])

if (!isEnabled) {
return null
}

return (
<motion.div
Expand All @@ -14284,13 +14332,12 @@ export function SmoothCursor({
zIndex: 100,
pointerEvents: "none",
willChange: "transform",
opacity: isVisible ? 1 : 0,
}}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
initial={false}
animate={{ opacity: isVisible ? 1 : 0 }}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
duration: 0.15,
}}
>
{cursor}
Expand All @@ -14309,7 +14356,9 @@ export default function SmoothCursorDemo() {
return (
<>
<span className="hidden md:block">Move your mouse around</span>
<span className="block md:hidden">Tap anywhere to see the cursor</span>
<span className="block md:hidden">
SmoothCursor is disabled on touch devices
</span>
<SmoothCursor />
</>
)
Expand Down
2 changes: 1 addition & 1 deletion apps/www/public/r/smooth-cursor-demo.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"files": [
{
"path": "registry/example/smooth-cursor-demo.tsx",
"content": "import { SmoothCursor } from \"@/registry/magicui/smooth-cursor\"\n\nexport default function SmoothCursorDemo() {\n return (\n <>\n <span className=\"hidden md:block\">Move your mouse around</span>\n <span className=\"block md:hidden\">Tap anywhere to see the cursor</span>\n <SmoothCursor />\n </>\n )\n}\n",
"content": "import { SmoothCursor } from \"@/registry/magicui/smooth-cursor\"\n\nexport default function SmoothCursorDemo() {\n return (\n <>\n <span className=\"hidden md:block\">Move your mouse around</span>\n <span className=\"block md:hidden\">\n SmoothCursor is disabled on touch devices\n </span>\n <SmoothCursor />\n </>\n )\n}\n",
"type": "registry:example"
}
]
Expand Down
2 changes: 1 addition & 1 deletion apps/www/public/r/smooth-cursor.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"files": [
{
"path": "registry/magicui/smooth-cursor.tsx",
"content": "\"use client\"\n\nimport { FC, useEffect, useRef } from \"react\"\nimport { motion, useSpring } from \"motion/react\"\n\ninterface Position {\n x: number\n y: number\n}\n\nexport interface SmoothCursorProps {\n cursor?: React.ReactNode\n springConfig?: {\n damping: number\n stiffness: number\n mass: number\n restDelta: number\n }\n}\n\nconst DefaultCursorSVG: FC = () => {\n return (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width={50}\n height={54}\n viewBox=\"0 0 50 54\"\n fill=\"none\"\n style={{ scale: 0.5 }}\n >\n <g filter=\"url(#filter0_d_91_7928)\">\n <path\n d=\"M42.6817 41.1495L27.5103 6.79925C26.7269 5.02557 24.2082 5.02558 23.3927 6.79925L7.59814 41.1495C6.75833 42.9759 8.52712 44.8902 10.4125 44.1954L24.3757 39.0496C24.8829 38.8627 25.4385 38.8627 25.9422 39.0496L39.8121 44.1954C41.6849 44.8902 43.4884 42.9759 42.6817 41.1495Z\"\n fill=\"black\"\n />\n <path\n d=\"M43.7146 40.6933L28.5431 6.34306C27.3556 3.65428 23.5772 3.69516 22.3668 6.32755L6.57226 40.6778C5.3134 43.4156 7.97238 46.298 10.803 45.2549L24.7662 40.109C25.0221 40.0147 25.2999 40.0156 25.5494 40.1082L39.4193 45.254C42.2261 46.2953 44.9254 43.4347 43.7146 40.6933Z\"\n stroke=\"white\"\n strokeWidth={2.25825}\n />\n </g>\n <defs>\n <filter\n id=\"filter0_d_91_7928\"\n x={0.602397}\n y={0.952444}\n width={49.0584}\n height={52.428}\n filterUnits=\"userSpaceOnUse\"\n colorInterpolationFilters=\"sRGB\"\n >\n <feFlood floodOpacity={0} result=\"BackgroundImageFix\" />\n <feColorMatrix\n in=\"SourceAlpha\"\n type=\"matrix\"\n values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n result=\"hardAlpha\"\n />\n <feOffset dy={2.25825} />\n <feGaussianBlur stdDeviation={2.25825} />\n <feComposite in2=\"hardAlpha\" operator=\"out\" />\n <feColorMatrix\n type=\"matrix\"\n values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0\"\n />\n <feBlend\n mode=\"normal\"\n in2=\"BackgroundImageFix\"\n result=\"effect1_dropShadow_91_7928\"\n />\n <feBlend\n mode=\"normal\"\n in=\"SourceGraphic\"\n in2=\"effect1_dropShadow_91_7928\"\n result=\"shape\"\n />\n </filter>\n </defs>\n </svg>\n )\n}\n\nexport function SmoothCursor({\n cursor = <DefaultCursorSVG />,\n springConfig = {\n damping: 45,\n stiffness: 400,\n mass: 1,\n restDelta: 0.001,\n },\n}: SmoothCursorProps) {\n const lastMousePos = useRef<Position>({ x: 0, y: 0 })\n const velocity = useRef<Position>({ x: 0, y: 0 })\n const lastUpdateTime = useRef(Date.now())\n const previousAngle = useRef(0)\n const accumulatedRotation = useRef(0)\n\n const cursorX = useSpring(0, springConfig)\n const cursorY = useSpring(0, springConfig)\n const rotation = useSpring(0, {\n ...springConfig,\n damping: 60,\n stiffness: 300,\n })\n const scale = useSpring(1, {\n ...springConfig,\n stiffness: 500,\n damping: 35,\n })\n\n useEffect(() => {\n let timeout: ReturnType<typeof setTimeout> | null = null\n\n const updateVelocity = (currentPos: Position) => {\n const currentTime = Date.now()\n const deltaTime = currentTime - lastUpdateTime.current\n\n if (deltaTime > 0) {\n velocity.current = {\n x: (currentPos.x - lastMousePos.current.x) / deltaTime,\n y: (currentPos.y - lastMousePos.current.y) / deltaTime,\n }\n }\n\n lastUpdateTime.current = currentTime\n lastMousePos.current = currentPos\n }\n\n const smoothMouseMove = (e: MouseEvent) => {\n const currentPos = { x: e.clientX, y: e.clientY }\n updateVelocity(currentPos)\n\n const speed = Math.sqrt(\n Math.pow(velocity.current.x, 2) + Math.pow(velocity.current.y, 2)\n )\n\n cursorX.set(currentPos.x)\n cursorY.set(currentPos.y)\n\n if (speed > 0.1) {\n const currentAngle =\n Math.atan2(velocity.current.y, velocity.current.x) * (180 / Math.PI) +\n 90\n\n let angleDiff = currentAngle - previousAngle.current\n if (angleDiff > 180) angleDiff -= 360\n if (angleDiff < -180) angleDiff += 360\n accumulatedRotation.current += angleDiff\n rotation.set(accumulatedRotation.current)\n previousAngle.current = currentAngle\n\n scale.set(0.95)\n\n if (timeout !== null) {\n clearTimeout(timeout)\n }\n\n timeout = setTimeout(() => {\n scale.set(1)\n }, 150)\n }\n }\n\n let rafId: number\n const throttledMouseMove = (e: MouseEvent) => {\n if (rafId) return\n\n rafId = requestAnimationFrame(() => {\n smoothMouseMove(e)\n rafId = 0\n })\n }\n\n document.body.style.cursor = \"none\"\n window.addEventListener(\"mousemove\", throttledMouseMove)\n\n return () => {\n window.removeEventListener(\"mousemove\", throttledMouseMove)\n document.body.style.cursor = \"auto\"\n if (rafId) cancelAnimationFrame(rafId)\n if (timeout !== null) {\n clearTimeout(timeout)\n }\n }\n }, [cursorX, cursorY, rotation, scale])\n\n return (\n <motion.div\n style={{\n position: \"fixed\",\n left: cursorX,\n top: cursorY,\n translateX: \"-50%\",\n translateY: \"-50%\",\n rotate: rotation,\n scale: scale,\n zIndex: 100,\n pointerEvents: \"none\",\n willChange: \"transform\",\n }}\n initial={{ scale: 0 }}\n animate={{ scale: 1 }}\n transition={{\n type: \"spring\",\n stiffness: 400,\n damping: 30,\n }}\n >\n {cursor}\n </motion.div>\n )\n}\n",
"content": "\"use client\"\n\nimport { FC, useEffect, useRef, useState } from \"react\"\nimport { motion, useSpring } from \"motion/react\"\n\ninterface Position {\n x: number\n y: number\n}\n\nexport interface SmoothCursorProps {\n cursor?: React.ReactNode\n springConfig?: {\n damping: number\n stiffness: number\n mass: number\n restDelta: number\n }\n}\n\nconst DESKTOP_POINTER_QUERY = \"(any-hover: hover) and (any-pointer: fine)\"\n\nfunction isTrackablePointer(pointerType: string) {\n return pointerType !== \"touch\"\n}\n\nconst DefaultCursorSVG: FC = () => {\n return (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width={50}\n height={54}\n viewBox=\"0 0 50 54\"\n fill=\"none\"\n style={{ scale: 0.5 }}\n >\n <g filter=\"url(#filter0_d_91_7928)\">\n <path\n d=\"M42.6817 41.1495L27.5103 6.79925C26.7269 5.02557 24.2082 5.02558 23.3927 6.79925L7.59814 41.1495C6.75833 42.9759 8.52712 44.8902 10.4125 44.1954L24.3757 39.0496C24.8829 38.8627 25.4385 38.8627 25.9422 39.0496L39.8121 44.1954C41.6849 44.8902 43.4884 42.9759 42.6817 41.1495Z\"\n fill=\"black\"\n />\n <path\n d=\"M43.7146 40.6933L28.5431 6.34306C27.3556 3.65428 23.5772 3.69516 22.3668 6.32755L6.57226 40.6778C5.3134 43.4156 7.97238 46.298 10.803 45.2549L24.7662 40.109C25.0221 40.0147 25.2999 40.0156 25.5494 40.1082L39.4193 45.254C42.2261 46.2953 44.9254 43.4347 43.7146 40.6933Z\"\n stroke=\"white\"\n strokeWidth={2.25825}\n />\n </g>\n <defs>\n <filter\n id=\"filter0_d_91_7928\"\n x={0.602397}\n y={0.952444}\n width={49.0584}\n height={52.428}\n filterUnits=\"userSpaceOnUse\"\n colorInterpolationFilters=\"sRGB\"\n >\n <feFlood floodOpacity={0} result=\"BackgroundImageFix\" />\n <feColorMatrix\n in=\"SourceAlpha\"\n type=\"matrix\"\n values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n result=\"hardAlpha\"\n />\n <feOffset dy={2.25825} />\n <feGaussianBlur stdDeviation={2.25825} />\n <feComposite in2=\"hardAlpha\" operator=\"out\" />\n <feColorMatrix\n type=\"matrix\"\n values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0\"\n />\n <feBlend\n mode=\"normal\"\n in2=\"BackgroundImageFix\"\n result=\"effect1_dropShadow_91_7928\"\n />\n <feBlend\n mode=\"normal\"\n in=\"SourceGraphic\"\n in2=\"effect1_dropShadow_91_7928\"\n result=\"shape\"\n />\n </filter>\n </defs>\n </svg>\n )\n}\n\nexport function SmoothCursor({\n cursor = <DefaultCursorSVG />,\n springConfig = {\n damping: 45,\n stiffness: 400,\n mass: 1,\n restDelta: 0.001,\n },\n}: SmoothCursorProps) {\n const lastMousePos = useRef<Position>({ x: 0, y: 0 })\n const velocity = useRef<Position>({ x: 0, y: 0 })\n const lastUpdateTime = useRef(Date.now())\n const previousAngle = useRef(0)\n const accumulatedRotation = useRef(0)\n const [isEnabled, setIsEnabled] = useState(false)\n const [isVisible, setIsVisible] = useState(false)\n\n const cursorX = useSpring(0, springConfig)\n const cursorY = useSpring(0, springConfig)\n const rotation = useSpring(0, {\n ...springConfig,\n damping: 60,\n stiffness: 300,\n })\n const scale = useSpring(1, {\n ...springConfig,\n stiffness: 500,\n damping: 35,\n })\n\n useEffect(() => {\n const mediaQuery = window.matchMedia(DESKTOP_POINTER_QUERY)\n\n const updateEnabled = () => {\n const nextIsEnabled = mediaQuery.matches\n setIsEnabled(nextIsEnabled)\n\n if (!nextIsEnabled) {\n setIsVisible(false)\n }\n }\n\n updateEnabled()\n mediaQuery.addEventListener(\"change\", updateEnabled)\n\n return () => {\n mediaQuery.removeEventListener(\"change\", updateEnabled)\n }\n }, [])\n\n useEffect(() => {\n if (!isEnabled) {\n return\n }\n\n let timeout: ReturnType<typeof setTimeout> | null = null\n\n const updateVelocity = (currentPos: Position) => {\n const currentTime = Date.now()\n const deltaTime = currentTime - lastUpdateTime.current\n\n if (deltaTime > 0) {\n velocity.current = {\n x: (currentPos.x - lastMousePos.current.x) / deltaTime,\n y: (currentPos.y - lastMousePos.current.y) / deltaTime,\n }\n }\n\n lastUpdateTime.current = currentTime\n lastMousePos.current = currentPos\n }\n\n const smoothPointerMove = (e: PointerEvent) => {\n if (!isTrackablePointer(e.pointerType)) {\n return\n }\n\n setIsVisible(true)\n\n const currentPos = { x: e.clientX, y: e.clientY }\n updateVelocity(currentPos)\n\n const speed = Math.sqrt(\n Math.pow(velocity.current.x, 2) + Math.pow(velocity.current.y, 2)\n )\n\n cursorX.set(currentPos.x)\n cursorY.set(currentPos.y)\n\n if (speed > 0.1) {\n const currentAngle =\n Math.atan2(velocity.current.y, velocity.current.x) * (180 / Math.PI) +\n 90\n\n let angleDiff = currentAngle - previousAngle.current\n if (angleDiff > 180) angleDiff -= 360\n if (angleDiff < -180) angleDiff += 360\n accumulatedRotation.current += angleDiff\n rotation.set(accumulatedRotation.current)\n previousAngle.current = currentAngle\n\n scale.set(0.95)\n\n if (timeout !== null) {\n clearTimeout(timeout)\n }\n\n timeout = setTimeout(() => {\n scale.set(1)\n }, 150)\n }\n }\n\n let rafId = 0\n const throttledPointerMove = (e: PointerEvent) => {\n if (!isTrackablePointer(e.pointerType)) {\n return\n }\n\n if (rafId) return\n\n rafId = requestAnimationFrame(() => {\n smoothPointerMove(e)\n rafId = 0\n })\n }\n\n document.body.style.cursor = \"none\"\n window.addEventListener(\"pointermove\", throttledPointerMove, {\n passive: true,\n })\n\n return () => {\n window.removeEventListener(\"pointermove\", throttledPointerMove)\n document.body.style.cursor = \"auto\"\n if (rafId) cancelAnimationFrame(rafId)\n if (timeout !== null) {\n clearTimeout(timeout)\n }\n }\n }, [cursorX, cursorY, rotation, scale, isEnabled])\n\n if (!isEnabled) {\n return null\n }\n\n return (\n <motion.div\n style={{\n position: \"fixed\",\n left: cursorX,\n top: cursorY,\n translateX: \"-50%\",\n translateY: \"-50%\",\n rotate: rotation,\n scale: scale,\n zIndex: 100,\n pointerEvents: \"none\",\n willChange: \"transform\",\n opacity: isVisible ? 1 : 0,\n }}\n initial={false}\n animate={{ opacity: isVisible ? 1 : 0 }}\n transition={{\n duration: 0.15,\n }}\n >\n {cursor}\n </motion.div>\n )\n}\n",
"type": "registry:ui"
}
]
Expand Down
4 changes: 3 additions & 1 deletion apps/www/registry/example/smooth-cursor-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ export default function SmoothCursorDemo() {
return (
<>
<span className="hidden md:block">Move your mouse around</span>
<span className="block md:hidden">Tap anywhere to see the cursor</span>
<span className="block md:hidden">
SmoothCursor is disabled on touch devices
</span>
<SmoothCursor />
</>
)
Expand Down
Loading
Loading