Ir al contenido
Evaluar mi proyecto
Componentes · Modales y capasDisponible

Galería con lightbox

Miniaturas que abren la imagen a pantalla completa con flechas del teclado, contador y pie; ilustraciones SVG propias.

1 / 4

Amanecer en la bahía

Código completo

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

capa-lightbox.tsx106 líneas
Descargar
"use client";

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

/** Galería con lightbox sobre <dialog>: miniaturas que abren la imagen a pantalla completa, navegación con flechas del teclado, contador y pie con descripción. Ilustraciones SVG propias. React + TypeScript, sin dependencias. */
export type CapaLightboxItem = { id: string; title: string; a: string; b: string; shape: "circulo" | "montana" | "olas" | "bloques" };

export type CapaLightboxProps = {
  items?: CapaLightboxItem[];
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gccli-root { box-sizing: border-box; width: 100%; max-width: 520px; font: 500 15px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #fff; }
.gccli-root *, .gccli-root *::before, .gccli-root *::after { box-sizing: border-box; }
.gccli-root button { font: inherit; }
.gccli-root :focus-visible { outline: 3px solid #5aa9ff; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .gccli-dlg, .gccli-dlg::backdrop, .gccli-stage svg { animation: none !important; } }
.gccli-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.gccli-th { position: relative; display: block; aspect-ratio: 4 / 3; padding: 0; border: 0; border-radius: 12px; overflow: hidden; cursor: zoom-in; transition: transform .3s cubic-bezier(.16, 1, .3, 1), box-shadow .3s; }
.gccli-th:hover { transform: scale(1.025); box-shadow: 0 14px 24px -10px rgba(0, 0, 0, .5); }
.gccli-th svg, .gccli-stage svg { display: block; width: 100%; height: 100%; }
.gccli-dlg { width: 100vw; max-width: none; height: 100dvh; max-height: none; margin: 0; padding: 0; border: 0; background: transparent; color: #fff; }
.gccli-dlg::backdrop { background: rgba(6, 7, 10, .92); animation: gccli-fade .25s ease-out; }
.gccli-wrap { display: grid; grid-template-rows: auto 1fr auto; width: 100%; height: 100%; padding: 16px; }
.gccli-top { display: flex; justify-content: space-between; align-items: center; }
.gccli-count { font-size: 13px; opacity: .75; font-variant-numeric: tabular-nums; }
.gccli-ib { display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 50%; background: rgba(255, 255, 255, .12); color: #fff; cursor: pointer; transition: background .15s; }
.gccli-ib:hover { background: rgba(255, 255, 255, .22); }
.gccli-mid { position: relative; display: grid; place-items: center; min-height: 0; }
.gccli-stage { width: min(820px, 100%); max-height: 100%; aspect-ratio: 4 / 3; overflow: hidden; border-radius: 14px; box-shadow: 0 30px 60px -20px rgba(0, 0, 0, .8); }
.gccli-stage svg { animation: gccli-pop .35s cubic-bezier(.16, 1, .3, 1); }
.gccli-nav { position: absolute; top: 50%; margin-top: -22px; }
.gccli-nav[data-s="prev"] { left: 0; }
.gccli-nav[data-s="next"] { right: 0; }
.gccli-cap { padding-top: 12px; text-align: center; font-size: 15px; }
@keyframes gccli-fade { from { opacity: 0; } }
@keyframes gccli-pop { from { opacity: 0; transform: scale(.97); } }`;

const DEFAULT: CapaLightboxItem[] = [
  { id: "1", title: "Amanecer en la bahía", a: "#ffb27a", b: "#5b4fd1", shape: "circulo" },
  { id: "2", title: "Cordillera al atardecer", a: "#7fd4ff", b: "#264d8c", shape: "montana" },
  { id: "3", title: "Marea tranquila", a: "#9be7c4", b: "#14566b", shape: "olas" },
  { id: "4", title: "Ciudad en bloques", a: "#ffd66b", b: "#d6456f", shape: "bloques" },
];

function Art({ item }: { item: CapaLightboxItem }) {
  const gid = `g-${item.id}`;
  return (
    <svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
      <defs>
        <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={item.a} /><stop offset="1" stopColor={item.b} /></linearGradient>
      </defs>
      <rect width="400" height="300" fill={`url(#${gid})`} />
      {item.shape === "circulo" ? <><circle cx="270" cy="150" r="70" fill="#fff6d6" opacity=".92" /><rect y="200" width="400" height="100" fill={item.b} opacity=".85" /></> : null}
      {item.shape === "montana" ? <><path d="M0 240 L90 120 L160 210 L240 90 L330 220 L400 150 V300 H0Z" fill="#0f2a55" opacity=".8" /><path d="M0 270 L120 190 L220 260 L320 180 L400 250 V300 H0Z" fill="#0a1d3d" /></> : null}
      {item.shape === "olas" ? <>{[0, 1, 2, 3].map((i) => <path key={i} d={`M0 ${170 + i * 32} C 80 ${150 + i * 32} 140 ${195 + i * 32} 220 ${170 + i * 32} S 340 ${150 + i * 32} 400 ${175 + i * 32} V300 H0Z`} fill={item.b} opacity={0.25 + i * 0.2} />)}</> : null}
      {item.shape === "bloques" ? <>{[[40, 150, 60, 150], [110, 100, 50, 200], [170, 170, 70, 130], [250, 80, 60, 220], [320, 140, 50, 160]].map(([x, y, w, h], i) => <rect key={i} x={x} y={y} width={w} height={h} fill="#2b1a4a" opacity={0.55 + i * 0.08} />)}</> : null}
    </svg>
  );
}

export function CapaLightbox({ items = DEFAULT, className = "", style }: CapaLightboxProps) {
  const id = useId();
  const ref = useRef<HTMLDialogElement>(null);
  const [i, setI] = useState(0);
  const go = (n: number) => setI((n + items.length) % items.length);
  const open = (index: number) => { setI(index); ref.current?.showModal(); };

  return (
    <div className={`gccli-root ${className}`} style={style}>
      <style>{CSS}</style>
      <div className="gccli-grid">
        {items.map((item, index) => (
          <button key={item.id} className="gccli-th" type="button" aria-label={`Ampliar: ${item.title}`} onClick={() => open(index)}>
            <Art item={item} />
          </button>
        ))}
      </div>
      <dialog ref={ref} className="gccli-dlg" aria-labelledby={`${id}-t`} onKeyDown={(event) => { if (event.key === "ArrowRight") go(i + 1); if (event.key === "ArrowLeft") go(i - 1); }} onClick={(event) => event.target === ref.current && ref.current?.close()}>
        <div className="gccli-wrap" onClick={(event) => event.target === event.currentTarget && ref.current?.close()}>
          <div className="gccli-top">
            <span className="gccli-count" aria-live="polite">{i + 1} / {items.length}</span>
            <button className="gccli-ib" type="button" aria-label="Cerrar" onClick={() => ref.current?.close()}>
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
            </button>
          </div>
          <div className="gccli-mid">
            <button className="gccli-ib gccli-nav" data-s="prev" type="button" aria-label="Anterior" onClick={() => go(i - 1)}>
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m15 6-6 6 6 6" /></svg>
            </button>
            <div className="gccli-stage" key={items[i].id}>
              <Art item={items[i]} />
            </div>
            <button className="gccli-ib gccli-nav" data-s="next" type="button" aria-label="Siguiente" onClick={() => go(i + 1)}>
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
            </button>
          </div>
          <p className="gccli-cap" id={`${id}-t`}>{items[i].title}</p>
        </div>
      </dialog>
    </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/capa-lightbox

Ejemplo de uso de Galería con lightbox

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

ejemplo.tsx5 líneas
import { CapaLightbox } from "./capa-lightbox";

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

Propiedades de CapaLightbox

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

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

Accesibilidad y rendimiento

El archivo tiene 106 líneas (7 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Galería con lightbox 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-*.
  • Responde al teclado, no solo al ratón.
  • Usa el elemento <dialog> nativo, con foco y cierre con Escape gestionados por el navegador.
  • 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)