Home/Components/Custome Cursor/Blueprint Inspector Cursor - React Figma-Style Cursor
Code · Live Preview
Componenttsx
// src/components/BlueprintCursor/BlueprintCursor.tsx
"use client";

/**
 * Blueprint Inspector Cursor
 * A design-tool cursor for developer sites. Hairline crosshairs span the whole
 * screen and follow the pointer, with a live x / y readout beside it. Hover a
 * button, link, image or input and a selection box springs onto it (corner
 * handles and all) with its tag name on top and its exact size (W × H)
 * underneath, like inspecting a layer in Figma.
 *
 * Stack: React + TypeScript + Tailwind CSS v4 + Motion (motion/react)
 * Deps:  npm i motion
 *
 * Overrides on any element:
 *   data-cursor="inspect"   → show the selection box on this element
 *   data-cursor-name="Hero CTA" → label shown instead of the tag name
 *   data-cursor="default"   → never inspect here
 *   data-cursor="hide"      → hide the cursor in this area
 *
 * Only runs on devices with a mouse / trackpad (hover + fine pointer).
 */

import { useEffect, useSyncExternalStore } from "react";
import {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
} from "motion/react";

export interface BlueprintCursorProps {
  /** Accent colour for lines, box and labels */
  accent?: string;
  /** Full-screen crosshair lines */
  lines?: boolean;
  /** Live x / y readout next to the pointer */
  coords?: boolean;
  /** Inspect every element under the pointer, not just interactive ones */
  inspectAll?: boolean;
  /** Gap between the element and the selection box (px) */
  padding?: number;
}

const INSPECTABLE =
  'a[href], button, [role="button"], label[for], select, summary, input, textarea, img, video, svg, canvas, [contenteditable="true"]';

function useMediaQuery(query: string): boolean {
  return useSyncExternalStore(
    (onChange) => {
      const m = window.matchMedia(query);
      m.addEventListener("change", onChange);
      return () => m.removeEventListener("change", onChange);
    },
    () => window.matchMedia(query).matches,
    () => false,
  );
}

function nameOf(el: HTMLElement): string {
  const custom = el.dataset.cursorName;
  if (custom) return custom;
  const tag = el.tagName.toLowerCase();
  const firstClass =
    typeof el.className === "string" ? el.className.trim().split(/\s+/)[0] : "";
  return firstClass && firstClass.length <= 18 ? `${tag}.${firstClass}` : tag;
}

