Tres componentes: ventana modal, carrusel y buscador

Una galería que se abre en grande, un carrusel de testimonios y un buscador que filtra mientras escribes aparecen en casi todos los portfolios.

Modal: <dialog> + showModal() y close(); Escape y el foco, gratis.

Carrusel: un array, un índice y % para dar la vuelta; aria-live para anunciar el cambio.

Buscador: evento input, toLowerCase() + includes() y hidden en lo que no coincide.

  • Una galería que se abre en grande, un carrusel de testimonios y un buscador que filtra mientras escribes aparecen en casi todos los portfolios. Si se los pides a una IA te los hace en segundos, pero para saber si lo que te da está bien tienes que conocer cómo se construyen. Aquí tienes los tres, hechos con lo que ya sabes: querySelector, eventos, clases, atributos y arrays.
  • Una ventana modal se pone encima de la página y no deja usar lo de debajo hasta que se cierra. HTML tiene un elemento para eso, <dialog>, que ya resuelve lo difícil: se cierra con la tecla Escape, mantiene el foco dentro mientras está abierta y lo devuelve al botón que la abrió al cerrarse.
  • <ul class="miniaturas"> <li> <button type="button" class="miniatura" data-grande="img/cartel-1.webp" data-alt="Cartel del Festival Sur, tipografía roja sobre fondo crema"> <img src="img/cartel-1-mini.webp" alt="Cartel del Festival Sur"> </button> </li> <!-- … más miniaturas … --> </ul> <dialog id="visor" aria-label="Imagen ampliada"> <img id="visor-imagen" src="" alt=""> <button type="button" id="cerrar">Cerrar</button> </dialog>
  • const visor = document.querySelector("#visor"); const imagen = document.querySelector("#visor-imagen"); document.querySelectorAll(".miniatura").forEach((boton) => { boton.addEventListener("click", () => { imagen.src = boton.dataset.grande; // la imagen grande, de data-grande imagen.alt = boton.dataset.alt; visor.showModal(); // abre como modal: encima de todo }); }); document.querySelector("#cerrar").addEventListener("click", () => visor.close()); // Cerrar también al pulsar fuera de la imagen (en el fondo oscuro) visor.addEventListener("click", (evento) => { if (evento.target === visor) visor.close(); });
  • dialog { border: 0; padding: 0; max-width: min(90vw, 60rem); } dialog img { display: block; width: 100%; height: auto; } dialog::backdrop { background: rgb(0 0 0 / 0.75); } /* el fondo de detrás */

Introducción

Una galería que se abre en grande, un carrusel de testimonios y un buscador que filtra mientras escribes aparecen en casi todos los portfolios. Si se los pides a una IA te los hace en segundos, pero para saber si lo que te da está bien tienes que conocer cómo se construyen. Aquí tienes los tres, hechos con lo que ya sabes: querySelector, eventos, clases, atributos y arrays.

Ventana modal con <dialog>

Una ventana modal se pone encima de la página y no deja usar lo de debajo hasta que se cierra. HTML tiene un elemento para eso, <dialog>, que ya resuelve lo difícil: se cierra con la tecla Escape, mantiene el foco dentro mientras está abierta y lo devuelve al botón que la abrió al cerrarse.

<ul class="miniaturas"> <li> <button type="button" class="miniatura" data-grande="img/cartel-1.webp" data-alt="Cartel del Festival Sur, tipografía roja sobre fondo crema"> <img src="img/cartel-1-mini.webp" alt="Cartel del Festival Sur"> </button> </li> <!-- … más miniaturas … --> </ul> <dialog id="visor" aria-label="Imagen ampliada"> <img id="visor-imagen" src="" alt=""> <button type="button" id="cerrar">Cerrar</button> </dialog>

const visor = document.querySelector("#visor"); const imagen = document.querySelector("#visor-imagen"); document.querySelectorAll(".miniatura").forEach((boton) => { boton.addEventListener("click", () => { imagen.src = boton.dataset.grande; // la imagen grande, de data-grande imagen.alt = boton.dataset.alt; visor.showModal(); // abre como modal: encima de todo }); }); document.querySelector("#cerrar").addEventListener("click", () => visor.close()); // Cerrar también al pulsar fuera de la imagen (en el fondo oscuro) visor.addEventListener("click", (evento) => { if (evento.target === visor) visor.close(); });

dialog { border: 0; padding: 0; max-width: min(90vw, 60rem); } dialog img { display: block; width: 100%; height: auto; } dialog::backdrop { background: rgb(0 0 0 / 0.75); } /* el fondo de detrás */

Carrusel de testimonios

Un carrusel muestra un elemento de una lista cada vez y tiene botones para pasar al anterior y al siguiente. Por dentro es un array y un número, el índice del que se ve:

<section class="carrusel" aria-label="Testimonios"> <blockquote id="testimonio" aria-live="polite"></blockquote> <button type="button" id="anterior" aria-label="Testimonio anterior">←</button> <button type="button" id="siguiente" aria-label="Testimonio siguiente">→</button> </section>

const testimonios = [ { texto: "Entendió la marca a la primera.", autor: "Café Lento" }, { texto: "Entregó antes de plazo y sin sustos.", autor: "Librería Sur" }, { texto: "El cartel se agotó en una semana.", autor: "Festival Norte" }, ]; let indice = 0; const caja = document.querySelector("#testimonio"); function mostrar() { const t = testimonios[indice]; caja.innerHTML = ""; const p = document.createElement("p"); p.textContent = t.texto; const pie = document.createElement("footer"); pie.textContent = `— ${t.autor}`; caja.append(p, pie); } document.querySelector("#siguiente").addEventListener("click", () => { indice = (indice + 1) % testimonios.length; // del último vuelve al primero mostrar(); }); document.querySelector("#anterior").addEventListener("click", () => { indice = (indice - 1 + testimonios.length) % testimonios.length; // del primero va al último mostrar(); }); mostrar();

El % hace que el índice dé la vuelta, igual que el tono en el círculo cromático (lección Datos, variables y operadores): sumar la longitud antes de restar evita los negativos. aria-live="polite" hace que el lector de pantalla lea el testimonio nuevo cuando cambia. Los botones solo tienen una flecha, así que llevan aria-label. Si pasa solo cada pocos segundos, añade un botón de pausa y no lo muevas con prefers-reduced-motion: reduce (lección Variables, tipografía web y animación): un contenido que se mueve sin que lo pidas molesta y es un problema de accesibilidad.

Buscador en vivo

Filtra una lista mientras escribes: con cada tecla (evento input, lección Formularios con JavaScript) compara el texto con el título de cada tarjeta y oculta las que no coinciden.

<label for="buscador">Buscar proyectos</label> <input id="buscador" type="search"> <p id="resultados" aria-live="polite"></p> <article class="tarjeta"><h3>Cartel Festival Sur</h3>…</article> <article class="tarjeta"><h3>Identidad Café Lento</h3>…</article>

const buscador = document.querySelector("#buscador"); const tarjetas = document.querySelectorAll(".tarjeta"); const resultados = document.querySelector("#resultados"); buscador.addEventListener("input", () => { const texto = buscador.value.trim().toLowerCase(); let visibles = 0; tarjetas.forEach((tarjeta) => { const titulo = tarjeta.querySelector("h3").textContent.toLowerCase(); const coincide = titulo.includes(texto); tarjeta.hidden = !coincide; // oculta las que no coinciden if (coincide) visibles++; }); resultados.textContent = visibles === 0 ? "No hay proyectos con ese nombre." : `${visibles} proyectos`; });

toLowerCase() en los dos lados: así «cartel» encuentra «Cartel». trim() quita los espacios del principio y del final. Con el campo vacío, includes("") es true para todos: se ven todas las tarjetas. El párrafo con aria-live cuenta los resultados a quien no ve la pantalla.

Qué mirar en el código que te dé una IA

Con estos tres modelos delante, revisa lo que te genere con la lista de comprobación de la lección Programar con un copiloto de IA. Lo que más suele fallar en estos componentes:

Divs o imágenes con onclick en lugar de <button>: no funcionan con el teclado. Una modal hecha con div que no se cierra con Escape ni devuelve el foco. Un carrusel que se sale del array (índice -1 o igual a la longitud) por no usar %. Un buscador que distingue mayúsculas, o que no dice nada cuando no hay resultados. Librerías enteras (jQuery, un plugin de carrusel) para algo que son veinte líneas.

Errores frecuentes

Llamar a visor.show() en lugar de showModal(): se abre, pero no como modal (no hay fondo ni se cierra con Escape). Olvidar el alt de la imagen grande al cambiar su src. Usar innerHTML con texto que escribe el usuario: usa textContent. En el buscador, escuchar change en lugar de input: solo filtra al salir del campo. Poner hidden a una tarjeta que tiene display: flex o grid en el CSS y que no desaparezca: tu regla gana a la del navegador. Añade [hidden] { display: none !important; } a tu hoja de estilos o usa una clase .oculto.

JavaScript
72

Tres componentes: ventana modal, carrusel y buscador

Una galería que se abre en grande, un carrusel de testimonios y un buscador que filtra mientras escribes aparecen en casi todos los portfolios.

Código del tema: visor.showModal()

📘 Teoría

Introducción

Una galería que se abre en grande, un carrusel de testimonios y un buscador que filtra mientras escribes aparecen en casi todos los portfolios. Si se los pides a una IA te los hace en segundos, pero para saber si lo que te da está bien tienes que conocer cómo se construyen. Aquí tienes los tres, hechos con lo que ya sabes: querySelector, eventos, clases, atributos y arrays.

Ventana modal con <dialog>

Una ventana modal se pone encima de la página y no deja usar lo de debajo hasta que se cierra. HTML tiene un elemento para eso, <dialog>, que ya resuelve lo difícil: se cierra con la tecla Escape, mantiene el foco dentro mientras está abierta y lo devuelve al botón que la abrió al cerrarse.

<ul class="miniaturas">
  <li>
    <button type="button" class="miniatura" data-grande="img/cartel-1.webp" data-alt="Cartel del Festival Sur, tipografía roja sobre fondo crema">
      <img src="img/cartel-1-mini.webp" alt="Cartel del Festival Sur">
    </button>
  </li>
  <!-- … más miniaturas … -->
</ul>

<dialog id="visor" aria-label="Imagen ampliada">
  <img id="visor-imagen" src="" alt="">
  <button type="button" id="cerrar">Cerrar</button>
</dialog>
const visor = document.querySelector("#visor");
const imagen = document.querySelector("#visor-imagen");

document.querySelectorAll(".miniatura").forEach((boton) => {
  boton.addEventListener("click", () => {
    imagen.src = boton.dataset.grande;   // la imagen grande, de data-grande
    imagen.alt = boton.dataset.alt;
    visor.showModal();                   // abre como modal: encima de todo
  });
});

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

// Cerrar también al pulsar fuera de la imagen (en el fondo oscuro)
visor.addEventListener("click", (evento) => {
  if (evento.target === visor) visor.close();
});
dialog { border: 0; padding: 0; max-width: min(90vw, 60rem); }
dialog img { display: block; width: 100%; height: auto; }
dialog::backdrop { background: rgb(0 0 0 / 0.75); }   /* el fondo de detrás */
  • showModal() la abre como modal; close() la cierra. Escape funciona solo.
  • Las miniaturas son <button>, no imágenes con click: así se pueden usar con el teclado (lección Eventos).
  • ::backdrop es el pseudoelemento de la capa oscura que queda detrás.

Carrusel de testimonios

Un carrusel muestra un elemento de una lista cada vez y tiene botones para pasar al anterior y al siguiente. Por dentro es un array y un número, el índice del que se ve:

<section class="carrusel" aria-label="Testimonios">
  <blockquote id="testimonio" aria-live="polite"></blockquote>
  <button type="button" id="anterior" aria-label="Testimonio anterior">←</button>
  <button type="button" id="siguiente" aria-label="Testimonio siguiente">→</button>
</section>
const testimonios = [
  { texto: "Entendió la marca a la primera.", autor: "Café Lento" },
  { texto: "Entregó antes de plazo y sin sustos.", autor: "Librería Sur" },
  { texto: "El cartel se agotó en una semana.", autor: "Festival Norte" },
];
let indice = 0;
const caja = document.querySelector("#testimonio");

function mostrar() {
  const t = testimonios[indice];
  caja.innerHTML = "";
  const p = document.createElement("p");
  p.textContent = t.texto;
  const pie = document.createElement("footer");
  pie.textContent = `— ${t.autor}`;
  caja.append(p, pie);
}

document.querySelector("#siguiente").addEventListener("click", () => {
  indice = (indice + 1) % testimonios.length;                        // del último vuelve al primero
  mostrar();
});
document.querySelector("#anterior").addEventListener("click", () => {
  indice = (indice - 1 + testimonios.length) % testimonios.length;   // del primero va al último
  mostrar();
});

mostrar();
  • El % hace que el índice dé la vuelta, igual que el tono en el círculo cromático (lección Datos, variables y operadores): sumar la longitud antes de restar evita los negativos.
  • aria-live="polite" hace que el lector de pantalla lea el testimonio nuevo cuando cambia.
  • Los botones solo tienen una flecha, así que llevan aria-label.
  • Si pasa solo cada pocos segundos, añade un botón de pausa y no lo muevas con prefers-reduced-motion: reduce (lección Variables, tipografía web y animación): un contenido que se mueve sin que lo pidas molesta y es un problema de accesibilidad.

Buscador en vivo

Filtra una lista mientras escribes: con cada tecla (evento input, lección Formularios con JavaScript) compara el texto con el título de cada tarjeta y oculta las que no coinciden.

<label for="buscador">Buscar proyectos</label>
<input id="buscador" type="search">
<p id="resultados" aria-live="polite"></p>

<article class="tarjeta"><h3>Cartel Festival Sur</h3>…</article>
<article class="tarjeta"><h3>Identidad Café Lento</h3>…</article>
const buscador = document.querySelector("#buscador");
const tarjetas = document.querySelectorAll(".tarjeta");
const resultados = document.querySelector("#resultados");

buscador.addEventListener("input", () => {
  const texto = buscador.value.trim().toLowerCase();
  let visibles = 0;

  tarjetas.forEach((tarjeta) => {
    const titulo = tarjeta.querySelector("h3").textContent.toLowerCase();
    const coincide = titulo.includes(texto);
    tarjeta.hidden = !coincide;          // oculta las que no coinciden
    if (coincide) visibles++;
  });

  resultados.textContent = visibles === 0
    ? "No hay proyectos con ese nombre."
    : `${visibles} proyectos`;
});
  • toLowerCase() en los dos lados: así «cartel» encuentra «Cartel».
  • trim() quita los espacios del principio y del final.
  • Con el campo vacío, includes("") es true para todos: se ven todas las tarjetas.
  • El párrafo con aria-live cuenta los resultados a quien no ve la pantalla.

Qué mirar en el código que te dé una IA

Con estos tres modelos delante, revisa lo que te genere con la lista de comprobación de la lección Programar con un copiloto de IA. Lo que más suele fallar en estos componentes:

  • Divs o imágenes con onclick en lugar de <button>: no funcionan con el teclado.
  • Una modal hecha con div que no se cierra con Escape ni devuelve el foco.
  • Un carrusel que se sale del array (índice -1 o igual a la longitud) por no usar %.
  • Un buscador que distingue mayúsculas, o que no dice nada cuando no hay resultados.
  • Librerías enteras (jQuery, un plugin de carrusel) para algo que son veinte líneas.

Errores frecuentes

  • Llamar a visor.show() en lugar de showModal(): se abre, pero no como modal (no hay fondo ni se cierra con Escape).
  • Olvidar el alt de la imagen grande al cambiar su src.
  • Usar innerHTML con texto que escribe el usuario: usa textContent.
  • En el buscador, escuchar change en lugar de input: solo filtra al salir del campo.
  • Poner hidden a una tarjeta que tiene display: flex o grid en el CSS y que no desaparezca: tu regla gana a la del navegador. Añade [hidden] { display: none !important; } a tu hoja de estilos o usa una clase .oculto.

¿Qué es esto?

Soy Cristian Eslava y a veces hago webs para procrastinar yo y vosotros. culTest

La hice en febrero de 2026 para facilitar el aprendizaje de mis alumnos. La idea es aprender desarrollo web practicando y que el proyecto siga creciendo con nuevos temas, tests y retos.

Está inspirada en MDN, W3Schools, CodePen, Manz y muchos otros sitios de documentación sobre desarrollo web. Quería combinar teoría útil, ejemplos ejecutables, retos y el sistema de tests que ya tenía en culTest. culTest

Si te gustó, si no te gustó o si quieres escribirme, puedes hacerlo en cristianeslava@gmail.com