# 上下文光标（Contextual Cursor Ring (local demo)）

- 类型 / 材质：`cursor` / `contextual`（情境）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Figma 多人光标的上下文标签 + Pointer Events（局部演示，不接管全站）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/cursor/contextual` ｜ 条目：`/r/cursor--contextual.json`
- 必缺状态：焦点 / 按压
- 整页场景：尚未接入

## 规格

- 只在自身范围内跟随指针，原生光标始终保留，不写 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`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `children` | `ReactNode` | 必填 | — |
| `className` | `string` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「上下文光标」— Contextual Cursor Ring (local demo)（光标 / Interaction）
灵感来源：Figma 多人光标的上下文标签 + Pointer Events（局部演示，不接管全站）
规格：
- 只在自身范围内跟随指针，原生光标始终保留，不写 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
必缺状态：焦点 / 按压
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/cursor/contextual · 标准 interaction /design#interaction · 调研笔记 /showcase/component/cursor/contextual#notes
```

## 调研笔记

import { useEffect, useRef, useState, type PointerEvent, type ReactNode } from 'react';
import { prefersReducedMotion } from '@/lib/ui-echo/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>
  );
}
