Recreando la animación del corazón de Twitter (con un elemento, sin imágenes y sin JavaScript) | Programar Plus

Recientemente vi una recreación de la animación del corazón de Twitter entre las selecciones de CodePen. Si tengo un poco de tiempo, siempre reviso el código de las demostraciones que me llaman la atención para ver si hay algo que pueda usar o mejorar. En este caso, me sorprendió ver que la demostración usaba un sprite de imagen. Yo despues aprendí que así es como lo hace Twitter. Seguro que se podría hacer sin imágenes, ¿no?

Decidí que le daría una oportunidad. También decidí que lo haría sin JavaScript porque este es un candidato perfecto para el truco de la casilla de verificación, que le permite hacer cambios simples de encendido/apagado a través de elementos de formulario y CSS inteligente.

El resultado:

Grabación de la animación resultante.

¡Ahora veamos cómo lo hice!

Mirando el sprite original

Sprite original del corazón de Twitter. Ver en nueva pestaña.

Tiene 29 marcos, un número con el que no tengo ningún problema, hasta que se trata de cálculos. Ahí es cuando me empieza a parecer feo porque es un gran número primo, no puedo dividirlo por pequeños números bonitos como 2, 4 o 5 y obtener un número entero. Oh, bueno… para eso sirven las aproximaciones. 29 está bastante cerca de ambos 28, que es múltiplo de 4 como 4 * 7 = 28, y 30, que es múltiplo de 5 (5 * 6 = 30). Así que podríamos tomar esto 29 ser cualquiera 28 o 30, lo que más nos convenga.

Lo siguiente a notar sobre el sprite es que tiene tres componentes:

  • el corazón
  • la burbuja detrás del corazón
  • las partículas alrededor del corazón

Esto quiere decir que se puede hacer con un solo elemento y sus dos pseudos. El corazón es el elemento mismo, la burbuja es el ::before pseudo-elemento y las partículas son el ::after pseudo-elemento.

Usando el truco de la casilla de verificación

Todo el corazón y sus demás partes serán el <label> de la casilla de verificación. Al hacer clic en la etiqueta, se alternará la casilla de verificación y nos permitirá manejar los dos estados. En esta situación, nuestro HTML se ve así, una casilla de verificación y una etiqueta que contiene un corazón Unicode:

<input id="toggle-heart" type="checkbox" />
<label for="toggle-heart">❤</label>

Quitemos la casilla de verificación:

[id='toggle-heart'] {
  position: absolute;
  left: -100vw;
}

Luego establecemos un color valor para el corazón dependiendo de si nuestra casilla de verificación está marcada o no. Usamos un selector de color para sacar los valores reales del sprite.

[for="toggle-heart"] {
  color: #aab8c2;
}

[id='toggle-heart']:checked + label {
  color: #e2264d;
}

Centrar y agrandar

también establecemos cursor: pointer en la etiqueta y aumentar la font-size porque parece demasiado pequeño de lo contrario.

[for="toggle-heart"] {
  font-size: 2em;
  cursor: pointer;
}

Luego lo posicionamos en el medio de la pantalla para poder verlo mejor. ¡Gracias flex box!

body {
  display: flex;
  justify-content: center; /* horizontal alignment */
  margin: 0;
  height: 100vh; /* the viewport height */
}

/* vertical alignment, needs the height of 
   the body to be equal to that of the 
   viewport if we want it in the middle */
[for="toggle-heart"] { 
  align-self: center; 
}

Ahora tenemos un corazón que es gris cuando la casilla de verificación no está marcada y carmesí cuando lo está:

Animando el crecimiento del tamaño del corazón.

Mirando el sprite, vemos que el corazón está escalado a 0 del marco 2 a través del marco 6. después del marco 6, comienza a crecer y luego a partir de cierto punto disminuye un poco. Este tipo de cultivo es el caso de uso perfecto para el easeOutBack función de temporización. Tomamos el comienzo de crecer para ser 17.5% porque ese es un buen número que parece una buena aproximación dada nuestra cantidad total de fotogramas. Ahora tenemos que decidir cómo hacer esta escala. No podemos usar un scale() transform porque eso también afectaría a los descendientes o pseudos de nuestro elemento y no queremos que se escalen a 0 cuando nuestro corazón está. Entonces usamos font-size.

@keyframes heart { 0%, 17.5% { font-size: 0; } }

[id='toggle-heart']:checked + label {
  will-change: font-size;
  animation: heart 1s cubic-bezier(.17, .89, .32, 1.49);
}

