Ir al contenido
Evaluar mi proyecto
Componentes · Cursor y efectosDisponible

Cursor seguidor con etiqueta

Un disco con inercia sigue al puntero dentro del área, crece y muestra «Ver» sobre los enlaces; se apaga en táctil y con movimiento reducido.

Proyectos seleccionados

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

efecto-seguidor.tsx90 líneas
Descargar
"use client";

import { useEffect, useRef, type CSSProperties } from "react";

/** Cursor personalizado dentro de un área: un disco con retardo suave sigue al puntero, crece y muestra una etiqueta sobre los enlaces. Desaparece en táctil y con movimiento reducido; el cursor del sistema sigue disponible. React + TypeScript, sin dependencias. */
export type EfectoSeguidorItem = { title: string; meta: string };

export type EfectoSeguidorProps = {
  items?: EfectoSeguidorItem[];
  label?: string;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcesg-root { box-sizing: border-box; position: relative; width: 100%; max-width: 640px; padding: 28px 28px 20px; border-radius: 20px; background: #111; color: #f4f0e6; font: 400 16px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; overflow: hidden; }
.gcesg-root *, .gcesg-root *::before, .gcesg-root *::after { box-sizing: border-box; }
.gcesg-root :focus-visible { outline: 2px solid #ff6a3d; outline-offset: -2px; }
.gcesg-head { margin: 0 0 12px; font-size: 12px; letter-spacing: .1em; text-transform: uppercase; color: #8c8677; }
.gcesg-list { margin: 0; padding: 0; list-style: none; }
.gcesg-row { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 20px 0; border-top: 1px solid #2c2a26; color: inherit; text-decoration: none; transition: padding .3s cubic-bezier(.16, 1, .3, 1), color .2s; }
.gcesg-row:last-child { border-bottom: 1px solid #2c2a26; }
.gcesg-row:hover, .gcesg-row:focus-visible { padding-left: 14px; color: #ff6a3d; }
.gcesg-t { font: 500 clamp(26px, 5vw, 42px)/1 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.02em; }
.gcesg-m { font-size: 13px; color: #8c8677; }
.gcesg-dot { position: absolute; left: 0; top: 0; z-index: 5; display: grid; place-items: center; width: 22px; height: 22px; margin: -11px 0 0 -11px; border-radius: 50%; background: #f4f0e6; color: #111; font: 700 12px/1 ui-sans-serif, sans-serif; letter-spacing: .04em; mix-blend-mode: normal; pointer-events: none; opacity: 0; will-change: transform; transition: width .35s cubic-bezier(.16, 1, .3, 1), height .35s cubic-bezier(.16, 1, .3, 1), margin .35s cubic-bezier(.16, 1, .3, 1), opacity .2s, background .2s; }
.gcesg-dot[data-on="true"] { opacity: 1; }
.gcesg-dot[data-big="true"] { width: 84px; height: 84px; margin: -42px 0 0 -42px; background: #ff6a3d; color: #fff; }
.gcesg-dot span { opacity: 0; transition: opacity .2s; }
.gcesg-dot[data-big="true"] span { opacity: 1; }
@media (pointer: fine) { .gcesg-root[data-js="true"] { cursor: none; } .gcesg-root[data-js="true"] a { cursor: none; } }
@media (prefers-reduced-motion: reduce), (pointer: coarse) { .gcesg-dot { display: none; } .gcesg-root[data-js="true"], .gcesg-root[data-js="true"] a { cursor: auto; } }`;

const DEFAULT: EfectoSeguidorItem[] = [
  { title: "Portal de clientes", meta: "2025" },
  { title: "Sistema de inventario", meta: "2024" },
  { title: "Panel de reportes", meta: "2024" },
];

export function EfectoSeguidor({ items = DEFAULT, label = "Ver", className = "", style }: EfectoSeguidorProps) {
  const root = useRef<HTMLDivElement>(null);
  const dot = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = root.current;
    const d = dot.current;
    if (!el || !d) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || window.matchMedia("(pointer: coarse)").matches) return;
    el.dataset.js = "true";
    let tx = 0, ty = 0, x = 0, y = 0, raf = 0, on = false;
    const loop = () => {
      x += (tx - x) * 0.18;
      y += (ty - y) * 0.18;
      d.style.transform = `translate(${x}px, ${y}px)`;
      raf = on || Math.hypot(tx - x, ty - y) > 0.3 ? requestAnimationFrame(loop) : 0;
    };
    const move = (e: PointerEvent) => {
      const r = el.getBoundingClientRect();
      tx = e.clientX - r.left; ty = e.clientY - r.top;
      if (!on) { x = tx; y = ty; on = true; d.dataset.on = "true"; }
      d.dataset.big = (e.target as HTMLElement).closest("a") ? "true" : "false";
      if (!raf) raf = requestAnimationFrame(loop);
    };
    const leave = () => { on = false; d.dataset.on = "false"; };
    el.addEventListener("pointermove", move);
    el.addEventListener("pointerleave", leave);
    return () => { cancelAnimationFrame(raf); el.removeEventListener("pointermove", move); el.removeEventListener("pointerleave", leave); delete el.dataset.js; };
  }, []);

  return (
    <div ref={root} className={`gcesg-root ${className}`} style={style}>
      <style>{CSS}</style>
      <p className="gcesg-head">Proyectos seleccionados</p>
      <ul className="gcesg-list">
        {items.map((item) => (
          <li key={item.title}>
            <a className="gcesg-row" href="#" onClick={(event) => event.preventDefault()}>
              <span className="gcesg-t">{item.title}</span>
              <span className="gcesg-m">{item.meta}</span>
            </a>
          </li>
        ))}
      </ul>
      <div ref={dot} className="gcesg-dot" aria-hidden="true">
        <span>{label}</span>
      </div>
    </div>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/efecto-seguidor

Ejemplo de uso de Cursor seguidor con etiqueta

Después de copiar efecto-seguidor.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { EfectoSeguidor } from "./efecto-seguidor";

export default function Ejemplo() {
  return <EfectoSeguidor label="Ejemplo" />;
}

Propiedades de EfectoSeguidor

EfectoSeguidor acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
items?EfectoSeguidorItem[]Opcional.
label?stringOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 90 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Cursor seguidor con etiqueta por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • Reacciona al puntero con eventos pointer (ratón, lápiz y táctil).
  • Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)