Nachbildung der Twitter-Herz-Animation (mit einem Element, ohne Bilder und ohne JavaScript)

Avatar of Ana Tudor
Ana Tudor am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Ich habe kürzlich eine Nachbildung der Twitter-Herz-Animation unter den Highlights auf CodePen gesehen. Wenn ich etwas Zeit habe, schaue ich mir immer den Code von Demos an, die meine Aufmerksamkeit erregen, um zu sehen, ob etwas darin ist, das ich verwenden oder verbessern könnte. In diesem Fall war ich überrascht, dass die Demo ein Bild-Sprite verwendete. Später erfuhr ich, dass Twitter das so macht. Sicherlich könnte es ohne Bilder gemacht werden, oder?

Ich beschloss, das zu versuchen. Ich beschloss auch, es ohne JavaScript zu machen, da dies ein perfekter Kandidat für den Checkbox-Hack ist, der es Ihnen ermöglicht, einfache Ein/Aus-Umschaltungen über Formularelemente und clevere CSS zu realisieren.

Das Ergebnis

Aufnahme der resultierenden Animation

Jetzt sehen wir, wie ich es gemacht habe!

Das Original-Sprite betrachten

Original Twitter Herz-Sprite. In neuem Tab anzeigen.

Es hat 29 Frames, eine Zahl, mit der ich kein Problem habe, bis es um Berechnungen geht. Dann sieht es für mich hässlich aus, weil es eine große Primzahl ist, die ich nicht durch kleine schöne Zahlen wie 2, 4 oder 5 teilen kann und eine ganze Zahl erhalte. Nun ja ... dafür sind Annäherungen gut. 29 liegt ziemlich nah an beiden 28, was ein Vielfaches von 4 ist, da 4 * 7 = 28, und 30, was ein Vielfaches von 5 ist (5 * 6 = 30). Wir könnten diese 29 also entweder als 28 oder als 30 betrachten, je nachdem, was uns am besten passt.

Das Nächste, was man am Sprite bemerkt, ist, dass er drei Komponenten hat

  • das Herz
  • die Blase hinter dem Herz
  • die Partikel um das Herz herum

Das bedeutet, dass es mit nur einem Element und seinen zwei Pseudo-Elementen gemacht werden kann. Das Herz ist das Element selbst, die Blase ist das ::before Pseudo-Element und die Partikel sind das ::after Pseudo-Element.

Den Checkbox-Hack verwenden

Das ganze Herz und seine anderen Teile werden das <label> der Checkbox sein. Das Klicken auf das Label schaltet die Checkbox um und ermöglicht es uns, die beiden Zustände zu behandeln. In dieser Situation sieht unser HTML so aus: eine Checkbox und ein Label, das ein Unicode-Herz enthält.

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

Lassen Sie uns die Checkbox aus dem Blickfeld verschwinden lassen.

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

Wir setzen dann einen color-Wert für das Herz, je nachdem, ob unsere Checkbox aktiviert ist oder nicht. Wir verwenden einen Farbwähler, um die tatsächlichen Werte aus dem Sprite zu extrahieren.

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

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

Zentrieren und vergrößern

Wir setzen auch cursor: pointer auf das Label und erhöhen die font-size, da es sonst zu klein aussieht.

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

Dann positionieren wir es in der Mitte des Bildschirms, damit wir es besser sehen können. Danke, Flexbox!

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; 
}

Wir haben jetzt ein Herz, das grau ist, wenn die Checkbox nicht aktiviert ist, und karmesinrot, wenn sie es ist.

Animieren des Größenwachstums des Herzens

Wenn wir uns das Sprite ansehen, sehen wir, dass das Herz von Frame 2 bis Frame 6 auf 0 skaliert wird. Nach Frame 6 beginnt es zu wachsen und nimmt dann ab einem bestimmten Punkt etwas ab. Diese Art von Wachstum ist der perfekte Anwendungsfall für die easeOutBack-Timing-Funktion. Wir nehmen den Beginn des Wachstums bei 17,5% an, da dies eine schöne Zahl ist, die angesichts unserer Gesamtzahl von Frames eine ziemlich gute Annäherung darstellt. Nun müssen wir entscheiden, wie wir diese Skalierung durchführen. Wir können keine scale()-Transformation verwenden, da dies auch alle Nachfahren oder Pseudoelemente unseres Elements beeinflussen würde, und wir möchten nicht, dass diese auf 0 skaliert werden, wenn unser Herz es ist. Also verwenden wir 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);
}

Das Ergebnis des obigen Codes ist im folgenden Pen zu sehen.

Wenn wir die 0%- oder 100%-Keyframes nicht einschließen, werden sie automatisch mit den für dieses Element gesetzten Werten (in unserem Fall font-size: 2em) generiert, oder, wenn wir das nicht getan haben, aus den Standardwerten (was im Fall von font-size 1em wäre).

Die Blase

