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