上下文光标

cursor · contextual · 灵感来源:Figma 多人光标的上下文标签 + Pointer Events(局部演示,不接管全站)

复用卡

Contextual Cursor Ring (local demo) · 上下文光标环 · 光标 / Interaction

规格 · 术语提示词(给 AI 用)

做一个 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(局部演示,不接管全站)

参数(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

必缺状态

焦点 / 按压

整页场景

(尚未接进整页场景)

安装(一条链接)

调研笔记

上下文光标环

拆解

  • 只在自身范围内跟随指针:环是局部装饰,不写 cursor: none,原生光标始终保留
  • 状态由子树声明:data-cursor-state="pointer | grab | text | disabled",环只负责表现
  • 换形用三组属性:scale 放大、border-radius 圆形→方形、描边色与填充
  • text 状态直接让位(环隐藏),避免遮挡插入点与选区
  • 坐标用 rAF 直写 transform,状态变化才触发 React 更新——跟随与换形互不拖累

边界

  • 只在 (hover: hover) and (pointer: fine) 且未开启减少动态偏好时渲染跟随环
  • 环 aria-hidden + pointer-events: none,不参与命中测试与读屏
  • 原生光标、键盘焦点与输入行为全部不变

限制

  • 局部示范:不做站点级光标、不做拖尾、不做文字标签;跨出范围立即隐藏
  • 状态来自显式 data-cursor-state,没有自动推断;漏标即按默认环

实现

cursor/contextual
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>
  );
}

元信息

标识
cursor/contextual
状态
done
最后提交
2026-11-01
风格轴
contextual
依赖 token
--color-accent --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --dur-fast --ease-standard --glass-border --shadow-sm --text-2xs
在工作台打开 源码与 notes 都在 src/components/cursor/contextual/