Slides usando Blend Mode y DocumentFragment

Animación de Slides con transición entre imágenes y propiedad Blend Mode de CSS para manipular la apariencia de las imágenes
Slides usando Blend Mode y Document Fragment

He estado trasteando con una propiedad CSS llamada mix-blend-mode y su similar background-blend-mode. Despues de jugar con colores sobre imágenes y fondos he decido crear un slide que contenga algunas de las pruebas.

El Slide consiste en imágenes superpuestas como background una encima de otra. Sólo una será vsibible. La transición se realiza cambiando su opacidad de manera que durante un tiempo determinado ambas imágenes serán visibles e invisibles gradualmente.

Las dos propiedades, mix-blend-mode y background-blend-mode, necesitan que al mismo nivel o por debajo exista definido algún color de fondo para que tenga efecto de "fusión".

También quería usar DocumentFragment para añadir algo más a la animación. Consiste en crear grid de rectángulos sobre toda la capa que contiene las imágenes que durante la transición se modifica la opacidad y su tamaño.

En el siguiente Pen se puede ver el ejemplo:

Código Html del Slide

Existen tres capas:

  • <div class="viewport">
  • <div class="thumbs">
  • <div class="options">

La primera capa contiene HTML para pintar las flechas de navegación. Mediante JS se añadirán las imágenes de fondo. Se ha creado una capa <div class="js-container"> para añadir los rectángulos con DocumentFragment

La segunda capa será para añadir thumbs de imágenes con sus identificadores mediante data-atributos. Estas imágenes serán usadas para pintar las imágenes del Slide mediane JS

La tercera capa es sólo para poder cambiar, desde el front, el background-color del viewport y el mix-blend-mode de las imágenes

<div class="viewport">
  <div class="js-container"></div>
  <div class="nav">
    <button class="nav__control" type="button">
      <svg xmlns="http://www.w3.org/2000/svg" class="js-navigation js-prev" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><path d="M11 17l-5-5 5-5M18 17l-5-5 5-5" /></svg>
    </button>
    <button class="nav__control" type="button">
      <svg xmlns="http://www.w3.org/2000/svg" class="js-navigation js-next" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 17l5-5-5-5M6 17l5-5-5-5"/></svg>
    </button>
  </div>
</div>
<div class="thumbs">
  <img data-slide="slide-0" class="thumb js-thumb thumb--active" src="img/beauty.jpg" alt="">
  <img data-slide="slide-1" class="thumb js-thumb" src="img/girl.jpg" alt="">
  <img data-slide="slide-2" class="thumb js-thumb" src="img/model.jpg" alt="">
  <img data-slide="slide-3" class="thumb js-thumb" src="img/snow.jpg" alt="">
  <img data-slide="slide-4" class="thumb js-thumb" src="img/woman.jpg" alt="">
</div>
<div class="options">
  <select name="blend-mode" id="blend-mode">
    <option value="unset">unset</option>
    <option value="normal">normal</option>
    <option value="multiply">multiply</option>
    <option value="screen">screen</option>
    [...]
  </select>
  <input name="bg-mode" id="bg-mode" type="color">
</div>

Código JS del Slide

Las funciones usadas son las siguientes:

  • Insertar Frames de imágenes: const createFrameSlides = thumbs => {}
  • Función de utilidad para crear nodos en el DOM: const elFactory = (type, attributes, ...children) => {}
  • Función de utilidad para insertar nodos en el DOM: const insertBefore = (el, referenceNode) => {};
  • Crear rectángulos Fragment: const createFragment = (row, col) => {}
  • Tiempo para la animación: const setDelay = timeout => {}
  • Para habilitar/deshabilitar botones de navegación: const statusNavigation = el => {}
  • Registrar evento "click" en el Thumb: const handlerThumbs = (timeout) => {}
  • Registrar evento "click" en el Botones de Navegación: const handlerNavigation = (nav, timeout) => {}
  • Para aplicar transición, llamada tanto para Botones de navegación como Thumb: const transitionTo = (to, timeout) => {}
  • Para que el usuario pueda cambiar tanto colores de fondo como Blend Mode: const handlerCustomBackground = () => {}

