Proyectos · JavaScript · Avanzado

Galería de fotos

Una galería en rejilla con búsqueda que ignora las tildes, filtros por categoría combinables y un visor a pantalla completa que se recorre con las flechas.

  • 5 pasos
  • 1 hora y media aproximadamente
  • 211 líneas de código
  • Editor en vivo

Lo que vas a construir

Una galería parece un trabajo de diseño, pero detrás hay decisiones de programación: cómo filtrar por varias cosas a la vez, cómo buscar sin que importen las tildes o cómo mostrar un visor accesible que se cierre con Esc. Vas a construirla con datos en un array, CSS Grid y el elemento <dialog> del navegador.

Lo que vas a practicar

  • Rejillas adaptables con auto-fill
  • Carga diferida de imágenes
  • Buscar sin tener en cuenta las tildes
  • Combinar filtros
  • Un visor accesible con dialog
Resultado final

Antes de empezar, conviene haber hecho estas lecciones

Paso 1 de 5

La estructura y la rejilla

El buscador, los botones de categoría y una lista vacía donde irán las fotos. Con grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)), la rejilla pone tantas columnas como quepan, de al menos 220 px: tres en el ordenador y una en el móvil, sin ninguna consulta de medios.

Las fotos son de dominio público y están alojadas en esta web; en tu proyecto puedes cambiarlas por las tuyas.

index.html
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Galería</title>
  <link rel="stylesheet" href="estilos.css">
</head>
<body>
  <main class="pagina">
    <h1>Galería</h1>
    <div class="controles">
      <label for="buscar" class="oculto">Buscar fotos</label>
      <input type="search" id="buscar" placeholder="Buscar: gato, calle, cámara…" autocomplete="off">
      <div class="categorias" role="group" aria-label="Categoría">
        <button type="button" data-categoria="todas" aria-pressed="true">Todas</button>
        <button type="button" data-categoria="naturaleza" aria-pressed="false">Naturaleza</button>
        <button type="button" data-categoria="ciudad" aria-pressed="false">Ciudad</button>
        <button type="button" data-categoria="objetos" aria-pressed="false">Objetos</button>
      </div>
    </div>
    <p id="estado" class="estado" aria-live="polite"></p>
    <ul id="galeria" class="galeria"></ul>
    <p class="creditos">Fotos de dominio público (CC0 y Public Domain Mark) vía Openverse.</p>
  </main>
  <script src="app.js"></script>
</body>
</html>
estilos.css
* { box-sizing: border-box; }