export default function BlueprintCursor({
  accent = "#0d99ff",
  lines = true,
  coords = true,
  inspectAll = false,
  padding = 0,
}: BlueprintCursorProps) {
  const enabled = useMediaQuery("(hover: hover) and (pointer: fine)");
  const reduce = useReducedMotion() ?? false;

  // Pointer (exact, no lag)
  const mx = useMotionValue(-100);
  const my = useMotionValue(-100);
  const visible = useMotionValue(0);
  const visibleSmooth = useSpring(visible, { stiffness: 400, damping: 40 });
  const press = useMotionValue(1);
  const pressSmooth = useSpring(press, { stiffness: 600, damping: 28 });

  // Coordinate chip
  const flipX = useMotionValue(0);
  const flipY = useMotionValue(0);
  const chipLeft = useTransform(flipX, (f) => (f ? -14 : 14));
  const chipTop = useTransform(flipY, (f) => (f ? -14 : 14));
  const chipShiftX = useTransform(flipX, (f) => (f ? "-100%" : "0%"));
  const chipShiftY = useTransform(flipY, (f) => (f ? "-100%" : "0%"));
  const coordText = useTransform(
    [mx, my],
    ([x, y]: number[]) => `x ${Math.round(x)}  y ${Math.round(y)}`,
  );

  // Selection box
  const box = reduce
    ? { stiffness: 2000, damping: 100 }
    : { stiffness: 520, damping: 40, mass: 0.6 };
  const bx = useMotionValue(0);
  const by = useMotionValue(0);
  const bw = useMotionValue(0);
  const bh = useMotionValue(0);
  const bo = useMotionValue(0);
  const boxX = useSpring(bx, box);
  const boxY = useSpring(by, box);
  const boxW = useSpring(bw, box);
  const boxH = useSpring(bh, box);
  const boxOpacity = useSpring(bo, { stiffness: 400, damping: 40 });
  const dims = useMotionValue("");
  const tag = useMotionValue("");

  useEffect(() => {
    if (!enabled) return;

    const root = document.documentElement;
    root.classList.add("bp-active");

    let current: HTMLElement | null = null;
    let hidden = false;
    let first = true;

    const pick = (
      node: EventTarget | null,
    ): { el: HTMLElement | null; hide: boolean } => {
      if (!(node instanceof Element)) return { el: null, hide: false };

      const explicit = node.closest<HTMLElement>("[data-cursor]");
      const v = explicit?.dataset.cursor;
      if (explicit && v === "hide") return { el: null, hide: true };
      if (explicit && v === "default") return { el: null, hide: false };
      if (explicit && v === "inspect") return { el: explicit, hide: false };

      const el = inspectAll
        ? (node as HTMLElement)
        : node.closest<HTMLElement>(INSPECTABLE);

      if (!el || el === document.body || el === document.documentElement) {
        return { el: null, hide: false };
      }
      // Skip page-sized wrappers (mostly relevant with inspectAll)
      const r = el.getBoundingClientRect();
      if (
        r.width >= window.innerWidth * 0.95 &&
        r.height >= window.innerHeight * 0.6
      ) {
        return { el: null, hide: false };
      }
      return { el, hide: false };
    };

    const place = (el: HTMLElement, jump: boolean) => {
      const r = el.getBoundingClientRect();
      const vals = [
        r.left - padding,
        r.top - padding,
        r.width + padding * 2,
        r.height + padding * 2,
      ];
      if (jump) {
        // First time a box appears: start at the element instead of flying in from 0,0
        boxX.jump(vals[0]);
        boxY.jump(vals[1]);
        boxW.jump(vals[2]);
        boxH.jump(vals[3]);
      }
      bx.set(vals[0]);
      by.set(vals[1]);
      bw.set(vals[2]);
      bh.set(vals[3]);
      dims.set(`${Math.round(r.width)} × ${Math.round(r.height)}`);
      tag.set(nameOf(el));
    };

    const apply = (next: { el: HTMLElement | null; hide: boolean }) => {
      hidden = next.hide;
      if (next.el) {
        place(next.el, !current);
        bo.set(1);
      } else {
        bo.set(0);
      }
      current = next.el;
      visible.set(hidden ? 0 : 1);
    };

    const onMove = (e: PointerEvent) => {
      if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;
      mx.set(e.clientX);
      my.set(e.clientY);
      flipX.set(e.clientX > window.innerWidth - 170 ? 1 : 0);
      flipY.set(e.clientY > window.innerHeight - 60 ? 1 : 0);
      if (first) {
        first = false;
        apply(pick(e.target));
      }
      if (!hidden) visible.set(1);
    };
    const onOver = (e: PointerEvent) => apply(pick(e.target));
    const recheck = () =>
      apply(pick(document.elementFromPoint(mx.get(), my.get())));
    const onOut = (e: MouseEvent) => {
      if (!e.relatedTarget) visible.set(0);
    };
    const onDown = () => press.set(0.6);
    const onUp = () => press.set(1);

    window.addEventListener("pointermove", onMove, { passive: true });
    document.addEventListener("pointerover", onOver);
    document.addEventListener("mouseout", onOut);
    window.addEventListener("pointerdown", onDown);
    window.addEventListener("pointerup", onUp);
    window.addEventListener("blur", onUp);
    window.addEventListener("scroll", recheck, { passive: true });
    window.addEventListener("resize", recheck);

    return () => {
      root.classList.remove("bp-active");
      window.removeEventListener("pointermove", onMove);
      document.removeEventListener("pointerover", onOver);
      document.removeEventListener("mouseout", onOut);
      window.removeEventListener("pointerdown", onDown);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("blur", onUp);
      window.removeEventListener("scroll", recheck);
      window.removeEventListener("resize", recheck);
    };
  }, [
    enabled,
    inspectAll,
    padding,
    mx,
    my,
    visible,
    press,
    flipX,
    flipY,
    bx,
    by,
    bw,
    bh,
    bo,
    boxX,
    boxY,
    boxW,
    boxH,
    dims,
    tag,
  ]);

  if (!enabled) return null;

  const layer = "pointer-events-none fixed left-0 top-0 z-[9999]";
  const mono = "font-mono text-[10px] font-medium leading-none tabular-nums";

  return (
    <>
      <style>{`html.bp-active, html.bp-active * { cursor: none !important; }`}</style>

      <motion.div
        aria-hidden="true"
        className="pointer-events-none fixed inset-0 z-[9999]"
        style={{ opacity: visibleSmooth }}
      >
        {/* Crosshair hairlines */}
        {lines && (
          <>
            <motion.div
              className={`${layer} h-dvh w-px -translate-x-1/2`}
              style={{ x: mx, background: accent, opacity: 0.35 }}
            />
            <motion.div
              className={`${layer} h-px w-screen -translate-y-1/2`}
              style={{ y: my, background: accent, opacity: 0.35 }}
            />
          </>
        )}

        {/* Selection box */}
        <motion.div
          className={layer}
          style={{
            x: boxX,
            y: boxY,
            width: boxW,
            height: boxH,
            opacity: boxOpacity,
            outline: `1.5px solid ${accent}`,
            background: `color-mix(in srgb, ${accent} 8%, transparent)`,
          }}
        >
          {/* Corner handles */}
          {[
            "-left-[4px] -top-[4px]",
            "-right-[4px] -top-[4px]",
            "-left-[4px] -bottom-[4px]",
            "-right-[4px] -bottom-[4px]",
          ].map((pos) => (
            <span
              key={pos}
              className={`absolute size-[7px] bg-white ${pos}`}
              style={{ outline: `1.5px solid ${accent}` }}
            />
          ))}

          {/* Name tag */}
          <motion.span
            className={`absolute bottom-full left-0 mb-1.5 whitespace-nowrap ${mono}`}
            style={{ color: accent }}
          >
            {tag}
          </motion.span>

          {/* Size badge */}
          <motion.span
            className={`absolute left-1/2 top-full mt-1.5 -translate-x-1/2 whitespace-nowrap rounded-sm px-1.5 py-1 text-white ${mono}`}
            style={{ background: accent }}
          >
            {dims}
          </motion.span>
        </motion.div>

        {/* Pointer: centre reticle + coordinate chip */}
        <motion.div className={`${layer} size-0`} style={{ x: mx, y: my }}>
          <motion.span
            className="absolute left-0 top-0 block size-[11px] -translate-x-1/2 -translate-y-1/2 bg-white"
            style={{
              outline: `1.5px solid ${accent}`,
              scale: pressSmooth,
              rotate: 45,
            }}
          />
          {coords && (
            <motion.span
              className={`absolute whitespace-pre rounded-sm px-1.5 py-1 text-white ${mono}`}
              style={{
                left: chipLeft,
                top: chipTop,
                x: chipShiftX,
                y: chipShiftY,
                background: accent,
              }}
            >
              {coordText}
            </motion.span>
          )}
        </motion.div>
      </motion.div>
    </>
  );
}
// Component Source: https://codekk.dev/
DesktopSandpackTailwind v4