Nun zu den Pseudo-Elementen, die die Blase (und auch die Partikel, die wir als Nächstes behandeln) erstellen. Wir setzen position: relative auf unser Herz-Label, damit wir sie absolut positionieren können. Wir möchten sie unter dem Herz haben, also verwenden wir z-index: -1 dafür. Wir wollen sie in der Mitte, also bei 50% von top und left. Sowohl die Blase als auch die Partikel sind rund, also geben wir ihnen border-radius: 50%. Wir beginnen hier, die SCSS-Syntax zu verwenden, da wir sie am Ende verwenden werden, da wir einige Berechnungen durchführen müssen.

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

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

Wenn wir uns das Sprite ansehen, sehen wir, dass die Blase maximal etwas mehr als doppelt so groß wie das Herz ist, also nehmen wir ihren Durchmesser auf 4,5rem. Wir verwenden rem-Einheiten, nicht em, da die font-size des Elements animiert wird, um die Größe des Herzens zu ändern. Wir dimensionieren und positionieren unser ::before Pseudo-Element in der Mitte. Wir geben ihm auch eine Test-Hintergrundfarbe, nur um zu sehen, ob es da ist und richtig aussieht (das entfernen wir später).

$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;
}

Bisher alles gut.

Von Frame 2 bis Frame 5 wächst die Blase von nichts auf ihre volle Größe und wechselt von einem Karmesinrot zu einem Violett. Dann, bis Frame 9, wächst ein Loch in der Mitte, bis dieses Loch so groß ist wie die Blase selbst. Der wachsende Teil sieht aus wie eine Aufgabe, die das Animieren einer scale()-Transformation bewältigen kann. Das wachsende Loch können wir durch Animieren der border-width von $bubble-r (dem Blasenradius) auf 0 erreichen. Beachten Sie, dass wir auch box-sizing: border-box auf der Blase (dem ::before Pseudo-Element) setzen müssen, damit dies funktioniert.

[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);
  }
}

Wir können die Keyframes mit einem Mixin verdichten.

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

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

Wir lassen die Pseudo-Elemente auch die Herz-Animation erben, stellen beide auf eine easeOutCubic-Timing-Funktion um und ändern die animation-name für jedes einzeln.

[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; }
}

Wir können uns ansehen, was der obige Code im folgenden Pen erzeugt.

Die Partikel

Wenn wir uns das Sprite ansehen, können wir sehen, dass wir sieben Gruppen von jeweils zwei runden Partikeln haben und diese Gruppen auf einem Kreis verteilt sind.

Nahaufnahme von drei aufeinanderfolgenden Frames im Sprite, die die Partikel in Gruppen um das Herz zeigen.

Was sich bei ihnen ändert, ist ihre opacity, ihre Position (weil der Radius des Kreises, auf dem die Gruppen liegen, zunimmt) und ihre Größe. Wir erstellen die Partikel mit mehreren Box-Schatten (einer für jeden Partikel) und animieren dann die opacity des Pseudo-Elements sowie die Offsets und den Spread dieser Box-Schatten.

Das Erste, was wir tun, ist, die Dimensionen eines Partikels zu bestimmen, dann unser ::after Pseudo-Element zu dimensionieren und zu positionieren.

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

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

Wir verteilen die sieben Partikelgruppen auf einem Kreis. Wir haben 360° auf einem Kreis, wie die folgende Demo zeigt.

Wir teilen diese 360° in so viele Teile auf, wie wir Gruppen haben. Jeder Eckpunkt eines Polygons in der folgenden Demo würde die Position einer Gruppe markieren.

Wir gehen im Uhrzeigersinn und beginnen an der +-Achse der x-Achse (3 Uhr). Wenn wir bei der --Achse der y-Achse (12 Uhr) beginnen wollen, müssen wir 90° vom Winkel subtrahieren, der der Position jeder Gruppe entspricht.

Nun sehen wir, wie wir eine Verteilung von Gruppen auf einem Kreis codieren, dessen Radius wir anfangs so groß wie den Radius der Blase nehmen ($bubble-r), beginnend von oben (12 Uhr). Wenn wir davon ausgehen, dass wir nur einen Partikel in der Mitte jeder solchen Gruppe haben, dann sollte unser Code so aussehen:

$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;
}

Das Setzen von box-shadow: $shadow-list auf unser ::after Pseudo-Element ergibt das folgende Ergebnis.

Nun betrachten wir den Fall, dass wir zwei Partikel in jeder Gruppe haben.

Wir positionieren die Partikel in einer Gruppe auf einem Kreis (mit einem Radius, sagen wir, gleich dem Durchmesser unseres ::after Pseudo-Elements – $particle-d) um den Mittelpunkt dieser Gruppe.

Das Nächste, woran wir denken müssen, ist der Startwinkel. Im Fall der Gruppen selbst war der Startwinkel -90°, weil wir von oben beginnen wollten. Für die einzelnen Partikel ist der Startwinkel der Winkel, der der Gruppe entspricht (derjenige, den wir zur Berechnung ihrer Koordinaten verwenden), plus ein Offset-Winkel, der für alle Partikel um das Herz herum gleich ist. Wir nehmen diesen Winkel als 60° an, da dies gut aussieht.