El resultado del código anterior se puede ver en el siguiente Pen:

Si no incluimos el 0% o 100% fotogramas clave, se generan automáticamente utilizando los valores que hemos establecido para ese elemento (en nuestro caso font-size: 2em), o, si no lo hemos hecho, a partir de los valores por defecto (que serían 1em en el caso de la font-size).

La burbuja

Ahora pasemos a los pseudo elementos que crean la burbuja (y también a las partículas, que veremos a continuación). Establecimos position: relative en nuestra etiqueta de corazón para que podamos posicionarlos absolutamente. Los queremos debajo del corazón, así que usamos z-index: -1 para hacer esto. Los queremos en el medio, así que en 50% desde el top y left está. Tanto la burbuja como las partículas son redondas, así que les damos border-radius: 50%. Vamos a comenzar a usar la sintaxis SCSS aquí, ya que vamos a terminar usándola ya que necesitamos hacer algunos cálculos de todos modos.

[for="toggle-heart"] {
  position: relative;

  &:before, &:after {
    position: absolute;
    z-index: -1;
    top: 50%; left: 50%;
    border-radius: 50%;
    content: '';
  }
}

Mirando el sprite, vemos que, en su punto más grande, la burbuja es un poco más del doble del corazón, por lo que tomamos su diámetro como 4.5rem. Usamos rem unidades, no em porque el font-size del elemento se está animando para cambiar el tamaño del corazón. Dimensionamos y posicionamos nuestros ::before pseudo en el medio. También le damos un fondo de prueba solo para ver que está allí y se ve bien (lo eliminamos más tarde):

$bubble-d: 4.5rem; // bubble diameter
$bubble-r: .5 * $bubble-d; // bubble-radius

[for="toggle-heart"]::before {
  margin: -$bubble-r;
  width: $bubble-d; height: $bubble-d;
  background: gold;
}

Hasta ahora tan bueno:

Desde el marco 2 a través del marco 5, la burbuja crece de la nada a su tamaño máximo y pasa de un carmesí a un violeta. Luego, a través del marco 9, crece un agujero en el medio hasta que este agujero es tan grande como la burbuja misma. La parte creciente parece un trabajo que animar un scale() transformar puede hacer. El agujero creciente que podemos obtener al animar el border-width desde $bubble-r (el radio de la burbuja) a 0. Tenga en cuenta que también tenemos que configurar box-sizing: border-box en la burbuja (la ::before pseudo) para que esto funcione.

[for="toggle-heart"]:before {
  box-sizing: border-box;
  border: solid $bubble-r #e2264d;
  transform: scale(0);
}

@keyframes bubble {
  15% {
    border-color: #cc8ef5;
    border-width: $bubble-r;
    transform: scale(1);
  }
  30%, 100% {
    border-color: #cc8ef5;
    border-width: 0;
    transform: scale(1);
  }
}

Podemos compactar los fotogramas clave con un mixin:

@mixin bubble($ext) {
  border-color: #cc8ef5;
  border-width: $ext;
  transform: scale(1);
}

@keyframes bubble {
  15% { @include bubble($bubble-r); }
  30%, 100% { @include bubble(0); }
}

También hacemos que los pseudos hereden la animación del corazón, cambiamos ambos a un easeOutCubic tipo de función de temporización y cambiar el animation-name para cada uno individualmente:

[id='toggle-heart']:checked + label {
  &::before, &::after {
    animation: inherit;
    animation-timing-function: cubic-bezier(.21, .61, .35, 1);
  }

  &::before {
    will-change: transform, border-color, border-width;
    animation-name: bubble;
  }

  &::after { animation-name: particles; }
}

Podemos comprobar lo que produce el código anterior en el siguiente Pen:

las partículas

Mirando el sprite, podemos ver que tenemos siete grupos de dos partículas redondas cada uno y que estos grupos están distribuidos en un círculo.

Primer plano de tres cuadros consecutivos en el sprite, que muestra las partículas en grupos alrededor del corazón.

Lo que cambia en ellos es su opacity, su posición (porque el radio del círculo en el que se encuentran los grupos aumenta) y su tamaño. Creamos las partículas con múltiples sombras de caja (una para cada partícula) y luego animamos el opacity del pseudo y los desplazamientos y la propagación de estas sombras de cuadro.

Lo primero que hacemos es decidir las dimensiones de una partícula, luego el tamaño y la posición de nuestra ::after pseudo-elemento.

$particle-d: 0.375rem;
$particle-r: 0.5 * $particle-d;

