TL;DR

element.offsetTop gibt den Abstand zum nächsten positioned ancestor zurück, nicht zum Dokument-Anfang. Bei direkt unter <body> liegenden Elementen ist das zufällig korrekt. Bei tief verschachtelten Elementen (z.B. <li> in <ol> in mehreren <div>s) bekommst du einen kleinen relativen Wert zurück, scrollst also fast nach ganz oben.

Der Fix:

// Falsch:
const targetOffset = element.offsetTop - headerHeight;

// Richtig:
const targetOffset = element.getBoundingClientRect().top + window.pageYOffset - headerHeight;

Das Problem

Du hast dir einen schicken eigenen Smooth-Scroll-Handler gebaut. Er fängt Klicks auf alle a[href^="#"] ab, sucht das Ziel-Element, und scrollt dorthin – mit Offset für den sticky Header. Funktioniert prima für die Navigation:

<nav>
  <a href="#features">Features</a>
  <a href="#pricing">Preise</a>
  <a href="#contact">Kontakt</a>
</nav>

<section id="features">...</section>
<section id="pricing">...</section>
<section id="contact">...</section>

Kein Problem. Alle Nav-Links scrollen präzise zur richtigen Section.

Dann baust du einen Quellenbereich ein. Mit Fußnoten. Die Links sehen so aus:

<p>Laut einer Studie<sup><a href="#quelle-1">[1]</a></sup> ist das so.</p>

<!-- ... viel weiter unten ... -->
<section id="sources">
  <div class="sources-wrapper">
    <div class="sources-inner">
      <ol class="sources-list">
        <li id="quelle-1">Wichtige Studie, Autor et al., 2024</li>
        <li id="quelle-2">...</li>
      </ol>
    </div>
  </div>
</section>

Du klickst auf [1]. Die Seite scrollt. Nach ganz oben. Nicht zu #quelle-1.

Du klickst nochmal. Wieder nach oben. Dein Smooth-Scroll-Handler bricht Fußnoten-Links systematisch.


Die Diagnose

Der Handler sieht in etwa so aus:

document.querySelectorAll('a[href^="#"]').forEach(anchor => {
  anchor.addEventListener('click', function(e) {
    e.preventDefault();
    const targetId = this.getAttribute('href').substring(1);
    const target = document.getElementById(targetId);

    if (target) {
      const headerHeight = 80;
      const targetOffset = target.offsetTop - headerHeight;

      window.scrollTo({
        top: targetOffset,
        behavior: 'smooth'
      });
    }
  });
});

Debugging-Schritt: Was gibt target.offsetTop für verschiedene Elemente zurück?

// In der Browser-Konsole:
document.getElementById('features').offsetTop
// → 1240   ✅ korrekt, Section liegt ~1240px vom Top

document.getElementById('quelle-1').offsetTop
// → 12     ❌ Zwölf. Nicht 4800 wie erwartet.

Warum zwölf? Das <li> Element sitzt 12px unter seinem offsetParent – und offsetParent ist in diesem Fall ein innerer Container-Div, nicht <body>.


Die Ursache

offsetTop ist dokumentiert als:

“The distance of the outer border of the current element relative to the inner border of the top of the offsetParent node.”

Der entscheidende Teil: relativ zum offsetParent, nicht relativ zum Dokument.

Und offsetParent ist das nächste Ancestor-Element mit einer position die nicht static ist (relative, absolute, fixed, sticky). Falls keines existiert, ist es <body>.

Für deine Top-Level <section> Elemente:

<body>                          ← offsetParent (kein positioned ancestor)
  <header>...</header>
  <main>
    <section id="features">    ← offsetTop = Abstand zu body = korrekt

Für dein <li>:

<body>
  <section id="sources">
    <div class="sources-wrapper">       ← position: relative  ← HIER ist offsetParent!
      <div class="sources-inner">
        <ol class="sources-list">
          <li id="quelle-1">            ← offsetTop = 12px (Abstand zu .sources-wrapper)

Das <li> gibt dir 12 zurück. Du scrollst zu 12 - 80 = -68, also zu 0. Die Seite springt nach ganz oben.