body { margin: 0; background: #f8fafc; color: #0f172a; font-family: system-ui, sans-serif; }

.pagina { max-width: 1000px; margin: 0 auto; padding: 24px 16px; }

h1 { margin: 0 0 16px; }

.oculto { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }

.controles { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }

input[type="search"] {
  flex: 1 1 240px;
  padding: 10px 12px;
  border: 2px solid #cbd5e1;
  border-radius: 10px;
  font: inherit;
}

.categorias { display: flex; flex-wrap: wrap; gap: 6px; }

button {
  padding: 8px 14px;
  border: 0;
  border-radius: 999px;
  background: #e2e8f0;
  color: #0f172a;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
button[aria-pressed="true"] { background: #0f172a; color: #ffffff; }
button:focus-visible, input:focus-visible { outline: 3px solid #f59e0b; outline-offset: 2px; }

.estado { min-height: 1.5em; color: #475569; }

.galeria {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 12px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.galeria button { display: block; width: 100%; padding: 0; border-radius: 12px; overflow: hidden; background: none; }
.galeria img { display: block; width: 100%; height: 100%; aspect-ratio: 3 / 2; object-fit: cover; transition: transform 300ms ease; }
.galeria button:hover img { transform: scale(1.05); }

.creditos { margin-top: 24px; font-size: 0.85rem; color: #64748b; }

.visor { max-width: min(92vw, 900px); padding: 0; border: 0; border-radius: 14px; background: #0f172a; color: #f8fafc; }
.visor::backdrop { background: rgb(2 6 23 / 0.8); }
.visor img { display: block; width: 100%; max-height: 75vh; object-fit: contain; }
.visor p { margin: 0; padding: 12px 56px 14px 16px; }
.cerrar { position: absolute; top: 8px; right: 8px; width: 40px; height: 40px; padding: 0; font-size: 1.5rem; }
.flecha { position: absolute; top: 40%; width: 44px; height: 44px; padding: 0; font-size: 1.8rem; background: rgb(255 255 255 / 0.85); }
.flecha--izq { left: 8px; }
.flecha--der { right: 8px; }

@media (prefers-reduced-motion: reduce) { .galeria img { transition: none; } }
app.js
// El JavaScript llega en el paso 2

Paso 2 de 5

Las fotos como datos

Cada foto es un objeto con su identificador, una descripción (que será su texto alternativo) y su categoría. pintar crea un botón con su imagen por cada foto, para que se puedan abrir también con el teclado.

width y height reservan el hueco antes de que la imagen llegue (la página no salta) y loading="lazy" hace que solo se descarguen las fotos que van a verse.

app.js
const URL_FOTOS = "https://www.portaltec.eu/assets/img/fotos/";

const FOTOS = [
  { id: "a767fc64", texto: "Bosque con árboles de ramas retorcidas cubiertas de musgo", categoria: "naturaleza" },
  { id: "2f9aafaa", texto: "Nubes blancas en un cielo azul", categoria: "naturaleza" },
  { id: "c2d49b4d", texto: "Gato durmiendo sobre una manta", categoria: "naturaleza" },
  { id: "d8420e33", texto: "Calle llena de carteles luminosos por la noche", categoria: "ciudad" },
  { id: "d823a23c", texto: "Placas con nombres de calles en una esquina", categoria: "ciudad" },
  { id: "ff86cff8", texto: "Escritorio junto a un ventanal con vistas a la ciudad", categoria: "ciudad" },
  { id: "b07dc822", texto: "Cámara de fotos antigua sobre una mesa de madera", categoria: "objetos" },
  { id: "c2540b55", texto: "Despertador rojo y amarillo sobre una manta", categoria: "objetos" },
  { id: "c727a1bc", texto: "Móvil con varias cámaras junto a una cámara antigua", categoria: "objetos" },
];

const galeria = document.querySelector("#galeria");

function pintar(lista) {
  galeria.replaceChildren(
    ...lista.map((foto) => {
      const elemento = document.createElement("li");
      const boton = document.createElement("button");
      boton.type = "button";
      boton.dataset.id = foto.id;
      const imagen = document.createElement("img");
      imagen.src = `${URL_FOTOS}${foto.id}-800.webp`;
      imagen.alt = foto.texto;
      imagen.width = 800;
      imagen.height = 533;
      imagen.loading = "lazy"; // no se descarga hasta que va a verse
      boton.append(imagen);
      elemento.append(boton);
      return elemento;
    }),
  );
}

pintar(FOTOS);

Paso 3 de 5

Buscar y filtrar a la vez

visibles() aplica los dos filtros juntos: la foto tiene que ser de la categoría elegida y contener todas las palabras buscadas. Cada cambio en el buscador o en las categorías vuelve a pintar la galería.

normalize("NFD") separa las tildes de las letras y la expresión regular las elimina: así «camara» encuentra «cámara». Si no queda ninguna foto, un mensaje lo dice.

app.jsLo nuevo, resaltado
const URL_FOTOS = "https://www.portaltec.eu/assets/img/fotos/";

const FOTOS = [
  { id: "a767fc64", texto: "Bosque con árboles de ramas retorcidas cubiertas de musgo", categoria: "naturaleza" },
  { id: "2f9aafaa", texto: "Nubes blancas en un cielo azul", categoria: "naturaleza" },
  { id: "c2d49b4d", texto: "Gato durmiendo sobre una manta", categoria: "naturaleza" },
  { id: "d8420e33", texto: "Calle llena de carteles luminosos por la noche", categoria: "ciudad" },
  { id: "d823a23c", texto: "Placas con nombres de calles en una esquina", categoria: "ciudad" },
  { id: "ff86cff8", texto: "Escritorio junto a un ventanal con vistas a la ciudad", categoria: "ciudad" },
  { id: "b07dc822", texto: "Cámara de fotos antigua sobre una mesa de madera", categoria: "objetos" },
  { id: "c2540b55", texto: "Despertador rojo y amarillo sobre una manta", categoria: "objetos" },
  { id: "c727a1bc", texto: "Móvil con varias cámaras junto a una cámara antigua", categoria: "objetos" },
];

const galeria = document.querySelector("#galeria");
const buscar = document.querySelector("#buscar");
const estado = document.querySelector("#estado");
const botones = document.querySelectorAll("[data-categoria]");

function pintar(lista) {
  galeria.replaceChildren(
    ...lista.map((foto) => {
      const elemento = document.createElement("li");
      const boton = document.createElement("button");
      boton.type = "button";
      boton.dataset.id = foto.id;
      const imagen = document.createElement("img");
      imagen.src = `${URL_FOTOS}${foto.id}-800.webp`;
      imagen.alt = foto.texto;
      imagen.width = 800;
      imagen.height = 533;
      imagen.loading = "lazy"; // no se descarga hasta que va a verse
      boton.append(imagen);
      elemento.append(boton);
      return elemento;
    }),
  );
}

let categoria = "todas";

// Quita tildes y pasa a minúsculas: «cámara» y «camara» coinciden
function normalizar(texto) {
  return texto.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}

function visibles() {
  const palabras = normalizar(buscar.value).split(/\s+/).filter(Boolean);
  return FOTOS.filter((foto) => {
    const enCategoria = categoria === "todas" || foto.categoria === categoria;
    const texto = normalizar(foto.texto);
    return enCategoria && palabras.every((p) => texto.includes(p));
  });
}

function actualizar() {
  const lista = visibles();
  pintar(lista);
  estado.textContent = lista.length ? `${lista.length} de ${FOTOS.length} fotos` : "No hay ninguna foto con esa búsqueda.";
}

buscar.addEventListener("input", actualizar);

for (const boton of botones) {
  boton.addEventListener("click", () => {
    categoria = boton.dataset.categoria;
    for (const b of botones) b.setAttribute("aria-pressed", String(b === boton));
    actualizar();
  });
}

actualizar();

Paso 4 de 5

El visor con dialog

El elemento <dialog> con showModal() muestra la foto grande por encima de todo: el navegador oscurece el fondo (::backdrop), lleva el foco dentro, impide usar lo de detrás y lo cierra con Esc sin escribir nada más.

Un clic en el fondo oscuro también lo cierra: en ese caso, el objetivo del clic es el propio <dialog> y no su contenido.

index.htmlLo nuevo, resaltado
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Galería</title>
  <link rel="stylesheet" href="estilos.css">
</head>
<body>
  <main class="pagina">
    <h1>Galería</h1>
    <div class="controles">
      <label for="buscar" class="oculto">Buscar fotos</label>
      <input type="search" id="buscar" placeholder="Buscar: gato, calle, cámara…" autocomplete="off">
      <div class="categorias" role="group" aria-label="Categoría">
        <button type="button" data-categoria="todas" aria-pressed="true">Todas</button>
        <button type="button" data-categoria="naturaleza" aria-pressed="false">Naturaleza</button>
        <button type="button" data-categoria="ciudad" aria-pressed="false">Ciudad</button>
        <button type="button" data-categoria="objetos" aria-pressed="false">Objetos</button>
      </div>
    </div>
    <p id="estado" class="estado" aria-live="polite"></p>
    <ul id="galeria" class="galeria"></ul>
    <p class="creditos">Fotos de dominio público (CC0 y Public Domain Mark) vía Openverse.</p>
  </main>

  <dialog id="visor" class="visor" aria-labelledby="visor-texto">
    <img id="visor-foto" alt="">
    <p id="visor-texto"></p>
    <button type="button" id="cerrar" class="cerrar" aria-label="Cerrar">×</button>
  </dialog>
  <script src="app.js"></script>
</body>
</html>
app.jsLo nuevo, resaltado
const URL_FOTOS = "https://www.portaltec.eu/assets/img/fotos/";

const FOTOS = [
  { id: "a767fc64", texto: "Bosque con árboles de ramas retorcidas cubiertas de musgo", categoria: "naturaleza" },
  { id: "2f9aafaa", texto: "Nubes blancas en un cielo azul", categoria: "naturaleza" },
  { id: "c2d49b4d", texto: "Gato durmiendo sobre una manta", categoria: "naturaleza" },
  { id: "d8420e33", texto: "Calle llena de carteles luminosos por la noche", categoria: "ciudad" },
  { id: "d823a23c", texto: "Placas con nombres de calles en una esquina", categoria: "ciudad" },
  { id: "ff86cff8", texto: "Escritorio junto a un ventanal con vistas a la ciudad", categoria: "ciudad" },
  { id: "b07dc822", texto: "Cámara de fotos antigua sobre una mesa de madera", categoria: "objetos" },
  { id: "c2540b55", texto: "Despertador rojo y amarillo sobre una manta", categoria: "objetos" },
  { id: "c727a1bc", texto: "Móvil con varias cámaras junto a una cámara antigua", categoria: "objetos" },
];

const galeria = document.querySelector("#galeria");
const buscar = document.querySelector("#buscar");
const estado = document.querySelector("#estado");
const botones = document.querySelectorAll("[data-categoria]");

function pintar(lista) {
  galeria.replaceChildren(
    ...lista.map((foto) => {
      const elemento = document.createElement("li");
      const boton = document.createElement("button");
      boton.type = "button";
      boton.dataset.id = foto.id;
      const imagen = document.createElement("img");
      imagen.src = `${URL_FOTOS}${foto.id}-800.webp`;
      imagen.alt = foto.texto;
      imagen.width = 800;
      imagen.height = 533;
      imagen.loading = "lazy"; // no se descarga hasta que va a verse
      boton.append(imagen);
      elemento.append(boton);
      return elemento;
    }),
  );
}

let categoria = "todas";

// Quita tildes y pasa a minúsculas: «cámara» y «camara» coinciden
function normalizar(texto) {
  return texto.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}

function visibles() {
  const palabras = normalizar(buscar.value).split(/\s+/).filter(Boolean);
  return FOTOS.filter((foto) => {
    const enCategoria = categoria === "todas" || foto.categoria === categoria;
    const texto = normalizar(foto.texto);
    return enCategoria && palabras.every((p) => texto.includes(p));
  });
}

function actualizar() {
  const lista = visibles();
  pintar(lista);
  estado.textContent = lista.length ? `${lista.length} de ${FOTOS.length} fotos` : "No hay ninguna foto con esa búsqueda.";
}

buscar.addEventListener("input", actualizar);

for (const boton of botones) {
  boton.addEventListener("click", () => {
    categoria = boton.dataset.categoria;
    for (const b of botones) b.setAttribute("aria-pressed", String(b === boton));
    actualizar();
  });
}

const visor = document.querySelector("#visor");
const visorFoto = document.querySelector("#visor-foto");
const visorTexto = document.querySelector("#visor-texto");

function abrir(foto) {
  visorFoto.src = `${URL_FOTOS}${foto.id}-1200.webp`; // la versión grande
  visorFoto.alt = foto.texto;
  visorTexto.textContent = foto.texto;
  if (!visor.open) visor.showModal(); // modal: bloquea el resto y se cierra con Esc
}

galeria.addEventListener("click", (event) => {
  const boton = event.target.closest("button");
  if (boton) abrir(FOTOS.find((f) => f.id === boton.dataset.id));
});

document.querySelector("#cerrar").addEventListener("click", () => visor.close());

// Clic fuera de la foto (en el fondo oscuro) también cierra
visor.addEventListener("click", (event) => {
  if (event.target === visor) visor.close();
});

actualizar();

Paso 5 de 5

Recorrer las fotos con las flechas

El visor recuerda la posición de la foto abierta entre las visibles (respeta el filtro activo). mover suma o resta una posición y, con el operador %, al pasar de la última vuelve a la primera.

Los botones ‹ y › y las flechas del teclado llaman a mover. Los botones llevan aria-label porque sus símbolos no se leen bien en voz alta.

index.htmlLo nuevo, resaltado
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Galería</title>
  <link rel="stylesheet" href="estilos.css">
</head>
<body>
  <main class="pagina">
    <h1>Galería</h1>
    <div class="controles">
      <label for="buscar" class="oculto">Buscar fotos</label>
      <input type="search" id="buscar" placeholder="Buscar: gato, calle, cámara…" autocomplete="off">
      <div class="categorias" role="group" aria-label="Categoría">
        <button type="button" data-categoria="todas" aria-pressed="true">Todas</button>
        <button type="button" data-categoria="naturaleza" aria-pressed="false">Naturaleza</button>
        <button type="button" data-categoria="ciudad" aria-pressed="false">Ciudad</button>
        <button type="button" data-categoria="objetos" aria-pressed="false">Objetos</button>
      </div>
    </div>
    <p id="estado" class="estado" aria-live="polite"></p>
    <ul id="galeria" class="galeria"></ul>
    <p class="creditos">Fotos de dominio público (CC0 y Public Domain Mark) vía Openverse.</p>
  </main>

  <dialog id="visor" class="visor" aria-labelledby="visor-texto">
    <img id="visor-foto" alt="">
    <p id="visor-texto"></p>
    <button type="button" id="anterior" class="flecha flecha--izq" aria-label="Foto anterior">‹</button>
    <button type="button" id="siguiente" class="flecha flecha--der" aria-label="Foto siguiente">›</button>
    <button type="button" id="cerrar" class="cerrar" aria-label="Cerrar">×</button>
  </dialog>
  <script src="app.js"></script>
</body>
</html>
app.jsLo nuevo, resaltado
const URL_FOTOS = "https://www.portaltec.eu/assets/img/fotos/";

const FOTOS = [
  { id: "a767fc64", texto: "Bosque con árboles de ramas retorcidas cubiertas de musgo", categoria: "naturaleza" },
  { id: "2f9aafaa", texto: "Nubes blancas en un cielo azul", categoria: "naturaleza" },
  { id: "c2d49b4d", texto: "Gato durmiendo sobre una manta", categoria: "naturaleza" },
  { id: "d8420e33", texto: "Calle llena de carteles luminosos por la noche", categoria: "ciudad" },
  { id: "d823a23c", texto: "Placas con nombres de calles en una esquina", categoria: "ciudad" },
  { id: "ff86cff8", texto: "Escritorio junto a un ventanal con vistas a la ciudad", categoria: "ciudad" },
  { id: "b07dc822", texto: "Cámara de fotos antigua sobre una mesa de madera", categoria: "objetos" },
  { id: "c2540b55", texto: "Despertador rojo y amarillo sobre una manta", categoria: "objetos" },
  { id: "c727a1bc", texto: "Móvil con varias cámaras junto a una cámara antigua", categoria: "objetos" },
];

const galeria = document.querySelector("#galeria");
const buscar = document.querySelector("#buscar");
const estado = document.querySelector("#estado");
const botones = document.querySelectorAll("[data-categoria]");

function pintar(lista) {
  galeria.replaceChildren(
    ...lista.map((foto) => {
      const elemento = document.createElement("li");
      const boton = document.createElement("button");
      boton.type = "button";
      boton.dataset.id = foto.id;
      const imagen = document.createElement("img");
      imagen.src = `${URL_FOTOS}${foto.id}-800.webp`;
      imagen.alt = foto.texto;
      imagen.width = 800;
      imagen.height = 533;
      imagen.loading = "lazy"; // no se descarga hasta que va a verse
      boton.append(imagen);
      elemento.append(boton);
      return elemento;
    }),
  );
}

let categoria = "todas";

// Quita tildes y pasa a minúsculas: «cámara» y «camara» coinciden
function normalizar(texto) {
  return texto.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}

function visibles() {
  const palabras = normalizar(buscar.value).split(/\s+/).filter(Boolean);
  return FOTOS.filter((foto) => {
    const enCategoria = categoria === "todas" || foto.categoria === categoria;
    const texto = normalizar(foto.texto);
    return enCategoria && palabras.every((p) => texto.includes(p));
  });
}

function actualizar() {
  const lista = visibles();
  pintar(lista);
  estado.textContent = lista.length ? `${lista.length} de ${FOTOS.length} fotos` : "No hay ninguna foto con esa búsqueda.";
}

buscar.addEventListener("input", actualizar);

for (const boton of botones) {
  boton.addEventListener("click", () => {
    categoria = boton.dataset.categoria;
    for (const b of botones) b.setAttribute("aria-pressed", String(b === boton));
    actualizar();
  });
}

const visor = document.querySelector("#visor");
const visorFoto = document.querySelector("#visor-foto");
const visorTexto = document.querySelector("#visor-texto");

let actual = 0; // posición de la foto abierta dentro de las visibles

function abrir(foto) {
  actual = visibles().indexOf(foto);
  visorFoto.src = `${URL_FOTOS}${foto.id}-1200.webp`; // la versión grande
  visorFoto.alt = foto.texto;
  visorTexto.textContent = foto.texto;
  if (!visor.open) visor.showModal(); // modal: bloquea el resto y se cierra con Esc
}

galeria.addEventListener("click", (event) => {
  const boton = event.target.closest("button");
  if (boton) abrir(FOTOS.find((f) => f.id === boton.dataset.id));
});

document.querySelector("#cerrar").addEventListener("click", () => visor.close());

// Ir a la anterior o a la siguiente entre las fotos visibles (al llegar al final, vuelve al principio)
function mover(paso) {
  const lista = visibles();
  actual = (actual + paso + lista.length) % lista.length;
  abrir(lista[actual]);
}

document.querySelector("#anterior").addEventListener("click", () => mover(-1));
document.querySelector("#siguiente").addEventListener("click", () => mover(1));

visor.addEventListener("keydown", (event) => {
  if (event.key === "ArrowLeft") mover(-1);
  if (event.key === "ArrowRight") mover(1);
});

// Clic fuera de la foto (en el fondo oscuro) también cierra
visor.addEventListener("click", (event) => {
  if (event.target === visor) visor.close();
});

actualizar();

Retos para seguir

Has terminado el proyecto. Si quieres ir más allá, prueba a añadirle esto por tu cuenta:

  1. Desliza el dedo en el visor para pasar de foto en el móvil.
  2. Añade la fecha a cada foto y un selector para ordenar por fecha.
  3. Guarda en la dirección (#foto-id) la foto abierta, para poder compartir el enlace.

Conceptos clave

Repasa la ruta de JavaScript o elige otro proyecto.