Einfache Webanwendung von Grund auf mit HTML, CSS und JavaScript erstellen: vollständige Anleitung
Das Erstellen einer Webanwendung von Grund auf — nur mit HTML, CSS und JavaScript — ist eine der besten Methoden, um deine Fähigkeiten zu schärfen und den kompletten Browser-Stack zu beherrschen. In diesem Tutorial erstellst du eine vollständige, ausgefeilte und zugängliche Single-Page-Webapp: einen einfachen Aufgabenmanager, der Aufgaben erstellen, bearbeiten, als erledigt markieren, filtern und in localStorage persistieren kann. Du lernst, wie du Dateien strukturierst, semantisches HTML schreibst, mit modernem CSS gestaltest, JavaScript-Module architekturierst, Zustand verwaltest, Ereignisse über Delegation behandelst, Daten persistierst und die App statisch bereitstellst. Am Ende hast du eine professionelle App und ein wiederholbares Muster für zukünftige Projekte.
![]()
Was wir bauen werden
Wir bauen eine Todo-App mit:
- Aufgaben hinzufügen, bearbeiten, als erledigt markieren und löschen
- Filtern: Alle, Aktiv, Erledigt
- Persistenz über localStorage
- Zugängliche Markup (Labels, Rollen, Tastaturnavigation)
- Responsives Design und ausgefeilte Interaktionen
Wichtige Entscheidungen:
- Framework-frei bleiben (vanilla JS)
- Module (ES Modules) zur Organisation des Codes verwenden
- Anfangs auf Build-Tools verzichten; du kannst sie später hinzufügen
- Rendering einfach und robust halten
Voraussetzungen und Werkzeuge
Du solltest sicher sein im Umgang mit:
- HTML: Formulare, Semantik, grundlegendes ARIA
- CSS: Flexbox, Custom Properties, responsive Einheiten
- JavaScript: ES6+, DOM-API, Events, Module (import/export)
Werkzeuge:
- Ein moderner Browser (Chrome, Firefox, Edge, Safari)
- Ein Code-Editor (VS Code empfohlen)
- Ein lokaler Webserver (VS Code Live Server Extension oder python -m http.server)
Plane die App
Bevor du mit dem Codieren beginnst, skizziere die Form der App und das Datenmodell.
Funktionen und Abläufe:
- Der Nutzer tippt eine Aufgabe und drückt Enter oder klickt auf Hinzufügen
- Die Aufgabe erscheint in der Liste mit einer Checkbox und Aktionen (Bearbeiten, Löschen)
- Der Nutzer toggelt Erledigt, bearbeitet Text inline oder löscht die Aufgabe
- Filter passen die Listenansicht an
- Die App merkt sich Aufgaben zwischen Sitzungen
Datenmodell:
- Aufgabenstruktur: { id: string, text: string, completed: boolean, createdAt: number }
- App-Zustand: { tasks: Task[], filter: "all" | "active" | "completed" }
Rendering-Ansatz:
- Bei jeder Änderung aus dem Zustand rendern (einfach und verlässlich)
- Event-Delegation auf der Liste verwenden, um Item-Aktionen zu behandeln
- View-Funktionen so oft wie möglich rein halten
Barrierefreiheit:
- Ein form mit einem Label für das Eingabefeld verwenden
- Buttons für Aktionen verwenden (keine Links)
- Änderungen mit aria-live für die Aufgabenanzahl ankündigen
- Tastaturbedienbarkeit sicherstellen
Wireframe der Oberfläche:
- Header: Titel und markenkonforme Anzeige
- Formular: Texteingabe + Hinzufügen-Button
- Steuerung: Filter-Buttons + "Erledigte löschen"
- Liste: Jedes Element hat Checkbox, Text, Bearbeiten, Löschen
- Footer: Statistiken (verbleibende Elemente)

