Ir al contenido
Evaluar mi proyecto
Componentes · Cursor y efectosDisponible

Lista con vista previa que sigue al puntero

Al apuntar una fila aparece una vista previa con inercia e inclinación por velocidad; con teclado se fija junto a la fila enfocada.

Código completo

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

efecto-lista-imagen.tsx91 líneas
Descargar
"use client";

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

/** Lista editorial de proyectos: al apuntar una fila aparece una vista previa que sigue al puntero con inercia e inclinación según su velocidad. Con teclado, la vista previa se fija junto a la fila enfocada. Ilustraciones SVG propias. React + TypeScript, sin dependencias. */
export type EfectoListaImagenItem = { title: string; year: string; a: string; b: string };

export type EfectoListaImagenProps = {
  items?: EfectoListaImagenItem[];
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gceli-root { box-sizing: border-box; position: relative; width: 100%; max-width: 640px; padding: 8px 28px; border-radius: 20px; background: #f1ede4; color: #17150f; font: 400 16px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; overflow: hidden; }
.gceli-root *, .gceli-root *::before, .gceli-root *::after { box-sizing: border-box; }
.gceli-root :focus-visible { outline: 2px solid #17150f; outline-offset: -2px; }
.gceli-list { margin: 0; padding: 0; list-style: none; }
.gceli-row { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 22px 0; border-bottom: 1px solid #d9d2c1; color: inherit; text-decoration: none; transition: opacity .3s; }
.gceli-list:hover .gceli-row:not(:hover), .gceli-list:focus-within .gceli-row:not(:focus-visible) { opacity: .35; }
.gceli-t { font: 500 clamp(26px, 5vw, 44px)/1 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.03em; transition: transform .4s cubic-bezier(.16, 1, .3, 1); }
.gceli-row:hover .gceli-t, .gceli-row:focus-visible .gceli-t { transform: translateX(12px); }
.gceli-y { font: 500 13px/1 ui-monospace, "SF Mono", Menlo, monospace; color: #8c8573; }
.gceli-pv { position: absolute; left: 0; top: 0; z-index: 3; width: 180px; aspect-ratio: 4 / 5; margin: -100px 0 0 -90px; overflow: hidden; border-radius: 12px; pointer-events: none; opacity: 0; scale: .8; will-change: transform; transition: opacity .25s, scale .35s cubic-bezier(.16, 1, .3, 1); box-shadow: 0 24px 40px -16px rgba(0, 0, 0, .45); }
.gceli-pv[data-on="true"] { opacity: 1; scale: 1; }
.gceli-pv svg { display: block; width: 100%; height: 100%; }
@media (prefers-reduced-motion: reduce) { .gceli-pv { transition: none; } .gceli-t { transition: none; } }`;

const DEFAULT: EfectoListaImagenItem[] = [
  { title: "Portal de clientes", year: "2025", a: "#ff7a59", b: "#6b4bd8" },
  { title: "Sistema de inventario", year: "2024", a: "#3cc6a8", b: "#16405c" },
  { title: "Panel de reportes", year: "2024", a: "#ffd23f", b: "#d6456f" },
  { title: "App de campo", year: "2023", a: "#7fb2ff", b: "#1d2f78" },
];

export function EfectoListaImagen({ items = DEFAULT, className = "", style }: EfectoListaImagenProps) {
  const root = useRef<HTMLDivElement>(null);
  const pv = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState<number | null>(null);
  const target = useRef({ x: 0, y: 0 });
  const cur = useRef({ x: 0, y: 0, r: 0 });
  const raf = useRef(0);

  function loop() {
    const c = cur.current, t = target.current;
    const vx = t.x - c.x;
    c.x += vx * 0.16;
    c.y += (t.y - c.y) * 0.16;
    c.r += (Math.max(-12, Math.min(12, vx * 0.12)) - c.r) * 0.2;
    if (pv.current) pv.current.style.transform = `translate(${c.x}px, ${c.y}px) rotate(${c.r}deg)`;
    raf.current = Math.hypot(t.x - c.x, t.y - c.y) > 0.3 || Math.abs(c.r) > 0.1 ? requestAnimationFrame(loop) : 0;
  }
  function aim(x: number, y: number) {
    target.current = { x, y };
    if (!raf.current) raf.current = requestAnimationFrame(loop);
  }
  useEffect(() => () => cancelAnimationFrame(raf.current), []);

  return (
    <div ref={root} className={`gceli-root ${className}`} style={style} onPointerMove={(event) => { if (event.pointerType === "touch") return; const r = root.current!.getBoundingClientRect(); aim(event.clientX - r.left + 40, event.clientY - r.top); }}>
      <style>{CSS}</style>
      <ul className="gceli-list" onPointerLeave={() => setActive(null)}>
        {items.map((item, i) => (
          <li key={item.title}>
            <a className="gceli-row" href="#" onClick={(event) => event.preventDefault()} onPointerEnter={() => setActive(i)} onFocus={(event) => { setActive(i); const r = root.current!.getBoundingClientRect(); const b = event.currentTarget.getBoundingClientRect(); aim(r.width - 200, b.top - r.top + b.height / 2); }} onBlur={() => setActive(null)}>
              <span className="gceli-t">{item.title}</span>
              <span className="gceli-y">{item.year}</span>
            </a>
          </li>
        ))}
      </ul>
      <div ref={pv} className="gceli-pv" data-on={active !== null} aria-hidden="true">
        {active !== null ? (
          <svg viewBox="0 0 200 250" preserveAspectRatio="xMidYMid slice">
            <defs>
              <linearGradient id="gceli-g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor={items[active].a} /><stop offset="1" stopColor={items[active].b} /></linearGradient>
            </defs>
            <rect width="200" height="250" fill="url(#gceli-g)" />
            <rect x="30" y="40" width="140" height="90" rx="10" fill="#fff" opacity=".9" />
            <rect x="44" y="56" width="60" height="8" rx="4" fill={items[active].b} />
            <rect x="44" y="74" width="100" height="6" rx="3" fill="#d6d6dc" />
            <rect x="44" y="90" width="84" height="6" rx="3" fill="#d6d6dc" />
            <rect x="30" y="146" width="64" height="64" rx="10" fill="#fff" opacity=".75" />
            <rect x="106" y="146" width="64" height="64" rx="10" fill="#fff" opacity=".5" />
          </svg>
        ) : null}
      </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-lista-imagen

Ejemplo de uso de Lista con vista previa que sigue al puntero

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

ejemplo.tsx5 líneas
import { EfectoListaImagen } from "./efecto-lista-imagen";

export default function Ejemplo() {
  return <EfectoListaImagen />;
}

Propiedades de EfectoListaImagen

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

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

Accesibilidad y rendimiento

El archivo tiene 91 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Lista con vista previa que sigue al puntero 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)