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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/efecto-seguidorEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
items? | EfectoSeguidorItem[] | Opcional. |
label? | string | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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".