Zum Inhalt
← Sektionen & Funktionen

Mehr laden mit Rückkehr an die Stelle

Produktliste lädt in Schüben über einen echten Link ?seite=n nach, Fokus springt auf den ersten neuen Eintrag, und wer ein Produkt öffnet und zurückgeht, landet wieder genau dort, mit Fokus darauf; dazu Fortschritt, Fehler mit Erneut versuchen und Automatik mit Grenze.

Shop, Laden-Sortiment, Fahrzeug- und Immobilienbestand, Stellen: Nutzer entscheidet selbst, wann mehr kommt.

Eine Produkt- oder Ergebnisliste lädt in Schüben nach. Der Knopf „12 weitere laden“ ist ein echter Link auf ?seite=2, funktioniert also auch ohne JavaScript und für Suchmaschinen. Mit JavaScript hängen die neuen Einträge an, der Fokus springt auf den ersten neuen, die URL merkt sich den Stand. Wer ein Produkt öffnet und mit der Zurück-Taste zurückkommt, landet wieder genau bei diesem Produkt, mit Fokus darauf, statt oben in der Liste. Dazu ein Fortschritt („24 von 46“), ein Fehlerzustand mit „Erneut versuchen“ und ein Ende mit „Nach oben“.

Laut Baymard ist „Load more“ in Shops die bessere Wahl als Seitenzahlen oder endloses Scrollen, aber über 90 % der untersuchten Shops setzen die Rückkehr von der Produktseite falsch um (Smashing Magazine 2016). Wer nach dem Zurück wieder oben landet, sucht neu und bricht öfter ab, auf dem Telefon besonders. Ein Link statt eines reinen Knopfs hält die Liste auffindbar und teilbar.

Wann einsetzen

Produktlisten im Shop und im Laden-Sortiment, Fahrzeug- und Immobilienbestand, Stellenlisten, Ergebnislisten nach Filter, Projektarchive. Überall, wo man aus der Liste in eine Detailseite und wieder zurück geht.

Wann nicht

Für News und Blog mit Jahr-Archiv nimm news-uebersicht-archiv. Für weniger als 20 Einträge ganz ohne Nachladen. Kein endloses Scrollen ohne Grenze, sonst ist der Footer nicht erreichbar.

Varianten

  • Knopf: Weitere laden als Link mit Fokus auf den ersten neuen Eintrag
  • Automatisch: lädt beim Scrollen bis zu einer Grenze (z. B. 36), danach Knopf, Footer bleibt erreichbar
  • Rückkehr: Zurück von der Detailseite stellt Stand, Position und Fokus wieder her

Umsetzung

Server liefert ?seite=n mit allen Einträgen von 1 bis n (12 je Schub), damit ein geteilter Link und ein Neuladen den Stand zeigen. Der Knopf ist <a href="?seite=2">; JavaScript fängt den Klick ab, zeigt drei Skelett-Kacheln, lädt den nächsten Schub (fetch, hier simuliert), hängt ihn an, setzt die URL per history.replaceState (Zurück verlässt die Liste weiter normal) und setzt den Fokus mit preventScroll auf den Link des ersten neuen Eintrags; role=status sagt „12 weitere geladen, 24 von 46“. Vor dem Öffnen eines Produkts speichert die Liste {seite, id} in sessionStorage. Beim Laden der Liste (load und pageshow, auch aus dem bfcache) und history.scrollRestoration = 'manual': ist ein Eintrag gespeichert, die Kachel mit scrollIntoView({block: 'center'}) zeigen, ihren Link fokussieren, kurz markieren und den Eintrag löschen. Fehler beim Nachladen: Satz und Knopf „Erneut versuchen“ an derselben Stelle, Fokus bleibt. Automatisch: IntersectionObserver auf einem Wächter 600 px vor dem Ende lädt bis zur Grenze, danach der Knopf. Ende: „Alle 46 angezeigt“ und „Nach oben“.

Robust umsetzen

Telefon. Zwei Spalten, Knopf in voller Breite, Fortschritt darüber. Die Rückkehr wiegt auf dem Telefon am meisten, weil Scrollen dort langsam ist. Automatik nur mit Grenze, damit Footer und Kontakt erreichbar bleiben.