[for="toggle-heart"]:after {
  margin: -$particle-r;
  width: $particle-d; height: $particle-d;
}

Distribuimos los siete grupos de partículas en un círculo. Tenemos 360° en un círculo, como se ilustra en la siguiente demostración:

Dividimos estos 360° en tantas partes como grupos tengamos. Cada vértice de un polígono en la demostración a continuación marcaría la posición de un grupo.

Vamos en el sentido de las agujas del reloj y partimos desde el + de El x eje (3 en punto). Si queremos empezar desde el - de El y eje (12 en punto), entonces tenemos que restar 90° desde el ángulo correspondiente a la posición de cada grupo.

Ahora veamos cómo codificamos una distribución de grupos en un círculo cuyo radio inicialmente tomamos tan grande como el radio de la burbuja ($bubble-r), comenzando desde arriba (12 en punto). Si consideramos que vamos a tener solo una partícula en el medio de cada grupo, entonces nuestro código debería ser:

$shadow-list: (); // init shadow list
$n-groups: 7; // number of groups
$group-base-angle: 360deg/$n-groups;
$group-distr-r: $bubble-r; // circular distribution radius for groups

@for $i from 0 to $n-groups {
  // current group angle, starting fron 12 o'clock
  $group-curr-angle: $i*$group-base-angle - 90deg;
  // coords of the central point of current group of particles
  $xg: $group-distr-r*cos($group-curr-angle);
  $yg: $group-distr-r*sin($group-curr-angle);

  // add to shadow list
  $shadow-list: $shadow-list, $xg $yg;
}

Ajuste box-shadow: $shadow-list en nuestro ::after pseudo nos da el siguiente resultado:

Ahora tomemos el caso donde tenemos dos partículas en cada grupo.

Posicionamos las partículas en un grupo en un círculo (con un radio de, digamos, igual al diámetro de nuestro ::after seudo – $particle-d) alrededor del punto central de ese grupo.

Lo siguiente en lo que tenemos que pensar es en el ángulo de inicio. En el caso de los propios grupos, el ángulo de salida fue -90° porque queríamos empezar desde arriba. Para las partículas individuales, el ángulo inicial es el ángulo correspondiente al grupo (el que usamos para calcular sus coordenadas) más un ángulo de desplazamiento que es el mismo para todas las partículas alrededor del corazón. Tomamos este ángulo como 60° porque eso parece verse bien.

El código para calcular las posiciones de todas las partículas y agregar un box-shadow en cada una de esas posiciones es a continuación:

$shadow-list: ();
$n-groups: 7;
$group-base-angle: 360deg/$n-groups;
$group-distr-r: $bubble-r;
$n-particles: 2;
$particle-base-angle: 360deg/$n-particles;
$particle-off-angle: 60deg; // offset angle from radius

@for $i from 0 to $n-groups {
  $group-curr-angle: $i*$group-base-angle - 90deg;
  $xg: $group-distr-r*cos($group-curr-angle);
  $yg: $group-distr-r*sin($group-curr-angle);

  @for $j from 0 to $n-particles {
    $particle-curr-angle: $group-curr-angle + 
      $particle-off-angle + $j*$particle-base-angle;
    // coordinates of curent particle
    $xs: $xg + $particle-d*cos($particle-curr-angle);
    $ys: $yg + $particle-d*sin($particle-curr-angle);

    // add to shadow list
    $shadow-list: $shadow-list, $xs $ys;
  }
}

Ahora esto da como resultado lo que se puede ver en el siguiente Pen:

Partículas del arco iris

Las posiciones se ven bastante bien, pero todas estas sombras usan el color valor que hemos fijado para el corazón. Podemos hacerlos arcoiris dando a cada partícula un hsl() valor dependiendo del índice del grupo en el que se encuentre ($i) y en su índice dentro de ese grupo ($j). Así que cambiamos la adición a la parte de la lista oculta:

$shadow-list: $shadow-list, $xs $ys 
  hsl(($i + $j) * $group-base-angle, 100%, 75%);

Este simple cambio nos da partículas de arcoíris:

Incluso podríamos introducir cierto grado de aleatoriedad al elegir el tono, pero me sentí bastante satisfecho con este resultado.

Al animar las partículas, queremos que vayan desde la posición en la que las tenemos ahora, lo que significa grupos en el círculo del radio. $bubble-r, un poco hacia afuera, digamos, hasta que los grupos estén en un círculo de radio 1.25 * $bubble-r. Esto significa que tenemos que cambiar el $group-distr-r variable.