Kein Fehler. Kein Warning. Nur falsches Ergebnis.


Warum das schwer zu bemerken ist

Das Problem manifestiert sich erst wenn du verschachtelte Anker-Ziele hast. Typische Szenarien:

  • Fußnoten (<li id="fn-1"> in einer <ol>)
  • FAQ-Einträge (<div id="faq-3"> in einem Akkordeon-Container)
  • Glossar-Einträge (<dt id="term-api"> in einem <dl> in einem gestylten Wrapper)
  • Tabellen-Zeilen (<tr id="row-5"> in einer <table>)

Direkte Kinder von <main> oder <body> ohne position-Regeln im Ancestor-Tree? Funktioniert zufällig. Alles andere: Russisches Roulette.


Die Lösung

getBoundingClientRect() gibt die Position relativ zum Viewport zurück – unabhängig von offsetParent, unabhängig von Verschachtelungstiefe.

const rect = element.getBoundingClientRect();
// rect.top = Abstand zwischen Viewport-Oberkante und Element-Oberkante
// Wenn du schon nach unten gescrollt hast: kann negativ sein

Um die absolute Dokument-Position zu bekommen:

rect.top + window.pageYOffset

window.pageYOffset ist der aktuelle Scroll-Offset des Dokuments. Viewport-Position plus Scroll-Offset = absolute Position im Dokument.

Der vollständige Fix:

document.querySelectorAll('a[href^="#"]').forEach(anchor => {
  anchor.addEventListener('click', function(e) {
    e.preventDefault();
    const targetId = this.getAttribute('href').substring(1);
    const target = document.getElementById(targetId);

    if (target) {
      const headerHeight = 80;

      // getBoundingClientRect().top = Position relativ zum Viewport
      // + window.pageYOffset = aktueller Scroll-Offset
      // = absolute Dokument-Position, funktioniert für alle Elemente
      const targetOffset = target.getBoundingClientRect().top + window.pageYOffset - headerHeight;

      window.scrollTo({
        top: targetOffset,
        behavior: 'smooth'
      });
    }
  });
});

Verifizierung in der Konsole:

const el = document.getElementById('quelle-1');

// Alt (falsch für verschachtelte Elemente):
el.offsetTop
// → 12

// Neu (korrekt für alle Elemente):
el.getBoundingClientRect().top + window.pageYOffset
// → 4823   ✅

Bonusfalle: scrollIntoView()

Falls du element.scrollIntoView({ behavior: 'smooth' }) nutzt, hast du das offsetTop-Problem nicht – aber einen anderen: kein Header-Offset. Die Seite scrollt korrekt zum Element, aber der sticky Header verdeckt es.

Wenn du Header-Offset brauchst, musst du trotzdem manuell rechnen:

// scrollIntoView mit Header-Offset:
const offset = element.getBoundingClientRect().top + window.pageYOffset - 80;
window.scrollTo({ top: offset, behavior: 'smooth' });

scrollIntoView bietet (noch) keine Option für Offset.


Wann ist offsetTop trotzdem sinnvoll?

offsetTop ist nützlich wenn du explizit den Abstand zu einem bestimmten Ancestor brauchst – zum Beispiel für relative Positionierungen innerhalb eines Containers, sticky-within-Container Logik, oder benutzerdefinierte Scroll-Container (die nicht <window> sind).

Für Dokument-weites Smooth-Scrolling ist es die falsche Wahl.


Lessons Learned

  1. offsetTop ist relativ, nicht absolut – es hängt vom offsetParent ab, nicht vom Dokument
  2. offsetParent ist das nächste positioned Ancestor-Elementposition: relative in einem Wrapper reicht um offsetTop zu verfälschen
  3. getBoundingClientRect().top + window.pageYOffset ist die robuste Alternative – funktioniert für alle Elemente unabhängig von CSS-Kontext
  4. Das Problem tritt nur bei bestimmten Elementen auf – Nav-Links zu Top-Level-Sections können zufällig korrekt funktionieren und das Problem verschleiern

Referenzen