Das Animieren von Akkordeons in JavaScript war eine der am häufigsten nachgefragten Animationen auf Websites. Fun Fact: jQuerys slideDown()-Funktion war bereits in der ersten Version im Jahr 2006 verfügbar.
In diesem Artikel sehen wir uns an, wie Sie das native <details>-Element mithilfe der Web Animations API animieren können.
HTML-Setup
Zuerst sehen wir uns an, wie wir das Markup für diese Animation strukturieren.
Das <details>-Element benötigt ein <summary>-Element. Die Zusammenfassung ist der Inhalt, der sichtbar ist, wenn das Akkordeon geschlossen ist.
Alle anderen Elemente innerhalb von <details> sind Teil des inneren Inhalts des Akkordeons. Um die Animation dieses Inhalts zu erleichtern, verpacken wir ihn in ein <div>.
<details>
<summary>Summary of the accordion</summary>
<div class="content">
<p>
Lorem, ipsum dolor sit amet consectetur adipisicing elit.
Modi unde, ex rem voluptates autem aliquid veniam quis temporibus repudiandae illo, nostrum, pariatur quae!
At animi modi dignissimos corrupti placeat voluptatum!
</p>
</div>
</details>
Accordion-Klasse
Um unseren Code wiederverwendbarer zu machen, sollten wir eine Accordion-Klasse erstellen. Auf diese Weise können wir new Accordion() für jedes <details>-Element auf der Seite aufrufen.
class Accordion {
// The default constructor for each accordion
constructor() {}
// Function called when user clicks on the summary
onClick() {}
// Function called to close the content with an animation
shrink() {}
// Function called to open the element after click
open() {}
// Function called to expand the content with an animation
expand() {}
// Callback when the shrink or expand animations are done
onAnimationFinish() {}
}
Constructor()
Der Konstruktor ist der Ort, an dem wir alle pro Akkordeon benötigten Daten speichern.
constructor(el) {
// Store the <details> element
this.el = el;
// Store the <summary> element
this.summary = el.querySelector('summary');
// Store the <div class="content"> element
this.content = el.querySelector('.content');
// Store the animation object (so we can cancel it, if needed)
this.animation = null;
// Store if the element is closing
this.isClosing = false;
// Store if the element is expanding
this.isExpanding = false;
// Detect user clicks on the summary element
this.summary.addEventListener('click', (e) => this.onClick(e));
}
onClick()
In der onClick()-Funktion prüfen wir, ob das Element animiert wird (schließen oder öffnen). Dies müssen wir tun, falls Benutzer auf das Akkordeon klicken, während es animiert wird. Bei schnellen Klicks möchten wir nicht, dass das Akkordeon von vollständig geöffnet zu vollständig geschlossen springt.
Das <details>-Element hat ein Attribut, [open], das vom Browser darauf angewendet wird, wenn wir das Element öffnen. Wir können den Wert dieses Attributs abrufen, indem wir die open-Eigenschaft unseres Elements mit this.el.open überprüfen.
onClick(e) {
// Stop default behaviour from the browser
e.preventDefault();
// Add an overflow on the <details> to avoid content overflowing
this.el.style.overflow = 'hidden';
// Check if the element is being closed or is already closed
if (this.isClosing || !this.el.open) {
this.open();
// Check if the element is being openned or is already open
} else if (this.isExpanding || this.el.open) {
this.shrink();
}
}
shrink()
Diese shrink-Funktion verwendet die WAAPI-Funktion .animate(). Sie können mehr darüber in der MDN-Dokumentation lesen. WAAPI ist sehr ähnlich zu CSS @keyframes. Wir müssen die Start- und End-Keyframes der Animation definieren. In diesem Fall benötigen wir nur zwei Keyframes, der erste ist die aktuelle Höhe des Elements und der zweite ist die Höhe des <details>-Elements, sobald es geschlossen ist. Die aktuelle Höhe wird in der Variablen startHeight gespeichert. Die geschlossene Höhe wird in der Variablen endHeight gespeichert und entspricht der Höhe des <summary>.
shrink() {
// Set the element as "being closed"
this.isClosing = true;
// Store the current height of the element
const startHeight = `${this.el.offsetHeight}px`;
// Calculate the height of the summary
const endHeight = `${this.summary.offsetHeight}px`;
// If there is already an animation running
if (this.animation) {
// Cancel the current animation
this.animation.cancel();
}
// Start a WAAPI animation
this.animation = this.el.animate({
// Set the keyframes from the startHeight to endHeight
height: [startHeight, endHeight]
}, {
// If the duration is too slow or fast, you can change it here
duration: 400,
// You can also change the ease of the animation
easing: 'ease-out'
});
// When the animation is complete, call onAnimationFinish()
this.animation.onfinish = () => this.onAnimationFinish(false);
// If the animation is cancelled, isClosing variable is set to false
this.animation.oncancel = () => this.isClosing = false;
}
open()
Die open-Funktion wird aufgerufen, wenn wir das Akkordeon erweitern möchten. Diese Funktion steuert die Animation des Akkordeons noch nicht. Zuerst berechnen wir die Höhe des <details>-Elements und wenden diese Höhe mit Inline-Stilen darauf an. Sobald dies geschehen ist, können wir das `open`-Attribut darauf setzen, um den Inhalt sichtbar zu machen, aber ihn zu verstecken, da wir `overflow: hidden` und eine feste Höhe für das Element haben. Dann warten wir auf den nächsten Frame, um die expand-Funktion aufzurufen und das Element zu animieren.
open() {
// Apply a fixed height on the element
this.el.style.height = `${this.el.offsetHeight}px`;
// Force the [open] attribute on the details element
this.el.open = true;
// Wait for the next frame to call the expand function
window.requestAnimationFrame(() => this.expand());
}
expand()
Die expand-Funktion ist ähnlich wie die shrink-Funktion, aber anstatt von der aktuellen Höhe zur geschlossenen Höhe zu animieren, animieren wir von der Höhe des Elements zur Endhöhe. Diese Endhöhe entspricht der Höhe der Zusammenfassung plus der Höhe des inneren Inhalts.
expand() {
// Set the element as "being expanding"
this.isExpanding = true;
// Get the current fixed height of the element
const startHeight = `${this.el.offsetHeight}px`;
// Calculate the open height of the element (summary height + content height)
const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight}px`;
// If there is already an animation running
if (this.animation) {
// Cancel the current animation
this.animation.cancel();
}
// Start a WAAPI animation
this.animation = this.el.animate({
// Set the keyframes from the startHeight to endHeight
height: [startHeight, endHeight]
}, {
// If the duration is too slow of fast, you can change it here
duration: 400,
// You can also change the ease of the animation
easing: 'ease-out'
});
// When the animation is complete, call onAnimationFinish()
this.animation.onfinish = () => this.onAnimationFinish(true);
// If the animation is cancelled, isExpanding variable is set to false
this.animation.oncancel = () => this.isExpanding = false;
}
onAnimationFinish()
Diese Funktion wird am Ende sowohl der schrumpfenden als auch der erweiternden Animation aufgerufen. Wie Sie sehen können, gibt es einen Parameter, [open], der auf `true` gesetzt wird, wenn das Akkordeon geöffnet ist. Dies ermöglicht es uns, das [open] HTML-Attribut auf das Element zu setzen, da es nicht mehr vom Browser gehandhabt wird.
onAnimationFinish(open) {
// Set the open attribute based on the parameter
this.el.open = open;
// Clear the stored animation
this.animation = null;
// Reset isClosing & isExpanding
this.isClosing = false;
this.isExpanding = false;
// Remove the overflow hidden and the fixed height
this.el.style.height = this.el.style.overflow = '';
}
Die Akkordeons einrichten
Puh, den größten Teil des Codes haben wir hinter uns!
Alles, was noch zu tun ist, ist, unsere Accordion-Klasse für jedes <details>-Element im HTML zu verwenden. Dazu verwenden wir querySelectorAll auf dem <details>-Tag und erstellen für jedes eine neue Accordion-Instanz.
document.querySelectorAll('details').forEach((el) => {
new Accordion(el);
});
Notizen
Um die Berechnungen für die geschlossene Höhe und die geöffnete Höhe durchzuführen, müssen wir sicherstellen, dass die <summary> und der Inhalt immer die gleiche Höhe haben.
Versuchen Sie zum Beispiel nicht, dem `summary` ein Padding hinzuzufügen, wenn es geöffnet ist, da dies zu Sprüngen während der Animation führen kann. Dasselbe gilt für den inneren Inhalt – er sollte eine feste Höhe haben und wir sollten es vermeiden, Inhalte zu haben, die ihre Höhe während der Öffnungsanimation ändern könnten.
Fügen Sie auch keinen Rand zwischen der Zusammenfassung und dem Inhalt hinzu, da dieser für die Keyframes der Höhen nicht berechnet wird. Verwenden Sie stattdessen ein Padding direkt auf dem Inhalt, um etwas Abstand zu schaffen.
Das Ende
Und *voilà*, wir haben ein schönes animiertes Akkordeon in JavaScript ohne eine Bibliothek! 🌈
Sehr nett, aber das fühlt sich nach SEHR viel Arbeit für ein einfaches Akkordeon an.
Ich verwende zwei CSS Custom Props, um die "eingeklappten" und "ausgeklappten" Höhen zu speichern, und dann ein kleines JavaScript, um diese zu berechnen. Es gibt einen ResizeObserver, um diese neu zu berechnen, wenn sich Breiten ändern.
Das
details-Elementheight: var(–collapsed);
Und bei [open]
height: var(–expanded);
Habe hier einen Codepen-Demo gemacht
Oh ja, das Vorausberechnen der geöffneten & geschlossenen Werte ist clever!
Ich habe ResizeObserver noch nie benutzt, aber es ist super nützlich (und die Browserunterstützung ist großartig ❤)
Danke für das Teilen deiner Demo
Ich sollte auch erwähnen, dass man das `details`-Tag auch ohne JavaScript animieren *kann*, wenn es nicht um die Höhe geht, z. B. bei einem Mega-Menü oder Toggle-Tipps, die keinen Inhalt nach unten schieben. Allerdings kann man keine Übergänge für den `[open]`-Zustand verwenden, aber Animationen funktionieren gut. Ich habe hier eine Demo für Toggle-Tipps gemacht: https://codepen.io/stoumann/pen/abZXxPx
Und... In meinem ersten Kommentar habe ich vergessen, mich für einen großartigen Artikel zu bedanken!
Das Problem mit diesem Skript ist, dass es das `details`-Element schließt, wenn es geöffnet ist.
Deine Breiten-Neuberechnung funktioniert tatsächlich nicht. Die Breite ändert sich nie, daher feuert der ResizeObserver nie. Wenn du das auf einem Element ausprobierst, das sich tatsächlich vergrößern wird, wirst du feststellen, dass es sowohl die geöffneten als auch die geschlossenen Höhen mit der geschlossenen Höhe misst.
Weißt du eigentlich, wie man DOM-Eigenschaften wie `scrollTop` mit WAAPI animiert? Für mich sieht es so aus, als könnte ich nur CSS animieren, aber die Möglichkeit, alles zu animieren, war ein entscheidender Anwendungsfall für eine JS-basierte Animations-API.
Nein, leider kann WAAPI keine JavaScript-Variablen oder Ähnliches animieren. Es geht wirklich darum, DOM-Elemente auf die gleiche Weise wie CSS-Animationen zu animieren.
Um `scrollTop` zu animieren, könntest du eine Variable mit der Scroll-Position erstellen und sie mit `requestAnimationFrame` animieren. Bei jedem Frame würdest du die neue Scroll-Position auf das Fenster anwenden. Oder du verwendest eine Bibliothek wie GreenSock, um all das für dich zu erledigen :)
Du möchtest also von einem numerischen Wert zu einem anderen numerischen Wert animieren, vielleicht zu einer Elementposition, die mit
getBoundingClientRect()erfasst wird?Unten habe ich eine Funktion erstellt, die du mit einem *von* und *zu* numerischen Wert aufrufen kannst.
Das *Element* ist das Element, innerhalb dessen gescrollt werden soll (standardmäßig der Body-Tag), `dir` ist die Scrollrichtung (standardmäßig
0, was vertikal ist, und1wäre horizontal),durationist in Millisekunden undeasingist die Art der Beschleunigung als Funktion. Ich habe ein paar Beschleunigungsfunktionen hinzugefügt, weitere findest du hier: https://easings.net/Beispiel: Scrolle zur Scroll-Position
1200, benutzescrollFromTo(0, 1200).Alexander, hier ist eine Demo für den Animationscode in meinem vorherigen Kommentar: https://codepen.io/stoumann/full/QWEoWPN
Sehr schön. Tolle Arbeit!
Mein einziger Vorschlag – und ich habe noch nicht versucht, ihn selbst zu lösen – wäre, die Dauer des Öffnens/Schließens von der Höhe abhängig zu machen. So wie es jetzt ist, je größer die Höhe, desto schneller die Geschwindigkeit. Das heißt, das Öffnen oder Schließen muss in der gleichen festen Zeit mehr Distanz zurücklegen.
Das ist keine Beschwerde. Ich *benutze* das schon :) Danke! Es ist einfach etwas, das mir aufgefallen ist, das man gut berücksichtigen könnte.
Danke für den Artikel. Da ich gerne Webkomponenten verwende, habe ich diese Demo mit LitElement modifiziert und einige benutzerdefinierte Eigenschaften für das Styling hinzugefügt: https://codepen.io/johnthad/pen/wvzgzYx
Das ist großartig — ich stimme zu, viel Aufwand für die einfache Animation des `details`-Elements, aber ich benutze lieber das `details`-Element für Akkordeons, und meistens will der Kunde das animiert haben, und verdammt, das fühlt sich gut an.
Ein Bonus wäre, eine Prüfung auf
prefers-reduced-motionhinzuzufügen, um die Animation komplett zu überspringen, wenn ein Benutzer diese auf seinem Betriebssystem aktiviert hat. Man könnte diese Logik beim Aufruf deiner Akkordeon-Klasse für ein Element durchführen, aber ich habe Klassen zu den verschiedenen Komponenten des Akkordeons dort hinzugefügt, sodass es für mich sinnvoller war, diese Logik in der Akkordeon-Klasse durchzuführen. Man könnte eine Prüfung in den Konstruktor einfügenUnd dann in den
shrink- undexpand-Methoden einfach alles WAAPI-Zeug inUnd dann wahrscheinlich ein
elseaufrufen, um die MethodeonAnimationFinishaufzurufen.Also insgesamt sieht meine
shrink-Methode zum Beispiel so ausIch habe eine Version mit reinem CSS erstellt, ohne JavaScript
Ich habe auch einen Beitrag geschrieben, der erklärt, wie man das macht
https://dev.to/jgustavoas/how-to-fully-animate-the-details-html-element-with-only-css-no-javascript-2n88
Das ist sehr interessant! Ich verstehe nicht ganz, warum der Übergang beim Schließen mit dem Input-Trick funktioniert und nicht mit dem Selektor
detail[open]...Es gibt immer noch zwei Probleme mit dieser Lösung. Erstens funktioniert sie leider nicht in Safari. Zweitens, wenn du Inhalte hast, die höher sind als die eingestellte `max-height`.
Auch die Übergangsdauer ist verzerrt, wenn du 100px hohen Inhalt neben 800px hohem Inhalt hast, da der erste Übergang schneller stattfindet, aber das ist nicht so dramatisch :)
Vielen Dank für dein Feedback, Louis!
Es scheint an der schlechten Unterstützung von Safari für das
<summary>-Element und das Pseudo-Element::markerzu liegen.Es ist auch merkwürdig festzustellen, dass diese Lösung unter Firefox auf MacOS überhaupt nicht funktioniert, im Gegensatz zu anderen Betriebssystemen, wo nur der Ansatz mit der Pseudoklasse
:has()in Firefox standardmäßig nicht funktioniert.