Der Code zur Berechnung der Positionen aller Partikel und zum Hinzufügen eines box-shadow an jeder dieser Positionen ist unten aufgeführt.

$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;
  }
}

Dies führt zu dem, was im folgenden Pen zu sehen ist.

Regenbogenpartikel

Die Positionen sehen ziemlich gut aus, aber all diese Schatten verwenden den color-Wert, den wir für das Herz festgelegt haben. Wir können sie regenbogenfarben machen, indem wir jedem Partikel einen hsl()-Wert geben, der vom Index der Gruppe, in der er sich befindet ($i), und seinem Index innerhalb dieser Gruppe ($j) abhängt. Also ändern wir den Teil des Hinzufügens zur Schattenliste.

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

Diese einfache Änderung ergibt Regenbogenpartikel.

Wir könnten sogar einen gewissen Grad an Zufälligkeit bei der Farbwahl einführen, aber ich war mit diesem Ergebnis ziemlich zufrieden.

Beim Animieren der Partikel wollen wir, dass sie von ihrer aktuellen Position, die bedeutet, dass die Gruppen auf dem Kreis mit dem Radius $bubble-r liegen, etwas nach außen wandern, sagen wir, bis die Gruppen auf einem Kreis mit dem Radius 1,25 * $bubble-r liegen. Das bedeutet, dass wir die Variable $group-distr-r ändern müssen.

Gleichzeitig wollen wir, dass sie von ihrer aktuellen vollen Größe auf Null schrumpfen. Das Schrumpfen von Box-Schatten ohne Weichzeichnung auf Null bedeutet, ihnen einen negativen Spreizradius zu geben, dessen absoluter Wert mindestens die Hälfte der kleinsten Dimension des Elements oder Pseudo-Elements beträgt, auf das sie angewendet werden. Beide Dimensionen unseres :after Pseudo-Elements sind gleich $particle-d (dem Partikel-Durchmesser), also sollte unser Spreizradius -$particle-r (dem Partikel-Radius) sein.

Zusammenfassend lässt sich sagen, dass wir im Zustand 0 einen Kreisverteilungsradius von $bubble-r und einen Spreizradius von 0 haben, während wir im Zustand 1 einen Kreisverteilungsradius von 1,25 * $bubble-r und einen Spreizradius von -$particle-r haben.

Wenn wir eine Variable $k für den Zustand verwenden, dann haben wir:

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

Dies führt uns dazu, ein Mixin zu erstellen, damit wir diese @for-Schleifen nicht zweimal schreiben müssen.

@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;
}

Betrachten wir nun noch einmal kurz das Sprite. Die Partikel erscheinen erst ab Frame 7. 7 ist ein Viertel (oder 25%) von 28, was ziemlich nah an unserer tatsächlichen Anzahl von Frames (29) liegt. Das bedeutet, unsere grundlegende Animation der Partikel würde etwa so aussehen.

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

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

Dies ist in Aktion im folgenden Pen zu sehen.

Feinabstimmungen

In allen Browsern außer Edge/IE sieht es gut aus, wo die Partikel nicht wirklich zu nichts schrumpfen, sie bleiben da, winzig klein, kaum sichtbar, aber immer noch sichtbar. Eine schnelle Lösung dafür wäre, den absoluten Wert des Spreizradius ein kleines bisschen zu erhöhen.

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

Ein weiteres Problem wäre die Tatsache, dass einige Betriebssysteme das Unicode-Herz durch ein Emoji-Zeichen ersetzen. Ich habe eine Lösung gefunden, die dies verhindern sollte, aber sie sieht hässlich aus und erwies sich als unzuverlässig, also habe ich am Ende einen filter von grayscale(1) angewendet, wenn die Checkbox nicht aktiviert ist, und ihn entfernt, wenn sie aktiviert wird.

Noch ein paar Tweaks wie das Setzen eines schönen background und einer font auf dem body und das Verhindern der Auswahl des Herzens, und wir erhalten.

Barrierefreiheit (Accessibility)

Dabei gibt es immer noch ein Problem, in diesem Fall ein Barrierefreiheitsproblem: Bei der Navigation mit der Tastatur gibt es keinen visuellen Hinweis darauf, ob der Herz-Schalter fokussiert ist oder nicht (da wir die Checkbox aus dem Blickfeld entfernt haben). Die erste Lösung, die mir einfällt, ist das Hinzufügen eines text-shadow auf das Herz, wenn die Checkbox fokussiert ist. Ein weißer scheint die beste Wahl zu sein.

[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;
}

Es schien nicht genügend Kontrast zum anfänglichen Grauzustand des Herzens zu haben, also habe ich das Grau aus dem Sprite in ein dunkleres geändert.

Update: Wie David Storey in den Kommentaren vorgeschlagen hat, habe ich dem Label auch aria-label='like' hinzugefügt.

Das Endergebnis