# 光标跟随 Custom Cursor > 一个小圆慢半拍跟着鼠标走,碰到文字变竖线,碰到图片放大写字,碰到按钮吸上去变外框。 分类:动效 · 角色:点缀(贴在某个元素上,一页两三个) [全部组件和流程](https://www.dachuiai.com/llms.txt) · [动效选型规则](https://www.dachuiai.com/guides/motion.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[动效选型规则](https://www.dachuiai.com/guides/motion.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:作品集、设计工作室、创意品牌,想让整个页面都在回应鼠标。 - 不适合:电商、SaaS 工具、后台、内容站(读者要正常的光标);同一页已经有图片拖尾。 - 适合的网站:作品集、官网、活动页 - 放在:全站 - 手机上:没效果:只在有鼠标的设备上生效,手机上自动不出现。 - 性能代价:低:一个元素每帧跟着鼠标。 - 常搭配:[磁吸 Magnetic](https://www.dachuiai.com/c/magnetic.md)、[悬停预览 Hover Reveal](https://www.dachuiai.com/c/hover-reveal.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react` - 文件:原样存成 `custom-cursor.tsx`(比如 `src/components/custom-cursor.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/custom-cursor.tsx),比手抄准:`curl -fsSL -o src/components/custom-cursor.tsx https://www.dachuiai.com/code/custom-cursor.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`CursorArea` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的 [/art/fluid-rose.jpg](https://www.dachuiai.com/art/fluid-rose.jpg)、[/art/planet.jpg](https://www.dachuiai.com/art/planet.jpg)、[/art/ice.jpg](https://www.dachuiai.com/art/ice.jpg)、[/art/lava.jpg](https://www.dachuiai.com/art/lava.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:用 CursorArea 包住页面,给元素加 data-cursor="text | view | snap" 决定光标碰到它时的样子,view 的字写在 data-cursor-label。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `children` | | 包在里面的内容 | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 全站加 Custom Cursor 光标跟随:一个小圆慢半拍跟着鼠标走,碰到文字变成竖线,碰到图片放大写「查看」,碰到按钮吸上去变成外框。 ## 完整代码 ```tsx import {useEffect, useRef, useState, type ReactNode} from 'react'; import {ArrowUpRight} from 'lucide-react'; /* --------------------------------------------------------------- * CustomCursor 光标跟随 * 一个小圆跟着鼠标走,碰到东西就变形。整个页面,都在回应你。 * 给元素加 data-cursor 就能决定光标碰到它时的样子: * data-cursor="text" 贴着文字,变成一根竖线 * data-cursor="view" 放大,里面写字(写在 data-cursor-label 里) * data-cursor="snap" 吸到按钮上,变成按钮的外框 * 只在有鼠标的设备上生效;手机上还是原来的样子。 * ------------------------------------------------------------- */ export function CursorArea({children, className = ''}: {children: ReactNode; className?: string}) { const area = useRef(null); const ring = useRef(null); const dot = useRef(null); const [label, setLabel] = useState(''); const [mode, setMode] = useState(''); const [fine, setFine] = useState(false); useEffect(() => { const el = area.current!; if (!matchMedia('(pointer: fine)').matches) return; setFine(true); const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const m = {x: 0, y: 0}, c = {x: 0, y: 0, w: 36, h: 36, r: 18}; const goal = {w: 36, h: 36, r: 18}; let snap: HTMLElement | null = null, raf = 0, on = false; const tick = () => { if (!area.current) return; raf = requestAnimationFrame(tick); const R = ring.current, D = dot.current; if (!R || !D) return; const b = el.getBoundingClientRect(); let tx = m.x, ty = m.y; if (snap) { const r = snap.getBoundingClientRect(); // 吸附时圆心落在按钮中心,但还跟着鼠标偏一点点 tx = r.left - b.left + r.width / 2 + (m.x - (r.left - b.left + r.width / 2)) * 0.12; ty = r.top - b.top + r.height / 2 + (m.y - (r.top - b.top + r.height / 2)) * 0.12; goal.w = r.width + 14; goal.h = r.height + 14; goal.r = (r.height + 14) / 2; } const k = reduce ? 1 : 0.2; c.x += (tx - c.x) * k; c.y += (ty - c.y) * k; c.w += (goal.w - c.w) * (reduce ? 1 : 0.22); c.h += (goal.h - c.h) * (reduce ? 1 : 0.22); c.r += (goal.r - c.r) * (reduce ? 1 : 0.22); const speed = reduce || snap ? 0 : Math.min(0.28, Math.hypot(tx - c.x, ty - c.y) / 260); const angle = Math.atan2(ty - c.y, tx - c.x); R.style.width = `${c.w}px`; R.style.height = `${c.h}px`; R.style.borderRadius = `${c.r}px`; // 跑得快时顺着方向拉长一点 R.style.transform = `translate(${c.x - c.w / 2}px, ${c.y - c.h / 2}px) rotate(${angle}rad) scale(${1 + speed}, ${1 - speed}) rotate(${-angle}rad)`; D.style.transform = `translate(${m.x - 3}px, ${m.y - 3}px)`; }; const set = (t: HTMLElement | null) => { const kind = t?.dataset.cursor ?? ''; snap = kind === 'snap' ? t : null; setMode(kind); setLabel(t?.dataset.cursorLabel ?? ''); if (kind === 'text') Object.assign(goal, {w: 3, h: 34, r: 2}); else if (kind === 'view') Object.assign(goal, {w: 104, h: 104, r: 52}); else if (kind !== 'snap') Object.assign(goal, {w: 36, h: 36, r: 18}); }; const move = (e: PointerEvent) => { const b = el.getBoundingClientRect(); m.x = e.clientX - b.left; m.y = e.clientY - b.top; if (!on) { on = true; c.x = m.x; c.y = m.y; el.dataset.in = '1'; } set((e.target as HTMLElement).closest('[data-cursor]')); }; const leave = () => { on = false; delete el.dataset.in; set(null); }; const press = () => { goal.w *= 0.8; goal.h *= 0.8; }; const release = (e: PointerEvent) => set((e.target as HTMLElement).closest('[data-cursor]')); el.addEventListener('pointermove', move); el.addEventListener('pointerleave', leave); el.addEventListener('pointerdown', press); el.addEventListener('pointerup', release); raf = requestAnimationFrame(tick); return () => { cancelAnimationFrame(raf); el.removeEventListener('pointermove', move); el.removeEventListener('pointerleave', leave); el.removeEventListener('pointerdown', press); el.removeEventListener('pointerup', release); }; }, []); return (
{children} {fine && ( <>
{label}
)}
); } /* ---------------- 示例:一页作品集 ---------------- */ export default function CustomCursorDemo() { return (

把每一个
细节都
做到位

品牌网站、产品发布页和交互设计,一个小团队,只接少量项目。

{[['/art/fluid-rose.jpg', '流体'], ['/art/planet.jpg', '远行星'], ['/art/ice.jpg', '蓝冰'], ['/art/lava.jpg', '地火']].map(([src, t]) => ( e.preventDefault()} data-cursor="view" data-cursor-label="查看" aria-label={t} className="block h-[210px] overflow-hidden rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-white"> ))}
); } ```