Un slideshow muestra una imagen de un conjunto y permite cambiar a otra sin recargar la página. Las tres variantes de este documento son la misma estructura con tres formas distintas de disparar el cambio: una flecha, un reloj, y la combinación de ambas.
No hace falta nada nuevo. Las piezas son las de la clase anterior:
getElementsByClassName.Lo único que cambia de una variante a otra es quién modifica el índice.
Las flechas suman o restan uno al índice. El problema está en los extremos: pasada la
última hay que volver a la primera, y antes de la primera hay que ir a la última. Con dos
if queda resuelto.
el slideshow manual, en su versión mínima
<div class="slideshow" id="slideshow"> <img class="slide" src="IMG/naturaleza-arroyo.jpg" alt="Arroyo de montaña en un bosque de Trentino"> <img class="slide" src="IMG/naturaleza-abetos.jpg" alt="Arroyo entre abetos bajo un cielo nublado"> <img class="slide" src="IMG/naturaleza-valle.jpg" alt="Valle con niebla al amanecer"> <img class="slide" src="IMG/naturaleza-lago-bn.jpg" alt="Lago entre montañas en blanco y negro"> </div> <div class="row"> <button class="arrow" id="anterior" aria-label="Imagen anterior">❮</button> <button class="arrow" id="siguiente" aria-label="Imagen siguiente">❯</button> <div class="caja" id="contador">1 de 4</div> </div>
.caja { background: #dcdcea; border: 2px solid #8686a8; padding: 1rem 1.2rem; font-family: Verdana, sans-serif; font-size: .85rem; } .row { display: flex; gap: 10px; align-items: center; } button { font: inherit; font-size: .85rem; padding: .4rem .9rem; border: 1px solid #8686a8; background: #e8e8f2; cursor: pointer; } .slideshow { max-width: 34rem; margin-bottom: .8rem; } .slideshow img { display: block; width: 100%; aspect-ratio: 3 / 2; /* la misma para todas: el alto no salta */ object-fit: cover; } .arrow { font-size: 1.3rem; line-height: 1; padding: .2rem .7rem; }
const fotos = document.getElementsByClassName("slide"); const contador = document.getElementById("contador"); let indice = 0; function mostrar(n) { for (let i = 0; i < fotos.length; i++) { fotos[i].style.display = (i === n) ? "block" : "none"; } contador.textContent = (n + 1) + " de " + fotos.length; } function mover(paso) { indice = indice + paso; if (indice >= fotos.length) indice = 0; // pasada la última if (indice < 0) indice = fotos.length - 1; // antes de la primera mostrar(indice); } mostrar(indice); document.getElementById("anterior").addEventListener("click", function () { mover(-1); }); document.getElementById("siguiente").addEventListener("click", function () { mover(1); });
Las dos flechas llaman a mover con 1 y con
-1. Ese es todo el parecido y toda la diferencia: una función, un argumento.
fotos.length - 1 es el número de la última, no fotos.length.
Con cuatro imágenes los números van del 0 al 3, y
length vale 4. Pedir la 4 devuelve
undefined, y el slideshow se queda en blanco sin dar ningún error.
Puestas dentro del contenedor y colocadas con position: absolute, las
flechas quedan sobre la imagen. Exige que el contenedor tenga
position: relative, para que el absolute se mida respecto a él y
no respecto a la ventana.
El pie de foto no se escribe aparte: ya está en el alt de cada imagen, y de
ahí se lee. Un solo texto por imagen, en un solo sitio.
flechas superpuestas, pie de foto y dots (puntos indicadores)
<div class="slideshow" id="slideshow"> <div class="slideshow__track"> <img class="slide" src="IMG/naturaleza-arroyo.jpg" alt="Arroyo de montaña en un bosque de Trentino"> <img class="slide" src="IMG/naturaleza-abetos.jpg" alt="Arroyo entre abetos bajo un cielo nublado"> <img class="slide" src="IMG/naturaleza-valle.jpg" alt="Valle con niebla al amanecer"> <img class="slide" src="IMG/naturaleza-lago-bn.jpg" alt="Lago entre montañas en blanco y negro"> </div> <button class="slideshow__arrow slideshow__arrow--left" data-paso="-1" aria-label="Imagen anterior">❮</button> <button class="slideshow__arrow slideshow__arrow--right" data-paso="1" aria-label="Imagen siguiente">❯</button> <p class="slideshow__caption" id="caption"></p> </div> <div class="dots" id="puntos"> <button class="dot" data-ir="0" aria-label="Ir a la imagen 1"></button> <button class="dot" data-ir="1" aria-label="Ir a la imagen 2"></button> <button class="dot" data-ir="2" aria-label="Ir a la imagen 3"></button> <button class="dot" data-ir="3" aria-label="Ir a la imagen 4"></button> </div>
button {
font: inherit;
font-size: .85rem;
padding: .4rem .9rem;
border: 1px solid #8686a8;
background: #e8e8f2;
cursor: pointer;
}
.slideshow {
position: relative; /* el marco de referencia de las flechas */
max-width: 34rem;
}
.slideshow img {
display: block;
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
}
.slideshow__arrow {
position: absolute;
top: 50%;
transform: translateY(-50%); /* la mitad de su propio alto, para centrarla */
border: 0;
padding: .6rem .8rem;
font-size: 1.4rem;
line-height: 1;
color: #fff;
background: rgb(0, 0, 0);
opacity: .55;
transition: opacity 200ms;
}
.slideshow__arrow:hover { opacity: .85; }
.slideshow__arrow--left { left: 0; }
.slideshow__arrow--right { right: 0; }
.slideshow__caption {
position: absolute;
bottom: 0;
width: 100%;
margin: 0;
padding: .5rem .8rem;
box-sizing: border-box;
color: #fff;
background: rgb(0, 0, 0);
font-family: Verdana, sans-serif;
font-size: .8rem;
}
.dots {
display: flex;
justify-content: center;
gap: .5rem;
max-width: 34rem;
padding: .7rem 0;
}
.dot {
width: .8rem;
height: .8rem;
padding: 0;
border: 0;
border-radius: 50%;
background: #c3c3d2;
transition: background 200ms;
}
.dot--active { background: #2a2a55; }
const fotos = document.getElementsByClassName("slide"); const dots = document.getElementsByClassName("dot"); const caption = document.getElementById("caption"); let indice = 0; function mostrar(n) { for (let i = 0; i < fotos.length; i++) { fotos[i].style.display = (i === n) ? "block" : "none"; } for (let i = 0; i < dots.length; i++) { dots[i].classList.toggle("dot--active", i === n); } caption.textContent = fotos[n].alt; // el pie sale del alt de la imagen } function mover(paso) { indice = indice + paso; if (indice >= fotos.length) indice = 0; if (indice < 0) indice = fotos.length - 1; mostrar(indice); } mostrar(indice); // las dos flechas, con un solo registro: el dato está en el HTML const arrows = document.getElementsByClassName("slideshow__arrow"); for (let i = 0; i < arrows.length; i++) { arrows[i].addEventListener("click", function () { mover(Number(arrows[i].dataset.paso)); }); } // los puntos no mueven: van directamente a una imagen for (let i = 0; i < dots.length; i++) { dots[i].addEventListener("click", function () { indice = Number(dots[i].dataset.ir); mostrar(indice); }); }
Tres cosas nuevas, pequeñas.
classList.toggle("dot--active", i === n) con un segundo argumento no
alterna: pone la clase si vale true y la quita si vale false. Se
ahorra el if.
Number(...) es necesario. Lo que se lee de un atributo es siempre
texto, y sumar texto concatena: 0 + "1" da "01",
no 1. Number lo convierte a número.
La flecha es negra, pero con opacity: .55 deja ver la foto por detrás.
Al pasar el ratón sube a .85 y se vuelve más sólida.
Aquí el índice no lo cambia un clic, sino el reloj del navegador.
setInterval recibe una función y un número de milisegundos, y la ejecuta una y
otra vez con esa separación.
setInterval(function () { ... }, 4000); // cada cuatro segundos
El valor que devuelve es el identificador del temporizador, y hay que guardarlo: es lo
único que permite detenerlo después con clearInterval.
Un slideshow que se mueve solo y no se puede parar incomoda a quien lee despacio y no llega a terminar. De ahí el botón de pausa del ejemplo.
el slideshow automático, con pausa
<div class="slideshow" id="slideshow"> <img class="slide" src="IMG/naturaleza-arroyo.jpg" alt="Arroyo de montaña en un bosque de Trentino"> <img class="slide" src="IMG/naturaleza-abetos.jpg" alt="Arroyo entre abetos bajo un cielo nublado"> <img class="slide" src="IMG/naturaleza-valle.jpg" alt="Valle con niebla al amanecer"> <img class="slide" src="IMG/naturaleza-lago-bn.jpg" alt="Lago entre montañas en blanco y negro"> </div> <div class="row"> <button id="pausa" aria-pressed="false">Pausa</button> <div class="caja" id="estado">En marcha</div> </div>
.caja { background: #dcdcea; border: 2px solid #8686a8; padding: 1rem 1.2rem; font-family: Verdana, sans-serif; font-size: .85rem; } .row { display: flex; gap: 10px; align-items: center; } button { font: inherit; font-size: .85rem; padding: .4rem .9rem; border: 1px solid #8686a8; background: #e8e8f2; cursor: pointer; } .slideshow { max-width: 34rem; margin-bottom: .8rem; } .slideshow img { display: block; width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }
const fotos = document.getElementsByClassName("slide"); const boton = document.getElementById("pausa"); const estado = document.getElementById("estado"); const ESPERA = 3000; // tres segundos entre imagen e imagen let indice = 0; let reloj = null; // null significa detenido function mostrar(n) { for (let i = 0; i < fotos.length; i++) { fotos[i].style.display = (i === n) ? "block" : "none"; } } function siguiente() { indice = indice + 1; if (indice >= fotos.length) indice = 0; mostrar(indice); } function arrancar() { if (reloj !== null) return; // ya estaba en marcha: no se duplica reloj = setInterval(siguiente, ESPERA); estado.textContent = "En marcha"; boton.textContent = "Pausa"; boton.setAttribute("aria-pressed", "false"); } function detener() { clearInterval(reloj); reloj = null; estado.textContent = "Detenido"; boton.textContent = "Continuar"; boton.setAttribute("aria-pressed", "true"); } mostrar(indice); boton.addEventListener("click", function () { if (reloj === null) arrancar(); else detener(); }); arrancar();
La comprobación if (reloj !== null) return no es un adorno. Sin ella, dos
pulsaciones seguidas en Continuar dejarían dos temporizadores corriendo a la vez, el slideshow
iría al doble de velocidad y clearInterval solo pararía uno de los dos. El
identificador del otro se habría perdido y no habría forma de detenerlo.
aria-pressed declara si un botón de dos estados está pulsado, igual que
aria-expanded declaraba si un desplegable está abierto.
Lo habitual es que el slideshow se detenga mientras el ratón está encima, para que se pueda leer el pie, y siga al salir. Son dos eventos y las dos funciones que ya existen:
const slideshow = document.getElementById("slideshow");
slideshow.addEventListener("mouseenter", detener);
slideshow.addEventListener("mouseleave", arrancar);
Aquí se pasa detener, sin paréntesis. Con paréntesis se ejecutaría la
función en ese mismo momento y se registraría su resultado, que no es nada. Sin paréntesis
se entrega la función para que el navegador la invoque cuando el suceso ocurra.
Esto cubre el ratón, no el dedo. En una pantalla táctil no hay mouseenter,
y por eso el botón de pausa sigue siendo necesario.
Lo que se pasa no tiene que ser una imagen suelta, ni tiene que verse de una en una. Un catálogo muestra varias tarjetas a la vez y la tira se desplaza lateralmente: es lo que hace cualquier tienda en su fila de más vendidos.
Eso obliga a cambiar el mecanismo. Ocultar con display: none servía mientras
solo hubiera una a la vista; aquí hay que mover la tira entera. El planteamiento es otro:
overflow: hidden, que recorta.wrap.transform: translateX(...), y la
transition del CSS hace el movimiento.Las tarjetas llevan flex: 0 0 13rem: ni crecen ni encogen, todas con el
mismo ancho. Cuántas se ven no lo decide el código, sino el ancho disponible, y por eso
tampoco hace falta ninguna media query.
El índice ya no es «qué tarjeta se ve», sino cuál es la primera de las que se
ven. Y el tope no es la última tarjeta: es la última menos las que caben, porque
pasado ese punto quedaría hueco a la derecha. Las flechas se apagan con
disabled al llegar a cada extremo.
un catálogo de láminas, con varias tarjetas a la vista
<div class="carousel"> <div class="carousel__head"> <h3>Más vendidas</h3> <div class="carousel__arrows"> <button class="carousel__arrow" data-paso="-1" aria-label="Láminas anteriores">❮</button> <button class="carousel__arrow" data-paso="1" aria-label="Láminas siguientes">❯</button> </div> </div> <div class="carousel__window" id="carousel-window"> <div class="carousel__track" id="carousel-track"> <article class="card"> <p class="card__tag">Oferta</p> <img class="slide" src="IMG/naturaleza-valle.jpg" alt="Lámina del valle con niebla al amanecer"> <h4 class="card__name">Valle con niebla</h4> <p class="card__tech">Papel de algodón, 40 × 30 cm</p> <p class="card__old">30,00 €</p> <p class="card__price card__price--offer">24,00 €</p> <p class="card__note">IGIC incl., envío no incl.</p> </article> <article class="card"> <img class="slide" src="IMG/naturaleza-lago-bn.jpg" alt="Lámina del lago entre montañas en blanco y negro"> <h4 class="card__name">Lago en blanco y negro</h4> <p class="card__tech">Papel barita, 50 × 70 cm</p> <p class="card__price">28,50 €</p> <p class="card__note">IGIC incl., envío no incl.</p> </article> <article class="card"> <p class="card__tag">Oferta</p> <img class="slide" src="IMG/ciudad-amanecer.jpg" alt="Lámina de los rascacielos al amanecer"> <h4 class="card__name">Ciudad al amanecer</h4> <p class="card__tech">Giclée, 30 × 30 cm</p> <p class="card__old">25,00 €</p> <p class="card__price card__price--offer">19,00 €</p> <p class="card__note">IGIC incl., envío no incl.</p> </article> <article class="card"> <img class="slide" src="IMG/naturaleza-arroyo.jpg" alt="Lámina del arroyo de montaña"> <h4 class="card__name">Arroyo de montaña</h4> <p class="card__tech">Papel de algodón, 40 × 30 cm</p> <p class="card__price">22,00 €</p> <p class="card__note">IGIC incl., envío no incl.</p> </article> <article class="card"> <p class="card__tag">Oferta</p> <img class="slide" src="IMG/bn-caballo.jpg" alt="Lámina del caballo en blanco y negro"> <h4 class="card__name">Caballo</h4> <p class="card__tech">Papel barita, 50 × 70 cm</p> <p class="card__old">26,00 €</p> <p class="card__price card__price--offer">21,00 €</p> <p class="card__note">IGIC incl., envío no incl.</p> </article> <article class="card"> <img class="slide" src="IMG/bn-cupula.jpg" alt="Lámina de la cúpula del Palacio de Bellas Artes"> <h4 class="card__name">Cúpula</h4> <p class="card__tech">Giclée, 30 × 30 cm</p> <p class="card__price">23,50 €</p> <p class="card__note">IGIC incl., envío no incl.</p> </article> </div> </div> </div>
button {
font: inherit;
font-size: .85rem;
padding: .4rem .9rem;
border: 1px solid #8686a8;
background: #e8e8f2;
cursor: pointer;
}
.carousel__head {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
justify-content: space-between;
font-family: Verdana, sans-serif;
}
.carousel__head h3 { margin: 0; font-size: 1.1rem; }
.carousel__arrows { display: flex; gap: .4rem; }
.carousel__arrow {
padding: .4rem .9rem;
border: 0;
font-size: 1.1rem;
line-height: 1;
color: #fff;
background: #1a1a1a;
}
.carousel__arrow:disabled {
color: #8a8a8a;
background: #e2e2e2;
cursor: default;
}
/* la ventana recorta; la tira es más ancha que ella */
.carousel__window {
overflow: hidden;
padding: .8rem 0;
}
.carousel__track {
display: flex;
gap: 1rem;
transition: transform 400ms ease-out;
}
.card {
flex: 0 0 13rem; /* ni crece ni encoge: todas igual de anchas */
box-sizing: border-box;
padding: .8rem;
border: 1px solid #d8d8e0;
font-family: Verdana, sans-serif;
font-size: .8rem;
}
.card img {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
margin-bottom: .6rem;
}
.card__tag {
display: inline-block;
margin: 0 0 .6rem;
padding: .15rem .6rem;
border: 1px solid #c2341f;
border-radius: 1rem;
color: #c2341f;
font-size: .72rem;
}
.card__name { margin: 0 0 .2rem; font-size: .9rem; }
.card__tech { margin: 0 0 .6rem; color: #666; }
.card__old {
margin: 0;
color: #888;
text-decoration: line-through;
}
.card__price {
margin: 0 0 .3rem;
font-size: 1.3rem;
font-weight: bold;
}
.card__price--offer { color: #c2341f; }
.card__note {
margin: 0;
font-size: .72rem;
color: #666;
text-decoration: underline;
}
const track = document.getElementById("carousel-track"); const window_ = document.getElementById("carousel-window"); const cards = document.getElementsByClassName("card"); const arrows = document.getElementsByClassName("carousel__arrow"); let indice = 0; // la primera tarjeta de las que se ven // lo que ocupa una tarjeta: su ancho más el hueco que la separa de la siguiente function paso() { const ancho = cards[0].getBoundingClientRect().width; const hueco = parseFloat(getComputedStyle(track).gap); return ancho + hueco; } // cuántas caben a la vez: depende del ancho disponible, no de un número fijo function caben() { return Math.max(1, Math.round(window_.clientWidth / paso())); } function mostrar() { const ultimo = Math.max(0, cards.length - caben()); if (indice > ultimo) indice = ultimo; // no se pasa del final if (indice < 0) indice = 0; // ni del principio track.style.transform = "translateX(" + (-indice * paso()) + "px)"; // las flechas se apagan en los extremos arrows[0].disabled = (indice === 0); arrows[1].disabled = (indice === ultimo); } mostrar(); for (let i = 0; i < arrows.length; i++) { arrows[i].addEventListener("click", function () { indice = indice + Number(arrows[i].dataset.paso); mostrar(); }); } // al cambiar el ancho de la ventana cambia cuántas caben: hay que recolocar addEventListener("resize", mostrar);
Lo nuevo aquí es que JavaScript mide. Hasta ahora no había
consultado ninguna dimensión: cambiaba clases y textos. getBoundingClientRect
devuelve el tamaño real de un elemento en pantalla, ya resuelto el rem, el
% y lo que haga falta.
Esos píxeles del translateX son los únicos de todo el curso, y no están
escritos: son el resultado de medir lo que el CSS ha decidido. Si mañana la tarjeta pasa a
15rem, el JavaScript no se toca.
caben() se vuelve a calcular en cada llamada, y de ahí el listener de
resize: al estrechar la ventana caben menos tarjetas, el tope baja, y una
posición que antes era válida deja de serlo.
La variable se llama window_ con un guion bajo porque
window ya existe: es el objeto que representa la ventana del navegador, el
mismo del que cuelgan addEventListener y setInterval. Dos nombres
iguales en el mismo ámbito y el segundo tapa al primero.
if: pasada la última se vuelve a la
0; antes de la primera, a length - 1.setInterval devuelve un identificador. Si no se guarda, el
temporizador no se puede detener.data- es texto: Number para
operar con él.display: none; con varias se
mueve la tira entera con transform dentro de una ventana que recorta.getBoundingClientRect; no la lleva escrita.Las fotos son de Pexels, con licencia libre: se pueden usar y modificar sin pagar y sin citar la fuente. Citarla no es obligatorio, pero es lo correcto, y aquí sirve además de ejemplo de cómo se hace.
naturaleza-lago-bn.jpg — Lago entre montañas en blanco y negro. Foto de Aysegul Aytoren, ficha en Pexels.naturaleza-arroyo.jpg — Arroyo de montaña en un bosque de Trentino. Foto de Pluto91 Operatic, ficha en Pexels.naturaleza-abetos.jpg — Arroyo entre abetos bajo un cielo nublado. Foto de Snapwire, ficha en Pexels.naturaleza-valle.jpg — Valle con niebla al amanecer. Foto de Johannes Plenio, ficha en Pexels.bn-cupula.jpg — Cúpula del Palacio de Bellas Artes en blanco y negro. Foto de ἐμμανυελ, ficha en Pexels.bn-caballo.jpg — Caballo en blanco y negro. Foto de Ruhul Amin Akash, ficha en Pexels.ciudad-amanecer.jpg — Rascacielos de Ciudad de México al amanecer. Foto de Yuriko Salazar Luna, ficha en Pexels.