Ohne Bewegung. Neue Einträge erscheinen ohne Versatz, Sprünge ohne weiches Scrollen, die Markierung nach der Rückkehr ist ein fester Rahmen statt eines Pulses.

Barrierefrei. Nach dem Nachladen Fokus auf den ersten neuen Eintrag, Ansage mit Anzahl und Stand. Nach der Rückkehr Fokus auf das zuletzt geöffnete Produkt. Der Knopf bleibt ein Link mit klarem Text („12 weitere Tees laden“), aria-busy während des Ladens. Skelette sind aria-hidden. Fehler als Text mit Knopf, nicht nur als Farbe.

Code

<!DOCTYPE html>
<!--
  Mehr laden mit Rückkehr an die Stelle
  Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/paginierung-mehr-laden/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de" data-v="knopf">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mehr laden mit Rückkehr an die Stelle · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Young Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/young-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

/* Rezept d2c-produkt-zuerst: warme Neutraltöne, Kacheln, schwarze Pille */
:root {
  --grund: #F5F4F1; --kachel: #ECE9E2; --text: #212121; --leise: #636362; --linie: rgba(33, 33, 33, .1); --gruen: #10714E; --fehler: #B3261E; --akzent: #EB5C9D;
  --ein: cubic-bezier(0.16, 1, 0.3, 1); --aus: cubic-bezier(0.7, 0, 0.84, 0);
  --t-neu: 300ms; --t-ui: 150ms; --t-mark: 1200ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-text-size-adjust: 100%; scroll-padding: 24px 0 96px; }