Component details

Overview, install command, dependencies, and project links.

A design-tool cursor for React built with TypeScript, Motion, and Tailwind CSS v4. It makes your site feel like inspecting a layer in Figma, a great fit for developer portfolios, design system docs, and dev tool landing pages. Thin crosshair lines follow the pointer across the whole screen, and a small chip beside it shows your exact x and y position. When you hover a button, link, image, or input, a selection box springs onto it with corner handles. The element's name sits on top, like button or a.rounded-full, and its exact size sits underneath, like 148 × 44. Moving between elements makes the box glide from one to the next, and clicking pinches the diamond at the pointer. Props set the accent color, toggle the crosshair lines and coordinates, and add padding around the box. The inspectAll option makes the box follow everything you hover, including text and layout containers, while skipping page-sized wrappers. Data attributes let you set custom labels, inspect non-interactive elements, or hide the cursor in a section. The cursor never moves your elements, so it works alongside other animated components. It only turns on for a mouse or trackpad, so touch devices are unchanged. With prefers-reduced-motion enabled, the box jumps to each element instead of gliding. All cursor layers are hidden from screen readers and ignore pointer events. It works in Vite, Create React App, and Next.js.

Install

terminal
npm
npm install motion

Dependencies

motion
Added Oct 2, 2026

Discussion

Feedback, implementation tips, and usage notes.

0 threads

Comments

Sign in to join the conversation