No explicaré todas las funciones una por una, ya que algunas son de utilidad y serán llamadas desde diferentes funciones

Creación de imágenes de fondo partiendo de Thumbs

La función const createFrameSlides = thumbs => {} recibe un parámetro que será un array de todas las imágenes añadidas en la galería de Thumbs y devolverá un objeto DocumentFragment con cada una de las imágenes como background y atributos ID, CLASS y STYLE. Para este propósito se ha creado una función const elFactory = (type, attributes, ...children) => {} para realizar la creación de nodos

const createFrameSlides = thumbs => {
  const fragment = new DocumentFragment();
  thumbs.forEach((thumb, index) => {
    const el = elFactory(
      'div',
      {
        id: `slide-${index}`,
        class: `slide${(index == 0 ? " slide--active" : "")}`,
        style: `background-image: url(${thumb.getAttribute('src')})`
      }
    )
    fragment.appendChild(el);
  })

  return fragment;
}

Con la función const insertBefore = (el, referenceNode), partiendo de la devolución de la función anterior, ahora será necesario añadirlo al DOM html. El sitio elegido ha sido como primeros hijos de la capa <div class="viewport">. Se pasan dos parámetros: el elemento (fragment) a insertar y el nodo de referencia

const insertBefore = (el, referenceNode) => referenceNode.parentNode.insertBefore(el, referenceNode);

Esta función será usada con la anterior una vez el DOM esté cargado. "container" es const container = document.querySelector('.js-container')

insertBefore(createFrameSlides(thumbs), container);

Creación de rectángulos para la animación

La función const createFragment = (row, col) => {} genera etiquetas span y se añaden a un DocumentFragment que luego será añadido al DOM.

Se quería que los rectángulos fueran apareciendo de fuera hacía el centro, para eso se han añadido clases que cumplan esa "numeración" para después poder aplicar estilos CSS en función de su posición

const createFragment = (row, col) => {
  let fragment = new DocumentFragment();

  let step;
  let items = -1;
  for (let r = 0; r < row; r++) {
    for (let c = 0; c < col; c++) {
      if ((row / 2) > r) {
        if ((col / 2) > c) step = r + c;
        else step--;
        if (items < step) items = step;
      } else {
        if ((col / 2) > c) step = row - r - 1 + c;
        else step--;
      }

      fragment.appendChild(elFactory('span', { class: `item item--${step}`}));
    }
  }
  document.documentElement.style.setProperty('--items', items);

  return fragment;
}

Tiempo de animación y delay de los rectángulos

La función const setDelay = timeout => {} es usada para insertar estilos a los keyframes que animan los rectángulos. Recibe un parámetro tiempo que es leído de una variable CSS (que es usada en el resto de animaciones). Dentro de la función se obtiene otra variable CSS para poder conocer cuantos "tipos" de rectángulos existen

const setDelay = timeout => {
  let style = document.createElement('style');
  document.head.appendChild(style);

  const items = getComputedStyle(document.documentElement).getPropertyValue('--items');

  for (let index = 0; index <= items; index++) {
    const item = `.item--${index} {
      animation-delay: ${index*100}ms;
      animation-duration: ${timeout-index*100}ms;
    }`;
    style.sheet.insertRule(item);
  }
}

Habilitar/Deshabilitar botones de navegación

Para poder anular la navegación Next o Prev se ha creado la función const statusNavigation = el => {} que es llamada dentro de la función const transitionTo = (to, timeout) => {}.

Cada cambio de Slide se comprueba si se ha llegado al Final o al Principio de los Slides y en tal caso se anula si procede

const statusNavigation = el => {
  if (!el.nextElementSibling) document.querySelector('.js-next').style.display = 'none';
  else document.querySelector('.js-next').style.display = 'flex';

  if (!el.previousElementSibling) document.querySelector('.js-prev').style.display = 'none';
  else document.querySelector('.js-prev').style.display = 'flex';
}