body { min-height: 100svh; font-size: 1rem; line-height: 1.55; }
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
:root:has(:focus-visible) { scroll-behavior: auto; }
a { color: inherit; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.kopf { display: flex; justify-content: space-between; align-items: center; gap: 8px 24px; flex-wrap: wrap; padding: 16px clamp(16px, 4vw, 48px); border-bottom: 1px solid var(--linie); }
.marke { font: 400 1.5rem/1 'Young Serif', Georgia, serif; text-decoration: none; letter-spacing: -.01em; }
.marke small { font: 500 .8125rem/1 'Instrument Sans', sans-serif; color: var(--leise); margin-left: 10px; letter-spacing: 0; }
.kopf nav { display: flex; gap: 4px 22px; flex-wrap: wrap; font-size: .9375rem; font-weight: 500; }
.kopf nav a { text-decoration: none; }
.kopf nav a[aria-current] { text-decoration: underline; text-underline-offset: .35em; text-decoration-thickness: 1.5px; }

main { max-width: 1320px; margin: 0 auto; padding: clamp(24px, 4vw, 48px) clamp(16px, 4vw, 48px) clamp(64px, 8vw, 104px); }
.listenkopf { display: flex; justify-content: space-between; align-items: end; gap: 12px 32px; flex-wrap: wrap; margin-bottom: 24px; }
.listenkopf h1 { font: 400 clamp(2.25rem, 1.4rem + 3.6vw, 4.5rem)/1.05 'Young Serif', Georgia, serif; letter-spacing: -.01em; }
.listenkopf p { color: var(--leise); max-width: 42ch; }

.raster { list-style: none; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 28px 18px; }
.produkt { position: relative; border-radius: 12px; }
.produkt .bild { aspect-ratio: 1; background: var(--kachel); border-radius: 12px; display: grid; place-items: center; overflow: hidden; }
.produkt svg { width: 62%; height: auto; transition: translate 300ms var(--ein); }
@media (hover: hover) { .produkt:hover svg { translate: 0 -4px; } }
.produkt h2 { font-size: 1.0625rem; font-weight: 600; line-height: 1.3; margin-top: 12px; }
.produkt h2 a { text-decoration: none; }
.produkt h2 a::after { content: ''; position: absolute; inset: 0; border-radius: 12px; }
.produkt:has(a:focus-visible) { outline: 2px solid var(--text); outline-offset: 4px; }
.produkt h2 a:focus-visible { outline: none; } /* Ersatz: Rahmen um die Kachel (Zeile darüber) */
.produkt .sorte { font-size: .875rem; color: var(--leise); }
.produkt .preis { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-top: 4px; font-variant-numeric: tabular-nums; }
.produkt .preis b { font-weight: 600; }
.produkt .preis small { font-size: .8125rem; color: var(--leise); }
.produkt.neu { animation: neu var(--t-neu) var(--ein) backwards; animation-delay: calc(var(--i, 0) * 40ms); }
@keyframes neu { from { opacity: 0; translate: 0 12px; } }
.produkt.zurueck .bild { box-shadow: 0 0 0 2px var(--text); animation: markieren var(--t-mark) var(--ein); }
@keyframes markieren { 0% { box-shadow: 0 0 0 0 var(--text); } 20% { box-shadow: 0 0 0 6px var(--text); } 100% { box-shadow: 0 0 0 2px var(--text); } }
.skelett { list-style: none; display: contents; }
.skelett li { border-radius: 12px; }
.skelett li::before { content: ''; display: block; aspect-ratio: 1; border-radius: 12px; background: linear-gradient(100deg, var(--kachel) 30%, #f3f1ec 50%, var(--kachel) 70%) 0 0 / 300% 100%; animation: schimmer 1.2s linear infinite; }
.skelett li::after { content: ''; display: block; height: 14px; width: 70%; margin-top: 14px; border-radius: 4px; background: var(--kachel); }
@keyframes schimmer { to { background-position: -150% 0; } }

/* Fuß der Liste */
.weiter { margin: 40px auto 0; max-width: 420px; text-align: center; display: grid; gap: 14px; justify-items: center; }
.stand { font-size: .9375rem; color: var(--leise); font-variant-numeric: tabular-nums; }
.balken { width: 220px; height: 3px; border-radius: 2px; background: var(--linie); overflow: hidden; }
.balken i { display: block; height: 100%; background: var(--text); transform-origin: left; transition: scale var(--t-neu) var(--ein); }
.mehr { display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 52px; padding: 0 28px; border-radius: 999px; background: var(--text); color: #fff; text-decoration: none; font-weight: 600; transition: background-color var(--t-ui) var(--ein); }
.mehr:hover { background: #3a3a3a; }
.mehr[aria-busy="true"] { cursor: progress; background: #4a4a4a; }
.mehr[aria-busy="true"] svg { animation: drehen .9s linear infinite; }
@keyframes drehen { to { rotate: 360deg; } }
.mehr svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 2; }
.fehlerzeile { color: var(--fehler); font-weight: 500; display: flex; gap: 8px; align-items: center; }
.fehlerzeile[hidden], .ende[hidden], .mehr[hidden] { display: none; }
.ende { display: grid; gap: 8px; justify-items: center; }
.ende a { font-weight: 600; text-underline-offset: .25em; }
.waechter { height: 1px; }

/* Detailseite */
.detail { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(24px, 5vw, 72px); align-items: start; }
.detail .bild { aspect-ratio: 1; background: var(--kachel); border-radius: 12px; display: grid; place-items: center; }
.detail .bild svg { width: 56%; height: auto; }
.krumen { font-size: .875rem; color: var(--leise); margin-bottom: 18px; display: flex; gap: 8px; flex-wrap: wrap; }
.krumen a { color: var(--text); }
.detail h1 { font: 400 clamp(2rem, 1.4rem + 2.6vw, 3.5rem)/1.05 'Young Serif', Georgia, serif; margin-bottom: 12px; }
.detail .preis { font-size: 1.5rem; font-weight: 600; font-variant-numeric: tabular-nums; margin-top: 18px; }
.detail .preis small { display: block; font-size: .875rem; font-weight: 400; color: var(--leise); }
.zurueck-link { display: inline-flex; align-items: center; gap: 10px; margin-top: 28px; min-height: 48px; padding: 0 22px; border-radius: 999px; border: 1.5px solid var(--text); text-decoration: none; font-weight: 600; }
.zurueck-link:hover { background: var(--text); color: #fff; }
.tipp { margin-top: 18px; font-size: .875rem; color: var(--leise); max-width: 44ch; }

/* Pult */
.pult { position: fixed; right: 16px; bottom: 16px; z-index: 40; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 5px; background: rgba(33, 33, 33, .93); color: #f5f4f1; border-radius: 999px; font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; max-width: calc(100vw - 32px); }
.pult span { padding: 0 8px 0 10px; opacity: .75; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; background: none; border: 0; border-radius: 999px; padding: 9px 11px; cursor: pointer; }
.pult button[aria-pressed="true"] { background: #f5f4f1; color: #212121; }
.pult button:focus-visible { outline: 2px solid #f5f4f1; outline-offset: 1px; }

@media (max-width: 960px) { .raster { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 680px) { .raster { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px 12px; } .detail { grid-template-columns: minmax(0, 1fr); } .kopf nav { display: none; } .weiter { max-width: none; } .mehr { width: 100%; } .produkt .preis { flex-direction: column; gap: 0; } }
@media (max-width: 360px) { .produkt .preis { flex-direction: column; } }

.vorschau body { overflow: hidden; }
.vorschau .kopf { display: none; }
.vorschau .raster { grid-template-columns: repeat(4, minmax(0, 1fr)); }

:root.reduziert *, :root.reduziert *::before, :root.reduziert *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-delay: 0ms !important; animation-iteration-count: 1 !important; }
:root.reduziert .produkt.zurueck .bild { box-shadow: 0 0 0 3px var(--text); }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
  .mehr, .zurueck-link { border: 1px solid ButtonText; }
  .produkt.zurueck .bild { outline: 3px solid Highlight; }
}
</style>
</head>
<body>
<header class="kopf">
  <a class="marke" href="?" data-start>Blattwerk<small>Teekontor · Aurich</small></a>
  <nav aria-label="Hauptmenü"><a href="?" aria-current="page">Tee</a><a href="#">Zubehör</a><a href="#">Geschenke</a><a href="#">Laden</a></nav>
</header>

<main data-main></main>

<p class="vh" role="status" aria-live="polite" data-ansage></p>

<div class="pult" role="group" aria-label="Steuerung der Demo">
  <span>Variante</span>
  <button type="button" data-wahl="knopf" aria-pressed="true">Knopf</button>
  <button type="button" data-wahl="automatisch" aria-pressed="false">Automatisch</button>
  <span>Netz</span>
  <button type="button" data-fehler aria-pressed="false">Laden schlägt fehl</button>
</div>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
  const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
  const JE = 12, AUTO_BIS = 36, SCHLUESSEL = 'blattwerk-rueckkehr';
  if ('scrollRestoration' in history) history.scrollRestoration = 'manual';   // Position stellen wir selbst her

  // 46 Sorten: Name, Sorte, Preis je 100 g, Dosenfarbe
  const SORTEN = [['Ostfriesen-Mischung', 'Schwarztee', 7.9], ['Assam Mokalbari', 'Schwarztee', 9.5], ['Darjeeling Margaret’s Hope', 'Schwarztee', 14.9], ['Ceylon Uva', 'Schwarztee', 8.4], ['Earl Grey Bergamotte', 'Schwarztee', 8.9], ['Friesischer Sonntag', 'Schwarztee', 8.2], ['Sencha Fukuyu', 'Grüntee', 11.5], ['Gyokuro Asahi', 'Grüntee', 24.0], ['Genmaicha', 'Grüntee', 7.6], ['Lung Ching', 'Grüntee', 16.8], ['Bancha Herbst', 'Grüntee', 6.9], ['Jasmin Perlen', 'Grüntee', 18.5], ['Pfefferminze Krause', 'Kräuter', 5.4], ['Kamille Blüten', 'Kräuter', 5.9], ['Melisse und Zitronengras', 'Kräuter', 6.2], ['Fenchel Anis Kümmel', 'Kräuter', 4.9], ['Rooibos Natur', 'Kräuter', 5.8], ['Honigbusch', 'Kräuter', 6.4], ['Hagebutte Hibiskus', 'Früchte', 5.2], ['Apfel Zimt', 'Früchte', 5.6], ['Holunder Rote Beere', 'Früchte', 5.9], ['Sanddorn Orange', 'Früchte', 6.1], ['Wintertraum', 'Früchte', 6.3], ['Kirsche Mandel', 'Früchte', 5.9], ['Oolong Milky', 'Oolong', 13.9], ['Tie Guan Yin', 'Oolong', 15.5], ['Da Hong Pao', 'Oolong', 19.9], ['Pai Mu Tan', 'Weißer Tee', 17.5], ['Silver Needle', 'Weißer Tee', 29.0], ['Lapsang Souchong', 'Schwarztee', 10.9], ['Yunnan Gold', 'Schwarztee', 13.5], ['Nilgiri Frost', 'Schwarztee', 11.2], ['Matcha Uji', 'Grüntee', 32.0], ['Hojicha', 'Grüntee', 9.8], ['Chai Kardamom', 'Gewürz', 7.4], ['Ingwer Zitrone', 'Gewürz', 6.6], ['Kurkuma Pfeffer', 'Gewürz', 7.2], ['Lavendel Salbei', 'Kräuter', 6.8], ['Brennnessel Minze', 'Kräuter', 5.1], ['Ostfriesen Blatt', 'Schwarztee', 9.2], ['Kluntje-Mischung', 'Schwarztee', 8.6], ['Deichgras', 'Kräuter', 6.0], ['Moorbirke', 'Kräuter', 6.5], ['Hafenlicht', 'Früchte', 6.2], ['Teepause Aurich', 'Schwarztee', 7.5], ['Leuchtfeuer Rauch', 'Schwarztee', 11.9]];
  const FARBEN = { 'Schwarztee': ['#2f3b4c', '#d9b25a'], 'Grüntee': ['#4f6b3a', '#e6dcb8'], 'Kräuter': ['#7a8c5d', '#f2ead8'], 'Früchte': ['#a8433b', '#f3d9b5'], 'Oolong': ['#6b4a2f', '#e8cf9a'], 'Weißer Tee': ['#c9c2b2', '#3b3a36'], 'Gewürz': ['#b5682c', '#2b2420'] };
  const slug = s => s.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
  const TEE = SORTEN.map(([name, sorte, preis], i) => ({ id: slug(name), name, sorte, preis, nr: i + 1, farbe: FARBEN[sorte] }));
  const GESAMT = TEE.length;

  // Teedose als Zeichnung: Zylinder mit Deckel und Etikett
  function dose(t, gross = false) {
    const [k, e] = t.farbe, n = t.nr;
    const muster = n % 3 === 0 ? `<path d="M46 98h108M46 104h108" stroke="${k}" stroke-width="1.2" opacity=".5"/>` : n % 3 === 1 ? `<circle cx="100" cy="122" r="16" fill="none" stroke="${k}" stroke-width="1.4" opacity=".55"/>` : `<path d="M60 128l20-14 20 14 20-14 20 14" fill="none" stroke="${k}" stroke-width="1.4" opacity=".55"/>`;
    return `<svg viewBox="0 0 200 230" aria-hidden="true" focusable="false">
      <ellipse cx="100" cy="214" rx="66" ry="8" fill="rgba(33,33,33,.12)"/>
      <path d="M38 52v150c0 8 28 14 62 14s62-6 62-14V52Z" fill="${k}"/>
      <path d="M38 52v150c0 8 28 14 62 14V52Z" fill="rgba(255,255,255,.07)"/>
      <path d="M146 56v146" stroke="rgba(255,255,255,.18)" stroke-width="5"/>
      <path d="M38 84h124v78H38z" fill="${e}"/>
      ${muster}
      <text x="100" y="148" text-anchor="middle" font-family="Young Serif, Georgia, serif" font-size="${t.name.length > 16 ? 10 : 12.5}" fill="${k}">${t.name.length > 24 ? t.name.slice(0, 22) + '…' : t.name}</text>
      <text x="100" y="94" text-anchor="middle" font-family="Instrument Sans, sans-serif" font-size="7.5" letter-spacing="2" fill="${k}" opacity=".8">BLATTWERK · ${String(n).padStart(2, '0')}</text>
      <path d="M34 40c0-7 30-12 66-12s66 5 66 12v14c0 7-30 12-66 12S34 61 34 54Z" fill="${k}"/>
      <path d="M34 40c0 7 30 12 66 12s66-5 66-12" fill="none" stroke="rgba(255,255,255,.22)" stroke-width="2"/>
      <ellipse cx="100" cy="40" rx="66" ry="12" fill="rgba(255,255,255,.08)"/>
    </svg>`;
  }

  const q = () => new URLSearchParams(location.search);
  let v = 'knopf', seite = 1, laedt = false, fehlerAn = false, fehlversuch = false, beobachter = null;

  function karte(t, i, neu) {
    return `<li class="produkt${neu ? ' neu' : ''}" data-id="${t.id}" style="--i:${i}">
      <div class="bild">${dose(t)}</div>
      <h2><a href="?produkt=${t.id}" data-produkt="${t.id}">${t.name}</a></h2>
      <p class="sorte">${t.sorte}</p>
      <p class="preis"><b>${euro.format(t.preis)}</b><small>100 g · ${euro.format(t.preis * 10)} / kg</small></p>
    </li>`;
  }

  // ---------- Liste ----------
  function listeZeigen(bis) {
    seite = Math.max(1, Math.min(Math.ceil(GESAMT / JE), bis));
    $('[data-main]').innerHTML = `
      <div class="listenkopf"><h1 tabindex="-1" data-titel>Alle Tees</h1><p>${GESAMT} Sorten aus unserem Kontor, abgefüllt in Aurich. Preise inkl. MwSt., zzgl. Versand.</p></div>
      <ul class="raster" data-raster aria-label="Tees">${TEE.slice(0, seite * JE).map((t, i) => karte(t, i)).join('')}</ul>
      <div class="waechter" data-waechter aria-hidden="true"></div>
      <div class="weiter">
        <p class="stand" data-stand></p>
        <div class="balken" aria-hidden="true"><i data-balken></i></div>
        <p class="fehlerzeile" data-fehlerzeile hidden><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="8" cy="8" r="6.5"/><path d="M8 4.5v4.5M8 11v.5"/></svg>Das Nachladen hat nicht geklappt.</p>
        <a class="mehr" href="?seite=2" data-mehr><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2v12M3 9l5 5 5-5"/></svg><span data-mehr-text></span></a>
        <div class="ende" data-ende hidden><p>Alle ${GESAMT} Tees angezeigt.</p><a href="#" data-oben>Nach oben</a></div>
      </div>`;
    standSetzen();
    $('[data-mehr]').addEventListener('click', e => { e.preventDefault(); nachladen(); });
    $('[data-oben]').addEventListener('click', e => { e.preventDefault(); scrollTo({ top: 0, behavior: reduziert() ? 'auto' : 'smooth' }); $('[data-titel]').focus({ preventScroll: true }); });
    automatik();
  }
  function standSetzen() {
    const n = Math.min(seite * JE, GESAMT), rest = GESAMT - n, mehr = $('[data-mehr]');
    $('[data-stand]').textContent = `${n} von ${GESAMT} Tees`;
    $('[data-balken]').style.scale = `${n / GESAMT} 1`;
    mehr.hidden = rest <= 0; $('[data-ende]').hidden = rest > 0;
    mehr.href = `?seite=${seite + 1}${v === 'automatisch' ? '&v=automatisch' : ''}`;
    $('[data-mehr-text]').textContent = fehlversuch ? 'Erneut versuchen' : `${Math.min(JE, rest)} weitere Tees laden`;
  }
  function urlSchreiben() {
    const p = q(); seite > 1 ? p.set('seite', seite) : p.delete('seite'); p.delete('produkt'); p.set('v', v);
    history.replaceState(null, '', location.pathname + '?' + p);
  }

  // Nachladen: Skelett, Schub anhängen, Fokus auf den ersten neuen Eintrag
  function nachladen(still = false) {
    if (laedt || seite * JE >= GESAMT) return Promise.resolve();
    laedt = true;
    const mehr = $('[data-mehr]'); mehr.setAttribute('aria-busy', 'true'); $('[data-mehr-text]').textContent = 'Lädt …';
    $('[data-fehlerzeile]').hidden = true;
    const sk = document.createElement('ul'); sk.className = 'skelett'; sk.setAttribute('aria-hidden', 'true'); sk.innerHTML = '<li></li>'.repeat(4);
    $('[data-raster]').append(sk);
    return new Promise(fertig => setTimeout(() => {
      sk.remove(); laedt = false; mehr.removeAttribute('aria-busy');
      if (fehlerAn && !fehlversuch) {   // simulierter Netzfehler: Satz und „Erneut versuchen“ an derselben Stelle
        fehlversuch = true; $('[data-fehlerzeile]').hidden = false; standSetzen();
        $('[data-ansage]').textContent = 'Das Nachladen hat nicht geklappt. Erneut versuchen.';
        return fertig();
      }
      fehlversuch = false;
      const von = seite * JE; seite++;
      const neue = TEE.slice(von, seite * JE);
      $('[data-raster]').insertAdjacentHTML('beforeend', neue.map((t, i) => karte(t, i, true)).join(''));
      standSetzen(); urlSchreiben();
      const n = Math.min(seite * JE, GESAMT);
      $('[data-ansage]').textContent = `${neue.length} weitere Tees geladen, ${n} von ${GESAMT}.`;
      if (!still) {
        const erster = $(`[data-produkt="${neue[0].id}"]`);
        erster.focus({ preventScroll: true });
        if (!html.classList.contains('vorschau')) erster.closest('.produkt').scrollIntoView({ block: 'nearest', behavior: reduziert() ? 'auto' : 'smooth' });
      }
      automatik();
      fertig();
    }, 450));
  }

  // Automatisch: Wächter vor dem Ende lädt bis zur Grenze, danach bleibt der Knopf
  function automatik() {
    beobachter?.disconnect();
    if (v !== 'automatisch' || seite * JE >= AUTO_BIS || seite * JE >= GESAMT) return;
    beobachter = new IntersectionObserver(e => { if (e[0].isIntersecting && !fehlversuch) nachladen(true); }, { rootMargin: '0px 0px 600px 0px' });
    beobachter.observe($('[data-waechter]'));
  }

  // ---------- Produkt öffnen: Stelle merken ----------
  document.addEventListener('click', e => {
    const a = e.target.closest('[data-produkt]'); if (!a || e.metaKey || e.ctrlKey || e.shiftKey) return;
    sessionStorage.setItem(SCHLUESSEL, JSON.stringify({ seite, id: a.dataset.produkt, v }));
    if (laeuft) { e.preventDefault(); return; }
    // echte Navigation zur Produktseite (?produkt=…), damit Zurück wie auf einer echten Seite funktioniert
    e.preventDefault(); location.href = `?produkt=${a.dataset.produkt}&v=${v}`;
  });

  // ---------- Detailseite ----------
  function detailZeigen(id) {
    const t = TEE.find(x => x.id === id) || TEE[0];
    const gemerkt = JSON.parse(sessionStorage.getItem(SCHLUESSEL) || 'null');
    const zurueck = `?${gemerkt && gemerkt.seite > 1 ? 'seite=' + gemerkt.seite + '&' : ''}v=${v}`;
    $('[data-main]').innerHTML = `
      <nav class="krumen" aria-label="Brotkrumen"><a href="${zurueck}" data-zur-liste>Alle Tees</a><span aria-hidden="true">/</span><span aria-current="page">${t.name}</span></nav>
      <div class="detail">
        <div class="bild">${dose(t, true)}</div>
        <div>
          <h1 tabindex="-1" data-titel>${t.name}</h1>
          <p>${t.sorte}, Nummer ${t.nr} im Sortiment. Lose, in der wiederverschließbaren Dose zu 100 g.</p>
          <p class="preis">${euro.format(t.preis)}<small>100 g · Grundpreis ${euro.format(t.preis * 10)} / kg · inkl. MwSt., zzgl. Versand</small></p>
          <a class="zurueck-link" href="${zurueck}" data-zur-liste><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 8H2M7 3L2 8l5 5"/></svg>Zurück zu allen Tees</a>
          <p class="tipp">Probieren Sie auch die Zurück-Taste des Browsers: Die Liste steht wieder an dieser Stelle, mit dem Fokus auf ${t.name}.</p>
        </div>
      </div>`;
    $$('[data-zur-liste]').forEach(a => a.addEventListener('click', e => {
      if (laeuft) { e.preventDefault(); return; }
      // Kam man aus der Liste, ist Zurück der natürliche Weg (bfcache). Sonst normaler Link mit ?seite=.
      if (document.referrer && new URL(document.referrer, location.href).pathname === location.pathname && history.length > 1) { e.preventDefault(); history.back(); }
    }));
    document.title = `${t.name} · Blattwerk`;
  }

  // ---------- Rückkehr: Stand, Position und Fokus wiederherstellen ----------
  function rueckkehr() {
    const g = JSON.parse(sessionStorage.getItem(SCHLUESSEL) || 'null');
    if (!g) return false;
    sessionStorage.removeItem(SCHLUESSEL);
    const index = TEE.findIndex(t => t.id === g.id); if (index < 0) return false;
    const noetig = Math.ceil((index + 1) / JE);
    if (!$('[data-raster]') || seite < noetig) listeZeigen(Math.max(seite, g.seite, noetig));
    const li = $(`.produkt[data-id="${g.id}"]`); if (!li) return false;
    li.scrollIntoView({ block: 'center' });
    $('a', li).focus({ preventScroll: true });
    $$('.produkt.zurueck').forEach(x => x.classList.remove('zurueck')); li.classList.add('zurueck');
    setTimeout(() => li.classList.remove('zurueck'), 4000);
    $('[data-ansage]').textContent = `Zurück bei ${TEE[index].name}, Platz ${index + 1} von ${GESAMT}.`;
    return true;
  }

  function route() {
    const p = q();
    v = p.get('v') === 'automatisch' ? 'automatisch' : 'knopf';
    html.dataset.v = v;
    $$('[data-wahl]').forEach(b => b.setAttribute('aria-pressed', b.dataset.wahl === v));
    if (p.get('produkt')) { detailZeigen(p.get('produkt')); scrollTo(0, 0); return; }
    document.title = 'Mehr laden mit Rückkehr an die Stelle · Fundus-Demo';
    listeZeigen(parseInt(p.get('seite')) || 1);
    if (!rueckkehr()) scrollTo(0, 0);
  }
  // Aus dem bfcache zurück: Liste ist noch da, nur Fokus und Markierung setzen
  addEventListener('pageshow', e => { if (e.persisted && !q().get('produkt')) rueckkehr(); });

  $$('[data-wahl]').forEach(b => b.addEventListener('click', () => {
    const lief = laeuft; pause(); v = b.dataset.wahl; html.dataset.v = v;
    $$('[data-wahl]').forEach(x => x.setAttribute('aria-pressed', x === b));
    const p = q(); p.set('v', v); p.delete('seite'); p.delete('produkt'); history.replaceState(null, '', location.pathname + '?' + p);
    fehlversuch = false; listeZeigen(1); if (lief) vorfuehren();
  }));
  $('[data-fehler]').addEventListener('click', e => { fehlerAn = !fehlerAn; fehlversuch = false; e.currentTarget.setAttribute('aria-pressed', fehlerAn); if ($('[data-mehr]')) standSetzen(); });

  // ---------- Vorführung: nachladen, Produkt öffnen, zurück an die Stelle ----------
  let laeuft = false, gen = 0, uhr = null;
  const warte = ms => new Promise(r => { uhr = setTimeout(r, ms); });
  async function vorfuehren() {
    pause(); laeuft = true; const g = ++gen; html.classList.add('vorfuehrung');
    const ok = () => g === gen, weich = () => reduziert() ? 'auto' : 'smooth';
    fehlversuch = false; listeZeigen(1); scrollTo(0, 0); await warte(700); if (!ok()) return;
    const ziel = () => $('[data-mehr]').getBoundingClientRect().top + scrollY - innerHeight * .62;
    scrollTo({ top: ziel(), behavior: weich() }); await warte(900); if (!ok()) return;
    await nachladen(true); if (!ok()) return;
    const erster = $(`[data-produkt="${TEE[JE].id}"]`).closest('.produkt');
    scrollTo({ top: erster.getBoundingClientRect().top + scrollY - 40, behavior: weich() }); await warte(1300); if (!ok()) return;
    // Produkt 17 „öffnen“: in der Vorführung ohne echte Navigation
    const t = TEE[16];
    sessionStorage.setItem(SCHLUESSEL, JSON.stringify({ seite, id: t.id, v }));
    $(`.produkt[data-id="${t.id}"]`).classList.add('zurueck'); await warte(500); if (!ok()) return;
    detailZeigen(t.id); scrollTo(0, 0); await warte(1800); if (!ok()) return;
    // „Zurück“: Liste mit Stand, Position und Markierung
    listeZeigen(seite); rueckkehr(); await warte(2600); if (!ok()) return;
    uhr = setTimeout(vorfuehren, 200);
  }
  function neustart() { pause(); sessionStorage.removeItem(SCHLUESSEL); fehlversuch = false; listeZeigen(1); scrollTo(0, 0); }
  function pause() { gen++; laeuft = false; clearTimeout(uhr); html.classList.remove('vorfuehrung'); }
  function weiter() { vorfuehren(); }

  route();

})();
</script>
</body>
</html>