Kletterbare React-Akkordeons: Zusammenfalten mit CSS Grid, nicht durch Entfernen
Erstellen Sie verschachtelte React-Accordions, die den Inhalt im DOM beibehalten, um ihn indizieren zu können; animieren Sie die Höhe mithilfe von Grid-Reihen, beschränken Sie pro Ebene die gleichzeitige Öffnung auf ein Element und setzen Sie den Zustand bei Schließen zurück.
Die meisten Akkordeon-Tutorials zeigen das Panel nur an, wenn es geöffnet ist. Das ist für ein Modalfenster in Ordnung, doch auf einer inhaltsreichen Seite wie einem Portfolio, einem Dokumentationszentrum oder einer FAQ bedeutet das, dass der Text innerhalb jeder geschlossenen Ebene beim Rendern der Seite durch einen Crawler nicht im Dokument vorhanden ist. Diese Anleitung erstellt eine Reihe von verschachtelten, einziehbaren Abschnitten, sodass nichts den DOM verlässt, die Höhe sanft animiert wird – ohne „Zauberzahlen“ –, das Prinzip „nur ein offener Abschnitt“ auf jeder Nestungsebene korrekt funktioniert, das Wiederöffnen eines Abschnitts von vorne beginnt und das Klickziel dort endet, wo der Überschriftentext endet.
Das Szenario: elf Abschnitte, drei Ebenen tief
Betrachten wir eine persönliche Website mit elf obersten Abschnitten, die sich zusammenfalten lassen: Über uns, Verfügbarkeit, Erfahrung, Portfolio, Ausbildung, Sprachen, Fähigkeiten, Labor, Downloads, Standort und Kontakt. Mehrere davon sind weiter unterteilt. Allein der Abschnitt „Kontakt“ geht drei Ebenen tief, wobei öffentliche Profile, Veröffentlichungen und Stellenbörsen jeweils in eigene Untergruppen aufgeteilt sind.
In vollständig erweiterter Form ist die Seite eine überwältigende Masse an Text. Zusammengefaltet lässt sie sich leicht durchscannen. Es ist daher die offensichtliche Wahl, die Abschnitte zusammenfaltbar zu gestalten. Die Frage ist nur, wie.
Das Muster, das fast jedes Tutorial vermittelt, ist die bedingte Darstellung:
{isOpen && (
<div className="content">
{children}
</div>
)}
Es funktioniert, und mit einem Wrapper-Element kann man sogar Animationen einbauen. Doch dadurch wird stillschweigend der Zweck einer Seite untergraben, die dazu da ist, gefunden zu werden.
Was bedingte Einbindung tatsächlich bewirkt
{isOpen && ...} versteckt nichts. Wenn isOpen falsch ist, erstellt React diesen Unterbaum niemals, sodass es im DOM nichts gibt, was versteckt oder angezeigt werden könnte.
Für Modal-Fenster und Dropdown-Menüs ist das genau das richtige Verhalten – ein geschlossenes Dialogfeld sollte nicht im Dokument vorhanden sein. Bei einer Inhaltsseite ist das jedoch umgekehrt. Auf einem Portfolio sind die zusammengeklappten Abschnitte der Kern: Jahre Erfahrung, Projektbeschreibungen, Technologielisten, Aufgaben und Ergebnisse. Jeder Begriff, nach dem ein Rekrutierer suchen könnte, befindet sich innerhalb von Elementen, die ursprünglich geschlossen sind.
Google führt JavaScript aus, daher ist das Problem weniger gravierend als früher. Die Seite wird jedoch in ihrem ursprünglichen Zustand dargestellt. Der Crawler klickt nicht auf Pfeile – alles, was bei der Ladezeit deinstalliert wird, ist zu Indexierungszwecken nicht auf der Seite vorhanden.
Zusammenklappen mit Styles anstelle von Renderung
Die Lösung besteht darin, „collapsed“ als Stilfrage und nicht als Darstellungsentscheidung zu betrachten. Der Inhalt wird einmal eingebunden und bleibt eingebunden; nur seine sichtbare Höhe ändert sich.
Die traditionelle Methode, um die Höhe zu animieren, ist max-height, wodurch man einen Wert wählen muss, der größer ist als der höchste Abschnitt, und ungleichmäßige Timing-Eigenschaften in Kauf nehmen muss, da die Übergangsanimation über den gesamten geschätzten Bereich stattfindet und nicht über die tatsächliche Höhe. CSS Grid bietet einen saubereren Ansatz, wie hier mit Tailwind-Klassen gezeigt:
<div
className={`grid transition-all duration-300 ${
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
}`}
>
<div className="overflow-hidden">{children}</div>
</div>
Warum der Trick mit grid-rows funktioniert
Ein Grid-Rahmen mit der Größe 1fr dehnt sich so aus, dass er die natürliche Höhe seines Inhalts aufnimmt, während ein Rahmen mit der Größe 0fr auf Null zusammenfällt. Browser können zwischen diesen beiden Werten interpolieren, wodurch der Übergang fließend ist und kein Maximum erforderlich ist. Der innere overflow-hidden-Wrapper ist unerlässlich: Ohne ihn würde der Inhalt aus der Zeile mit der Nullhöhe herausquellen, anstatt abgeschnitten zu werden.
Die Markup-Struktur ist nun im geöffneten und geschlossenen Zustand identisch. Nur die berechnete Höhe und Transparenz unterscheiden sich, sodass jedes Wort im Dokument bleibt und für die Indizierung verfügbar ist.
Eine Einschränkung: Die Animation von grid-template-rows ist eine relativ neue Browserfunktion. Ältere Browser werden einfach zwischen den Zuständen springen, daher sollten Sie bei der Unterstützung solcher Browser einen Ersatzplan erstellen oder den Mangel an Animation in Kauf nehmen. Überprüfen Sie die aktuellen Unterstützungsdaten im Vergleich zu Ihrer eigenen Nutzerbasis.
Vermeiden Sie, dass zusammengeklapptes Inhalte in die Tab-Reihenfolge gelangen
Das Beibehalten von Inhalten im DOM hat einen Aspekt der Barrierefreiheit, der berücksichtigt werden muss. Links und Schaltflächen innerhalb eines visuell zusammengeklappten Panels können weiterhin die Tastaturfokussierung erhalten, und Bildschirmleser können sie weiterhin ankündigen. Durch Hinzufügen des inert-Attributs zum Panel, solange es geschlossen ist (oder zumindest aria-hidden zusammen mit der Unfokussierbarkeit der inneren Steuerelemente), bleibt der Text für Suchmaschinen im Dokument, wird aber aus der Interaktion ausgeklammert. Koppeln Sie die Überschriftsschaltfläche mit aria-expanded, damit assistive Technologien den Zustand erkennen können. Der Browser bietet außerdem hidden="until-found" an, wodurch der Inhalt weiterhin über die Funktion „Suchen im Dokument“ auffindbar bleibt; dies lohnt eine Prüfung, doch seine Gestaltung und Animation unterscheiden sich vom Gitteransatz.
Bereichsbegrenzung „nur ein Element gleichzeitig geöffnet“
Durch das Zusammenfalten der Elemente ist die nächste Anforderung das klassische Akkordeonverhalten: Das Öffnen eines Abschnitts führt zum Schließen der übrigen. Bei einer flachen Liste bedeutet das einen Zustandswert, der den Öffnungsstatus an der Spitze speichert, sowie eine Gleichheitsprüfung in jedem Element.
Gestapelte Abschnitte durchbrechen das sofort. Eine einzige globale Regel schließt den übergeordneten Abschnitt, sobald ein untergeordneter geöffnet wird – schließlich handelt es sich beim Unterabschnitt um ein weiteres zusammenfaltbares Element, und die Regel kann keinen Unterschied erkennen. Wenn man eine Untergruppe wie z. B. Stellenseiten eines Unternehmens erweitert, schließt sich der darin enthaltene Abschnitt direkt unter dem Cursor.
Diese Einschränkung benötigt einen Geltungsbereich: Die Exklusivität gilt innerhalb einer Gruppe von Geschwisterelementen unter demselben Übergeordneten, nicht auf der gesamten Seite. Jedes zusammenfaltbare Element gehört zur Gruppe seines Übergeordneten und erstellt zugleich eine neue Gruppe für seine eigenen Kinderelemente. Die Gruppe ist eine kleine Struktur, die den Öffnungsstatus sowie den zugehörigen Steuermechanismus enthält:
type CollapseGroup = {
openKey: string | null;
setOpenKey: (key: string | null) => void;
};
Diese Struktur wird über einen React Context geteilt, wobei null bedeutet „nicht innerhalb einer Gruppe“:
const CollapseGroupContext = createContext<CollapseGroup | null>(null);
Jeder Knoten liest den Context seines Elternteils, um zu entscheiden, ob er geöffnet ist, und umhüllt anschließend seine Kinder mit einem neuen Provider. Bei drei Nestungsebenen befindet sich der Zustand in drei unabhängigen Contexts statt in einer einzigen, flachen Struktur mit zusammengesetzten Schlüsseln wie contact/profiles/boards. Dadurch bleibt jede Ebene einfach und die Tiefe unbegrenzt, ohne zusätzliche Verwaltung.
Auslöschen des verschachtelten Zustands beim Schließen eines Elternteils
Ein subtileres Benutzerproblem tritt erst nach längerer Nutzung der Seite auf. Öffnen Sie eine Abschnitt, dann einen Unterabschnitt und anschließend einen Untergeberabschnitt. Schließen Sie den obersten Abschnitt und lesen Sie etwas anderes. Wenn Sie diesen obersten Abschnitt später wieder öffnen, kehrt er genau in den Zustand mit drei Ebenen zurück, den Sie zuvor hinterlassen hatten.
Die Beibehaltung des Zustands klingt rücksichtsvoll, ist aber verwirrend. Das Wiederöffnen von etwas wirkt wie ein Neustart, und die Benutzeroberfläche widerspricht dieser Erwartung. Man hat vergessen, wo man war – die UI nicht.
Die Lösung besteht darin, beim Schließen eines Knotens alles darunter klarzustellen. Der Anbieter besitzt den offenen Schlüssel seiner Gruppe:
const CollapseGroupProvider = ({ isOpen, children }) => {
const [openKey, setOpenKey] = useState<string | null>(null);
und ein Effekt löscht diesen Schlüssel jedes Mal, wenn der eigene Knoten des Anbieters geschlossen wird:
useEffect(() => {
if (!isOpen) setOpenKey(null);
}, [isOpen]); // ...
};
Warum sich die Kaskade selbst kümmert
Wenn ein Knoten der ersten Ebene geschlossen wird, setzt sein Anbieter die Auswahl der zweiten Ebene auf null. Dadurch werden alle Knoten der zweiten Ebene geschlossen, was die Effekte ihrer Anbieter auslöst, welche wiederum die dritte Ebene usw. löschen. Die Rücksetzung breitet sich ohne explizite Durchquerung des Baums in jede Tiefe aus.
Der Kompromiss besteht darin, dass sich jeder Level in einem separaten Render-Pass verarbeitet, da Effekte nach dem Rendering ausgeführt werden. Bei einigen wenigen Levels ist das nicht wahrnehmbar. Falls dies in einem sehr tiefen Baum strukturell sichtbares Flackern verursacht, besteht eine Alternative darin, den Kind-Provider durch Änderung seiner key erneut zu montieren, wenn der Elternteil geschlossen wird – dadurch wird der verschachtelte Zustand in einem Schritt verworfen. In jedem Fall werden, da der Inhalt weiterhin montiert bleibt, nur die geöffneten Schlüssel zurückgesetzt; der DOM-Inhalt selbst wird niemals zerstört.
Eine zu große Klickzielfläche verkleinern
Ein kleineres Problem kann lange Zeit zum Diagnostizieren benötigen. Jeder Überschriftsbutton, der sich über die ganze Zeile erstreckt:
className="flex w-full items-center justify-start gap-3 py-1 ..."
Deshalb reagiert die gesamte Zeile auf Klicks – sogar der leere Bereich nach dem Titel, der fast den gesamten Bildschirm ausfüllt. Wenn man versucht, Text auszuwählen oder am Rand zu klicken, faltet sich ein Abschnitt unerwartet zusammen oder entfaltet sich wieder.
Durch den Wechsel von w-full zu w-fit wird die Schaltfläche auf ihre Inhalte zugeschnitten: das Emoji, der Titel und das Pfeilsymbol.
className="flex w-fit items-center justify-start gap-3 py-1 ..."
Die explizite Wahl von w-fit anstelle nur des Entfernens von w-full ist beabsichtigt. Eine <button>-Element mit display: flex und automatischer Breite hängt davon ab, wie jeder Browser Formularelemente inhärent formatiert – durch die explizite Angabe wird vermieden, auf ein überall gleiches Verhalten zu setzen.
Auch auf kleinen Bildschirmen kommt es zu keiner Verschlechterung. Eine Breite nach dem Prinzip fit-content wird auf den verfügbaren Platz begrenzt und überschreitet niemals den Container, sodass ein langer Titel auf einem Handy weiterhin genauso abgebrochen wird wie zuvor.
Testen der Zustandskaskade
Eine durch Effekte gesteuerte verschachtelte Zustandslogik sieht in der Überprüfung korrekt aus, versagt aber in der Praxis. Vor dem Versand lohnt es sich, den Anbieter mit React in jsdom darzustellen und die wichtigen Fälle zu überprüfen:
- Das Öffnen von L1, anschließend L2 und dann L3 lässt alle drei offen.
- Das Schließen von L1 lässt alle drei geschlossen.
- Das Wiederöffnen von L1 öffnet nur Ebene 1, während Ebenen 2 und 3 geschlossen bleiben.
- Das Öffnen eines Nebenelements von L1 lässt den gesamten L1-Ableger geschlossen.
- Das Schließen und Wiederöffnen des gesamten Abschnitts lässt alles darunter geschlossen.
Der dritte Fall ist der, für den das Zurücksetzen existiert, und auch derjenige, bei dem am ehesten Fehler auftreten, wenn man dem Code auf den ersten Blick vertraut. Es lohnt sich außerdem, eine Überprüfung hinzuzufügen, ob der zusammengefaltete Inhalt weiterhin im dargestellten Markup vorhanden ist, da genau diese Eigenschaft der gesamte Designansatz voraussetzt.
Zusammenfassung
Aus diesen Optionen ist keine auf einem Screenshot zu sehen. Besucher werden nicht bemerken, dass der eingeklappte Text weiterhin im DOM vorhanden ist, dass das Wiederöffnen eines Blocks einen sauberen Neustart bedeutet oder dass der Header keine Klicks mehr dort zulässt, wo der Text endet. Wenn dies gut umgesetzt wird, bleibt lediglich das Gefühl, dass nichts störend ist, und Suchmaschinen sehen die vollständige Seite.
- Entfernen Sie Elemente, die beim Schließen nicht vorhanden sein sollten, wie z. B. Modale und Menüs; klappen Sie mit CSS Inhalte ein, die stets Teil der Seite sein sollten.
- Animeieren Sie die Höhe mithilfe von
grid-template-rowszwischen0frund1frsowie einem Kind mitoverflow-hidden, anstatt einemax-heightzu vermuten. - Machen Sie eingeklappte Panels
inert, damit versteckter Inhalt indexierbar, aber nicht fokussierbar ist.
Für verwandte Techniken, wie man Inhalt außerhalb des Bildschirms kostengünstig darstellt und dennoch indexierbar hält, siehe das Überspringen der Darstellung außerhalb des Bildschirms mithilfe von content-visibility.