cursor · contextual · 灵感来源:Figma 多人光标的上下文标签 + Pointer Events(局部演示,不接管全站)
Contextual Cursor Ring (local demo) · 上下文光标环 · 光标 / Interaction
做一个 Contextual Cursor Ring (local demo)(上下文光标环):
- 只在自身范围内跟随指针,原生光标始终保留,不写 cursor: none
- 状态由子树 data-cursor-state 声明:pointer / grab / text / disabled
- 换形用 scale + border-radius + 描边色;text 状态环直接隐藏让位
- 只在 (hover: hover) and (pointer: fine),reduced-motion 下不启用
用这些 CSS 变量:--color-accent --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --dur-fast --ease-standard --glass-border --shadow-sm --text-2xs
灵感参考:Figma 多人光标的上下文标签 + Pointer Events(局部演示,不接管全站)--color-accent --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --dur-fast --ease-standard --glass-border --shadow-sm --text-2xs
焦点 / 按压
(尚未接进整页场景)
/r/cursor--contextual.json
免外壳的 markdown 版:/llms/components/cursor--contextual.mdcursor: none,原生光标始终保留data-cursor-state="pointer | grab | text | disabled",环只负责表现scale 放大、border-radius 圆形→方形、描边色与填充text 状态直接让位(环隐藏),避免遮挡插入点与选区transform,状态变化才触发 React 更新——跟随与换形互不拖累(hover: hover) and (pointer: fine) 且未开启减少动态偏好时渲染跟随环aria-hidden + pointer-events: none,不参与命中测试与读屏data-cursor-state,没有自动推断;漏标即按默认环import { useEffect, useRef, useState, type PointerEvent, type ReactNode } from 'react';
import { prefersReducedMotion } from '~/lib/motion';
type CursorState = 'default' | 'pointer' | 'grab' | 'text' | 'disabled';
interface Props {
children: ReactNode;
className?: string;
}
/** 子树用 data-cursor-state 声明状态:pointer / grab / text / disabled */
const stateOf = (target: EventTarget | null): CursorState => {
const el = target instanceof Element ? target.closest<HTMLElement>('[data-cursor-state]') : null;
const v = el?.dataset.cursorState;
return v === 'pointer' || v === 'grab' || v === 'text' || v === 'disabled' ? v : 'default';
};
/**
* 上下文光标环:只在自身范围内跟随指针,按目标状态换形。
* 原生光标始终保留;输入区(text)直接让位;减少动态偏好下不渲染跟随环。
*/
export function ContextualCursor({ children, className = '' }: Props) {
const hostRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLSpanElement>(null);
const rafRef = useRef(0);
const [enabled, setEnabled] = useState(false);
const [inside, setInside] = useState(false);
const [state, setState] = useState<CursorState>('default');
useEffect(() => {
setEnabled(
window.matchMedia('(hover: hover) and (pointer: fine)').matches && !prefersReducedMotion(),
);
return () => cancelAnimationFrame(rafRef.current);
}, []);
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!enabled || e.pointerType !== 'mouse') return;
const host = hostRef.current;
if (!host) return;
const rect = host.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const ring = ringRef.current;
if (!ring) return;
ring.style.transform = `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%)`;
});
const next = stateOf(e.target);
setInside(true);
if (next !== state) setState(next);
};
const visible = enabled && inside && state !== 'text';
const shape =
state === 'grab'
? 'scale-125 rounded-md border-[var(--color-accent)] bg-transparent'
: state === 'pointer'
? 'scale-125 rounded-full border-[var(--color-accent)] bg-[var(--color-accent)]/10'
: state === 'disabled'
? 'rounded-full border-dashed border-[var(--color-text-subtle)] bg-transparent'
: 'rounded-full border-[var(--glass-border)] bg-transparent';
return (
<div
ref={hostRef}
onPointerMove={onPointerMove}
onPointerEnter={() => setInside(true)}
onPointerLeave={() => setInside(false)}
onPointerCancel={() => setInside(false)}
className={`relative ${className}`}
>
{children}
<span
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 size-9"
>
<span ref={ringRef} className="block size-9">
<span
data-cursor-ring
className={`
block size-9 border
transition-[transform,border-radius,border-color,background-color,opacity]
duration-[var(--dur-fast)] ease-[var(--ease-standard)]
${shape}
${visible ? 'opacity-100' : 'opacity-0'}
`}
/>
</span>
</span>
</div>
);
}
src/components/cursor/contextual/