Projekt initialisieren
Erstelle einen Ordner und die folgende Struktur:
- index.html
- styles.css
- js/
- main.js
- state.js
- storage.js
- dom.js
Vom Terminal aus:
- mkdir simple-todo-app && cd simple-todo-app
- mkdir js
- touch index.html styles.css js/main.js js/state.js js/storage.js js/dom.js
HTML verfassen
Wir schreiben semantisches, zugängliches HTML mit einer Vorlage für Aufgabenitems.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Simple Tasks</title> <link rel="stylesheet" href="styles.css" /> </head> <body> <header class="app-header"> <h1 class="visually-hidden">Simple Tasks</h1> <div class="brand" aria-hidden="true">Simple Tasks</div> </header> <main class="app" id="app"> <section aria-labelledby="add-task-heading" class="panel"> <h2 id="add-task-heading" class="panel-title">Add a task</h2> <form id="task-form" class="task-form" autocomplete="off"> <label for="task-input" class="sr-only">Task</label> <input id="task-input" name="task" type="text" placeholder="What needs to be done?" required minlength="1" maxlength="200" /> <button type="submit" class="btn primary">Add</button> </form> </section> <section aria-labelledby="controls-heading" class="panel controls-panel"> <h2 id="controls-heading" class="panel-title">Controls</h2> <div class="filters" role="group" aria-label="Filter tasks"> <button class="btn filter is-active" data-filter="all" aria-pressed="true">All</button> <button class="btn filter" data-filter="active" aria-pressed="false">Active</button> <button class="btn filter" data-filter="completed" aria-pressed="false">Completed</button> </div> <button class="btn subtle" id="clear-completed" type="button">Clear completed</button> </section> <section aria-labelledby="list-heading" class="panel"> <h2 id="list-heading" class="panel-title">Tasks</h2> <ul id="todo-list" class="todo-list" aria-live="polite"></ul> <!-- Template for tasks --> <template id="task-template"> <li class="todo-item" data-id=""> <label class="checkbox"> <input type="checkbox" class="toggle" aria-label="Mark complete" /> <span class="check"></span> </label> <div class="item-text" tabindex="0"></div> <div class="actions"> <button class="btn icon edit" aria-label="Edit task" title="Edit">✏️</button> <button class="btn icon delete" aria-label="Delete task" title="Delete">🗑️</button> </div> </li> </template> </section> <footer class="panel footer"> <div id="stats" aria-live="polite" aria-atomic="true">0 items</div> </footer> </main> <script type="module" src="./js/main.js"></script> </body> </html>
Wichtige Details:
- Das h1 ist visuell verborgen, um Semantik zu bewahren, ohne Platz zu beanspruchen. Das sichtbare Branding steht in einem "brand"-Div.
- Das <template>-Element speichert die Item-Struktur zum Klonen für jede Aufgabe.
- aria-live auf der Liste und den Statistiken stellt sicher, dass Screenreader Aktualisierungen erhalten.
- Für Aktionen und Filter werden Buttons (keine Links) verwendet.
Mit modernem CSS gestalten
Wir halten es minimal, aber ausgefeilt, mit CSS-Custom-Properties, einem kleinen Reset und responsive Layout.
:root { --bg: #0f172a; --panel: #111827; --text: #e5e7eb; --muted: #9ca3af; --accent: #22c55e; --accent-2: #10b981; --danger: #ef4444; --border: #1f2937; --focus: #38bdf8; --shadow: 0 10px 30px rgba(0,0,0,.25); --radius: 12px; --space: 14px; --font: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; } * { box-sizing: border-box; } html, body { height: 100%; } body { margin: 0; font-family: var(--font); background: radial-gradient(1200px 800px at 20% -10%, #1e293b, transparent), radial-gradient(1400px 1000px at 80% 110%, #0b3b2b, transparent), var(--bg); color: var(--text); } .visually-hidden, .sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } .app-header { padding: calc(var(--space) * 2) var(--space); text-align: center; } .brand { font-weight: 800; font-size: clamp(20px, 5vw, 32px); letter-spacing: .5px; opacity: .95; } .app { max-width: 800px; margin: 0 auto; padding: var(--space); display: grid; gap: var(--space); } .panel { background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.005)); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: calc(var(--space) * 1.2); } .panel-title { margin-top: 0; margin-bottom: var(--space); font-size: 14px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } .task-form { display: grid; grid-template-columns: 1fr auto; gap: 10px; } #task-input { width: 100%; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border); background: #0b1220; color: var(--text); outline: none; transition: border-color .15s, box-shadow .15s; } #task-input:focus { border-color: var(--focus); box-shadow: 0 0 0 3px rgba(56,189,248,.25); } .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 10px; padding: 10px 14px; border: 1px solid var(--border); background: #0f172a; color: var(--text); cursor: pointer; transition: transform .04s ease, background .15s, border-color .15s; user-select: none; } .btn:hover { background: #162035; } .btn:active { transform: translateY(1px); } .btn.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); border: none; color: #052e16; font-weight: 700; } .btn.subtle { background: #0b1220; color: var(--muted); } .btn.icon { padding: 8px 10px; min-width: 36px; } .controls-panel { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: center; } .filters { display: inline-flex; gap: 8px; } .filter.is-active, .filter[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } .todo-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; } .todo-item { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; padding: 10px; border-radius: 10px; background: #0b1220; border: 1px solid var(--border); } .checkbox { position: relative; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; } .checkbox input { appearance: none; width: 0; height: 0; position: absolute; } .check { width: 20px; height: 20px; border-radius: 6px; border: 2px solid #334155; background: #0b1220; display: inline-block; position: relative; transition: border-color .15s, background .15s; } .toggle:focus + .check { outline: 3px solid rgba(56,189,248,.3); } .toggle:checked + .check { background: var(--accent); border-color: var(--accent); } .toggle:checked + .check::after { content: "✓"; color: #052e16; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -58%); font-weight: 900; font-size: 14px; } .item-text { outline: none; min-height: 24px; padding: 6px 8px; border-radius: 8px; } .todo-item.completed .item-text { color: var(--muted); text-decoration: line-through; } .item-text:focus { box-shadow: 0 0 0 3px rgba(56,189,248,.25); background: #0f172a; } .actions { display: inline-flex; gap: 6px; } .actions .delete:hover { border-color: var(--danger); color: var(--danger); } .footer { display: flex; justify-content: space-between; align-items: center; color: var(--muted); } @media (max-width: 560px) { .controls-panel { grid-template-columns: 1fr; } }
Highlights:
- Nutzt ein warmes, modernes Erscheinungsbild, geeignet für Dark Mode.
- Sichtbare Fokusindikatoren unterstützen die Barrierefreiheit.
- Subtile Hover- und Active-Zustände verbessern die Benutzererfahrung.
JavaScript-Module strukturieren
Wir teilen die Logik in Module auf:
- state.js: verwaltet Zustandsübergänge (reinere Funktionen)
- storage.js: laden/speichern in localStorage
- dom.js: Rendering und DOM-Bindings
- main.js: bootstrappt die App und verbindet die Events
state.js: Zustand und pure Updates
// js/state.js export const FILTERS = { ALL: "all", ACTIVE: "active", COMPLETED: "completed" }; export function createInitialState(loadedTasks = []) { return { tasks: loadedTasks, filter: FILTERS.ALL }; } export function addTask(state, text) { const newTask = { id: generateId(), text: text.trim(), completed: false, createdAt: Date.now() }; return { ...state, tasks: [newTask, ...state.tasks] }; } export function toggleTask(state, id) { return { ...state, tasks: state.tasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t) }; } export function deleteTask(state, id) { return { ...state, tasks: state.tasks.filter(t => t.id !== id) }; } export function editTask(state, id, newText) { const text = newText.trim(); if (!text) return state; // avoid empty text return { ...state, tasks: state.tasks.map(t => (t.id === id ? { ...t, text } : t)) }; } export function clearCompleted(state) { return { ...state, tasks: state.tasks.filter(t => !t.completed) }; } export function setFilter(state, filter) { if (!Object.values(FILTERS).includes(filter)) return state; return { ...state, filter }; } export function getVisibleTasks(state) { switch (state.filter) { case FILTERS.ACTIVE: return state.tasks.filter(t => !t.completed); case FILTERS.COMPLETED: return state.tasks.filter(t => t.completed); default: return state.tasks; } } export function remainingCount(state) { return state.tasks.filter(t => !t.completed).length; } function generateId() { // 16-char base36 id: timestamp + random return (Date.now().toString(36) + Math.random().toString(36).slice(2, 10)).slice(0, 16); }
Design-Hinweise:
- Funktionen geben neue Zustandsobjekte zurück; das macht Updates vorhersehbar und testbar.
- Der Zustand ist ein einfaches Objekt, leicht zu serialisieren.
storage.js: Persistenz
// js/storage.js const KEY = "simple-tasks/v1"; export function loadTasks() { try { const raw = localStorage.getItem(KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; // sanitize objects return parsed .filter(isTaskLike) .map(t => ({ id: String(t.id), text: String(t.text), completed: !!t.completed, createdAt: Number(t.createdAt) || Date.now() })); } catch { return []; } } export function saveTasks(tasks) { try { localStorage.setItem(KEY, JSON.stringify(tasks)); } catch (err) { // storage quota errors can happen console.warn("Failed to save to localStorage", err); } } function isTaskLike(v) { return v && typeof v === "object" && "id" in v && "text" in v && "completed" in v; }
Hinweise:
- Daten werden bereinigt, um Probleme mit beschädigtem Storage zu vermeiden.
dom.js: Rendering und DOM-Helfer
// js/dom.js import { getVisibleTasks, remainingCount } from "./state.js"; export function bindElements() { return { form: document.getElementById("task-form"), input: document.getElementById("task-input"), list: document.getElementById("todo-list"), template: document.getElementById("task-template"), filters: document.querySelectorAll(".filter"), clearCompletedBtn: document.getElementById("clear-completed"), stats: document.getElementById("stats") }; } export function render(state, els) { // Render the list const tasks = getVisibleTasks(state); els.list.innerHTML = ""; // simple replace const frag = document.createDocumentFragment(); for (const task of tasks) { const node = els.template.content.firstElementChild.cloneNode(true); node.dataset.id = task.id; if (task.completed) node.classList.add("completed"); const checkbox = node.querySelector(".toggle"); const textEl = node.querySelector(".item-text"); checkbox.checked = task.completed; textEl.textContent = task.text; frag.appendChild(node); } els.list.appendChild(frag); // Render filters ARIA and active class for (const btn of els.filters) { const pressed = btn.dataset.filter === state.filter; btn.classList.toggle("is-active", pressed); btn.setAttribute("aria-pressed", String(pressed)); } // Render stats const remaining = remainingCount(state); const plural = remaining === 1 ? "item" : "items"; els.stats.textContent = `${remaining} ${plural} left`; } export function focusInput(els) { els.input.focus(); els.input.select?.(); }
main.js: Klebercode und Event-Handler
// js/main.js import { createInitialState, addTask, toggleTask, deleteTask, editTask, clearCompleted, setFilter, FILTERS } from "./state.js"; import { loadTasks, saveTasks } from "./storage.js"; import { bindElements, render, focusInput } from "./dom.js"; let state = createInitialState(loadTasks()); const els = bindElements(); function update(nextState) { state = nextState; render(state, els); saveTasks(state.tasks); } function onSubmit(e) { e.preventDefault(); const text = els.input.value.trim(); if (!text) return; update(addTask(state, text)); els.form.reset(); focusInput(els); } function onListClick(e) { const item = e.target.closest(".todo-item"); if (!item) return; const id = item.dataset.id; if (e.target.matches(".toggle")) { update(toggleTask(state, id)); return; } if (e.target.matches(".delete")) { update(deleteTask(state, id)); return; } if (e.target.matches(".edit")) { startInlineEdit(item, id); return; } } function startInlineEdit(item, id) { const textEl = item.querySelector(".item-text"); const oldText = textEl.textContent; textEl.setAttribute("contenteditable", "true"); textEl.focus(); placeCaretEnd(textEl); function commit() { textEl.removeAttribute("contenteditable"); const newText = textEl.textContent; if (newText.trim() !== oldText.trim()) { update(editTask(state, id, newText)); } else { // re-render to normalize text and state render(state, els); } teardown(); } function cancel() { textEl.removeAttribute("contenteditable"); textEl.textContent = oldText; teardown(); } function onKeyDown(ev) { if (ev.key === "Enter") { ev.preventDefault(); commit(); } else if (ev.key === "Escape") { ev.preventDefault(); cancel(); } } function onBlur() { commit(); } textEl.addEventListener("keydown", onKeyDown); textEl.addEventListener("blur", onBlur); function teardown() { textEl.removeEventListener("keydown", onKeyDown); textEl.removeEventListener("blur", onBlur); } } function placeCaretEnd(el) { const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } function onFilterClick(e) { const btn = e.target.closest(".filter"); if (!btn) return; const filter = btn.dataset.filter; update(setFilter(state, filter)); } function onClearCompleted() { update(clearCompleted(state)); } function onListChange(e) { // Support toggling via keyboard when focusing checkbox if (e.target.matches(".toggle")) { const item = e.target.closest(".todo-item"); if (!item) return; const id = item.dataset.id; update(toggleTask(state, id)); } } // Bootstrap els.form.addEventListener("submit", onSubmit); els.list.addEventListener("click", onListClick); els.list.addEventListener("change", onListChange); document.querySelector(".filters").addEventListener("click", onFilterClick); els.clearCompletedBtn.addEventListener("click", onClearCompleted); // First render render(state, els);
Anmerkungen:
- update zentralisiert Rendering und Persistenz.
- Inline-Bearbeitung nutzt contenteditable mit Enter zum Bestätigen und Escape zum Abbrechen.
- Nach Updates wird neu gerendert, um DOM und Zustand synchron zu halten.
Lokal ausführen
Öffne einen lokalen Server, um Modul-/CORS-Probleme zu vermeiden:
- VS Code: "Live Server" installieren und auf "Go Live" klicken
- Python: python3 -m http.server 8000 und http://localhost:8000 öffnen
- Node: npx http-server
Deine App sollte mit einem Eingabefeld laden. Füge Aufgaben hinzu, teste die Filter und lade die Seite neu, um die Persistenz zu prüfen.
Warum diese Architektur funktioniert
- Trennung der Verantwortlichkeiten: Zustandsübergänge berühren nicht das DOM; Rendering verändert nicht den Zustand.
- Event-Delegation: Ein einziger Listener behandelt Aktionen vieler Items, was effizient und wartbar ist.
- Zustandsbasiertes Rendering: Jede Aktualisierung zeichnet die sichtbare UI aus dem Zustand neu, wodurch Synchronisationsfehler vermieden werden.
Barrierefreiheitsdetails und Best Practices
- Labels: Das Aufgaben-Eingabefeld hat ein Label (visuell verborgen) für Screenreader.
- Buttons statt Anchors: Aktionen nutzen button-Elemente mit aria-label für Klarheit.
- Tastaturunterstützung:
- Enter im Eingabefeld zum Absenden
- Tab zu einem Aufgabentext und Enter zum Bearbeiten, Escape zum Abbrechen
- Space toggelt eine fokussierte Checkbox
- aria-live-Regionen:
- Die Liste und Statistiken melden Zustandsänderungen, ohne den Fokus zu übernehmen.
- Fokusmanagement:
- Nach dem Hinzufügen einer Aufgabe kehrt der Fokus zum Eingabefeld zurück.
- Farbkontrast: Die Palette behält guten Kontrast; überprüfe sie mit den Accessibility-Tools der DevTools.
Zu vermeidende Fehler:
- Keine interaktiven Handler auf nicht-interaktiven Elementen ohne role und Tastaturunterstützung setzen.
- Die "Bearbeiten"-Aktion zugänglich machen; der Aufgabentext ist fokussierbar (tabindex="0").
UX mit kleinen Details verbessern
- Eingabe nach dem Hinzufügen automatisch auswählen, um das schnelle Hinzufügen zu beschleunigen.
- Leichte Einblendanimationen für neue Items (optional mit CSS-Transition auf transform/opacity).
- title-Attribute auf Edit-/Delete-Buttons für Hover-Hinweise.
- Filter im localStorage persistieren, falls gewünscht (zusätzliche Verbesserung).
Kleine Animationen hinzufügen (optional)
Um das Erscheinen von Listenelementen dezent zu animieren, füge hinzu:
.todo-item { opacity: 0; transform: translateY(4px); animation: fadeIn .18s ease-out forwards; } @keyframes fadeIn { to { opacity: 1; transform: translateY(0); } }
Das hält die Performance hoch (nur opacity/transform, GPU-beschleunigt).
Testen und Debugging
Manuelle Tests:
- Aufgaben hinzufügen, bearbeiten, toggeln, löschen
- Filter wechseln; prüfen, ob die Liste korrekt aktualisiert wird
- "Erledigte löschen"; verifizieren, dass verbleibende Aufgaben persistiert werden
- Seite neu laden; prüfen, ob Aufgaben und Erledigt-Status erhalten bleiben
- Nur Tastatur: Sind alle Aktionen möglich?
Konsolentests:
- Temporär state in update() loggen, um Übergänge zu untersuchen
- Große Aufgabenlisten simulieren: In DevTools-Konsole:
- for (let i=0;i<200;i++) { state = addTask(state, "Task " + i); } render(state, els);
Häufige Bugs:
- preventDefault() beim Formular nicht aufrufen → Seitenreload
- data-id bei Listenelementen nicht setzen → Event-Routing bricht
- contenteditable leert den Text; editTask verwendet trim(), um das zu vermeiden
Performance-Überlegungen
Für diese App ist Re-Rendering der sichtbaren Liste pro Update in Ordnung. Bei Skalierung:
- Liste diffen (nach id) und nur geänderte Nodes aktualisieren
- requestAnimationFrame nutzen, um DOM-Schreibvorgänge zu bündeln
- Aufwändige Arbeit außerhalb des Hauptthreads verlagern (hier nicht erforderlich)
- Layout-Thrashing vermeiden: DOM-Werte vor Schreibvorgängen lesen
LocalStorage-Performance:
- Einmal pro Update serialisieren; akzeptabel für kleine Payloads (<1 MB)
Filter persistieren (Verbesserung)
Um den ausgewählten Filter über Sitzungen zu merken, erweitere storage oder verwende die URL-Hash:
- Beim Start location.hash lesen (/#/active oder #/completed)
- setFilter bei hashchange aufrufen
- Links werden so teilbar, z. B. yourapp/#/completed
Beispielanpassung:
// in main.js (enhancement) window.addEventListener("hashchange", () => { const val = location.hash.replace("#/", "") || "all"; update(setFilter(state, val)); });
Responsivität und Layout-Anpassungen
- Das Grid klappt auf schmalen Bildschirmen die Steuerung in eine Spalte zusammen.
- Touch-Ziele sind mindestens 36px hoch für mobile Bedienung.
- Eingaben und Buttons reagieren auf Hover- und Active-Zustände als Feedback.
Mobile-Optimierungen:
- inputmode="text" und Autocomplete-Hinweise verwenden, falls du weitere Felder hinzufügst
- Buttons nicht zu dicht platzieren, um Fehlklicks zu vermeiden
Deployment: App ausliefern
Statisches Hosting ist ideal für diese App.
GitHub Pages:
- Erstelle ein GitHub-Repository und pushe deine Dateien
- In den Repository-Einstellungen GitHub Pages für den main-Branch und Root-Ordner
Bewerte dieses Tutorial
Anmelden um dieses Tutorial zu bewerten
Mehr zum Entdecken



Kommentare (0)
Anmelden um an der Diskussion teilzunehmen
Scrolle nach unten um Kommentare und Bewertungen zu laden