Al mismo tiempo, queremos que se reduzcan de su tamaño completo actual a cero. Reducir las sombras de los cuadros sin desenfoque a cero significa darles un radio de dispersión negativo cuyo valor absoluto es igual a al menos la mitad de la dimensión más pequeña del elemento o pseudo en el que están configuradas. Ambas dimensiones de nuestro :after pseudo son iguales a $particle-d (el diámetro de la partícula), por lo que nuestro radio de dispersión debe ser -$particle-r (el radio de la partícula).

En resumen, en estado 0, tenemos un círculo de distribución de grupo de radio $bubble-r y un radio de dispersión de 0, mientras esté en estado 1, tenemos un círculo de distribución de grupo de radio 1.25 * $bubble-r y un radio de dispersión de -$particle-r.

Si usamos una variable $k para el estado, entonces tenemos:

$group-distr-r: (1 + $k * 0.25) * $bubble-r;
$spread-r: -$k * $particle-r;

Esto nos lleva a crear un mixin, por lo que no escribimos esos @for bucles dos veces:

@mixin particles($k) {
  $shadow-list: ();
  $n-groups: 7;
  $group-base-angle: 360deg / $n-groups;
  $group-distr-r: (1 + $k * 0.25)*$bubble-r;
  $n-particles: 2;
  $particle-base-angle: 360deg / $n-particles;
  $particle-off-angle: 60deg; // offset angle from radius
  $spread-r: -$k * $particle-r;

  @for $i from 0 to $n-groups {
    $group-curr-angle: $i * $group-base-angle - 90deg;
    $xg: $group-distr-r * cos($group-curr-angle);
    $yg: $group-distr-r * sin($group-curr-angle);

    @for $j from 0 to $n-particles {
      $particle-curr-angle: $group-curr-angle + 
        $particle-off-angle + $j * $particle-base-angle;
      $xs: $xg + $particle-d * cos($particle-curr-angle);
      $ys: $yg + $particle-d * sin($particle-curr-angle);

      $shadow-list: $shadow-list, $xs $ys 0 $spread-r 
        hsl(($i + $j) * $group-base-angle, 100%, 75%);
    }
  }

  box-shadow: $shadow-list;
}

Ahora echemos un vistazo al sprite un poco más por un momento. Las partículas no aparecen hasta el marco. 7. 7 es un cuarto (o 25%) de 28, que está bastante cerca de nuestro número real de fotogramas (29). Esto significa que nuestra animación básica de las partículas se vería así:

@keyframes particles {
  0%, 20% { opacity: 0; }
  25% {
    opacity: 1;
    @include particles(0);
  }
}

[for="toggle-heart"]:after { @include particles(1); }

Esto se puede ver en acción en el siguiente Pen:

Ajustes

Se ve bien en todos los navegadores excepto en Edge/IE, donde las partículas en realidad no se reducen a nada, se quedan allí, muy pequeñas, apenas visibles, pero aún visibles. Una solución rápida para esto sería aumentar un poquito el valor absoluto del radio de dispersión:

$spread-r: -$k * 1.1 * $particle-r;

Otro problema sería el hecho de que algunos sistemas operativos convierten el corazón Unicode en un emoji. Encontré una solución que debería evitar que esto suceda, pero se ve fea y resultó ser poco confiable, así que terminé aplicando un filter de grayscale(1) cuando la casilla de verificación no está marcada y eliminarla cuando se marca.

Un par de ajustes más, como establecer un buen background y un font sobre el body y previniendo la selección del corazón y obtenemos:

Accesibilidad

Todavía hay un problema con esto, un problema de accesibilidad en este caso: cuando se usa el teclado para navegar, no hay una pista visual sobre si el interruptor del corazón está enfocado o no (porque hemos movido la casilla de verificación fuera de la vista). La primera solución que se me ocurre es añadir un text-shadow en el corazón cuando la casilla de verificación está enfocada. Una blanca parece la mejor apuesta:

[id='toggle-heart']:focus + label {
  text-shadow: 
    0 0 3px #fff, 
    0 1px 1px #fff, 0 -1px 1px #fff, 
    1px 0 1px #fff, -1px 0 1px #fff;
}

No parecía que tuviera suficiente contraste con el estado gris inicial del corazón, así que terminé cambiando el gris del sprite a uno más oscuro.

Actualización: como sugirió David Storey en los comentarios, también he agregado aria-label="like" a la etiqueta

El resultado final

(Visited 1 times, 1 visits today)