Evento click en Thumb

Con la función const handlerThumbs = (timeout) => {} se manejan eventos click sobre las miniaturas del Slide. Esta función, al igual que la siguiente función, realiza la llamada a la función transitionTo(event.target, timeout) que es la encargada de realizar la animación

const handlerThumbs = (timeout) => transitionTo(event.target, timeout);

Evento click en Navigation

Con la función const handlerNavigation = (nav, timeout) => {} se manejan eventos click sobre botones Prev/Next del Slide. Igual que antes, se llama a la función transitionTo(event.target, timeout) encargada de la animación

const handlerNavigation = (nav, timeout) => {
  let active = null;
  if (nav.classList.contains('js-next')) {
    active = document.querySelector('.thumb--active').nextElementSibling;
  } else {
    active = document.querySelector('.thumb--active').previousElementSibling;
  }

  transitionTo(active, timeout)
}

Cambiar imagen de los Slides

Una de las funciones principales para el Slide es const transitionTo = (to, timeout) => {}. Es la que realiza el cambio de imágenes, tanto al cambiar imágenes con "click" en Thumb como en Botones de Navegación.

La función recibe dos parámetros.

El primer parámetro "to" es el slide que se debe mostrar. Para el caso de pulsación sobre algún Thumbs se pasará el target y para el caso de pulsación sobre flechas de navegación se pasará el nodo siguiente o previo que esté marcado como .thumb--active

Dentro se añaden y quitan clases para realizar la transición

const transitionTo = (to, timeout) => {
  document.body.classList.add('js-animating');
  document.querySelector('.thumb--active').classList.remove('thumb--active');

  const particles = document.querySelectorAll('.js-container .item');
  particles.forEach(item => item.classList.add('particles'))

  to.classList.add('thumb--active');
  statusNavigation(to);

  const current = document.querySelector('.slide--active');
  current.classList.add('fade-out');

  const slide = document.querySelector('#'+to.getAttribute('data-slide'));
  slide.classList.add('fade-in');

  setTimeout(() => {
    document.body.classList.remove('js-animating');
    current.classList.remove(...['fade-out', 'slide--active']);
    slide.classList.remove('fade-in');
    slide.classList.add('slide--active');
    particles.forEach(item => item.classList.remove('particles'));
  }, timeout);
}

Personalización de colores de fondo

Se ha habilitado que se pueda cambiar desde el front:

  • Color de fondo durante la transición
  • Blend Mode de las imágenes del Slide

Con la función const handlerCustomBackground = () => {} se manejan eventos de cambio sobre <select name="blend-mode" id="blend-mode"> y sobre <input name="bg-mode" id="bg-mode" type="color">

const handlerCustomBackground = () => {
  if (event.target.id === 'blend-mode') {
    document.querySelectorAll('.viewport .slide').forEach(slide => {
      slide.style.mixBlendMode = `${event.target.value}`;
    });
  } else if (event.target.id === 'bg-mode') {
    document.querySelector('.viewport').style.backgroundColor = `${event.target.value}`;
  } else {
    return;
  }
}

Registro de eventos con DOM cargado

Con todas las funciones necesarias, ahora necesitamos usarlas una vez el DOM está cargado

document.addEventListener('DOMContentLoaded', () => {

  const viewport = document.querySelector('.viewport');

  const container = document.querySelector('.js-container');
  const thumbs = document.querySelectorAll('.thumbs .thumb');
  const navs = document.querySelectorAll('.js-navigation');

  const row = getComputedStyle(document.documentElement).getPropertyValue('--row');
  const col = getComputedStyle(document.documentElement).getPropertyValue('--col');
  const timeout = getComputedStyle(document.documentElement).getPropertyValue('--timeout');

  if (container && viewport && thumbs) {
    insertBefore(createFrameSlides(thumbs), container);
    container.appendChild(createFragment(row, col));
    setDelay(timeout);
    thumbs.forEach(anime => {
      anime.addEventListener('click', handlerThumbs.bind(this, timeout), false);
    });
    document.addEventListener('input', handlerCustomBackground, false);
    navs.forEach(nav => {
      nav.addEventListener('click', handlerNavigation.bind(this, nav, timeout), false);
    });
  }

});

Hacemos querySelector y querySelectorAll del viewport, js-container, thumb y js-navigation.

También obtenemos tres variables CSS:

  • --row: cantidad de filas de rectángulos
  • --col: cantidad de columnas de rectángulos
  • --timeout: tiempo en las transiciones

Con esto podemos definir desde CSS este tipo de cambios en la animación.

El código dentro del if es para, primero crear los rectángulos y añadirlos al DOM, segundo añadir timeout y registrar todos los eventos del Slide.

Código CSS del Slide

Se han creado variables CSS para ser obtenidas desde JS y poder personalizarlo desde CSS

$base-color: rgba(84, 17, 17, .2);
$row: 7;
$col: 11;
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: sans-serif;
}
html, body {height: 100%;}
button {
  background-color: transparent;
  border: 0;
  outline: none;
  cursor: pointer;
}
:root {
  --col: #{$col};
  --row: #{$row};
  --items: 0;
  --timeout: 2000;
}
.js-container {
  display: grid;
  grid-template-columns: repeat(var(--col), minmax(calc(100% / var(--col)), 1fr));
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  right: 0;

  .item {
    z-index: 4;
    transform: scale(0);
    opacity: 0;
  }
}
.viewport {
  position: relative;
  width: 100%;
  height: 550px;
  max-height: 100vh;
  .slide {
    width: inherit;
    height: inherit;
    max-height: inherit;
    position: absolute;
    opacity: 0;
    background-repeat: no-repeat;
    background-size: cover;
    background-position: center;
    &--active {
      opacity: 1;
    }
  }
}

.nav {
  position: absolute;
  width: 100%;
  height: 100%;
  z-index: 5;
  display: flex;
  justify-content: space-between;
  align-items: center;
  &__control {
    display: flex;
    opacity: 0;
    transition: opacity .3s ease;
  }
  &:hover {
    .nav__control {
      background-color: #242424;
      opacity: 1;
      transition: opacity .9s ease, background-color 2s ease;
    }
  }
}

.thumbs {
  display: flex;
  justify-content: center;
  margin: 10px 5px;
  .thumb {
    margin: 0 5px;
  }
}

.options {
  display: flex;
  justify-content: center;
  margin: 15px 15px 20px;
  > * {
    height: 28px;
    border: 1px solid #6f6f6f;
    margin: 1px 5px;
  }
}

.js-animating {
  .thumbs {
    cursor: wait;
  }
  .js-thumb {
    pointer-events: none;
  }
  [class^="js-"] {
    pointer-events: none;
  }
  .item {
    animation-name: particles;
    animation-timing-function: ease-in-out;
  }
}

.thumb {
  cursor: pointer;
  width: 100px;
  max-width: calc(20% - 10px);
  box-shadow: 0 0 7px #000;
  &:hover {
    box-shadow: 0 0 2px #000;
  }
  &--active {
    pointer-events: none;
    box-shadow: 0 0 2px #000;
  }
}

.fade-in {
  animation: fadeIn calc(var(--timeout) * 1ms) ease-in-out 0s forwards;
}
.fade-out {
  animation: fadeOut calc(var(--timeout) * 1ms) ease-in-out 0s forwards;
}


@keyframes fadeIn {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}
@keyframes fadeOut {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}
@keyframes particles {
  0% {
    transform: scale(0);
    opacity: 0;
  }
  30% {
    opacity: 1;
    background-color: $base-color;
    transform: scale(.95);
  }
  70% {
    transform: scale(.7);
    opacity: .4;
  }
  100% {
    transform: scale(0);
    opacity: 0;
  }
}

Código en mi GitHub