Zum Artikel springen

Bewegung Veröffentlicht Lesezeit 8 Min.

Smooth Scroll macht deine Ankerlinks nicht kaputt — preventDefault schon

Ein Fragment-Klick sind fünf Browser-Verhalten in einem Trenchcoat, und nur eines davon ist Scrollen. Gemessen in Chromium 151 gegen ein gepinntes Lenis: die Library lässt in ihren Standardeinstellungen alle fünf intakt, und das Snippet, zu dem alle greifen, zerstört vier davon in einer einzigen Zeile.

Kurz gesagt

Smooth Scroll hält die Seite selten auf — es hält den Browser davon ab, die vier anderen Dinge zu tun, die zu einer Fragment-Navigation gehören. Der Algorithmus scroll to the fragment im HTML Standard setzt das Zielelement des Dokuments, das :target liest, macht verborgene Vorfahren sichtbar, scrollt das Element unter Beachtung von scroll-margin in den sichtbaren Bereich, führt die Fokussierungsschritte aus und verschiebt den Startpunkt der sequenziellen Fokusnavigation. Ein Aufruf von preventDefault bricht das alles auf einmal ab, und zurückgeholt wird meist nur der Scroll. Gemessen in Chromium 151 verliert das kanonische Snippet den Hash, senkt history.length von 3 auf 2, lässt :target null und schickt den nächsten Tab-Druck zurück zu dem Link, den der Leser gerade verlassen hat. pushState dazuzunehmen holt die URL und den History-Eintrag zurück und keines der beiden anderen.

Was du mitnimmst

  1. Smooth Scroll bewegt die Seite fast immer; was preventDefault abbricht, sind der Hash, der History-Eintrag, :target, scroll-margin und die Position der Tastatur — vier davon holt niemand zurück.
  2. Lenis fängt in seinen Standardeinstellungen den Klick überhaupt nicht ab — gemessen am gepinnten 1.1.20 scheitert es in genau einem Zustand, nämlich während eine eigene Animation bereits läuft.
  3. pushState holt die URL und einen Zurück-Eintrag zurück, und es feuert nie hashchange und bringt :target nie zum Greifen, sodass eine Highlight-Regel still stirbt, sobald ein Handler abfängt.
  4. scroll-margin-top ist eine Eigenschaft davon, ein Element in den sichtbaren Bereich zu scrollen, nicht davon, zu einer Koordinate zu scrollen — jeder Smoother, der eine Zahl animiert, verfehlt es um den vollen Margin, auf der Testseite um 120 px.
  5. Diese ganze Klasse von Defekten ist für Werkzeuge unsichtbar: 144 axe-Durchläufe über 72 Dokumente auf dieser Seite meldeten fünf Verstöße und hätten diesen einen nie melden können, weil axe keine Tabulatortaste drücken kann.

Chromium 151 · Lenis 1.1.20 · 2026-08-26

Gehen Ankerlinks mit Smooth Scroll wirklich kaputt?

Die Beschwerde ist leicht zu finden und fast immer als Tatsache formuliert: installiere eine Smooth-Scroll-Library und die seiteninternen Links funktionieren nicht mehr. Gemessen gegen den exakten Pin dieser Seite — Lenis 1.1.20, Byte für Byte mitgeliefert — in Chromium 151.0.7922.34 ist das als allgemeine Aussage falsch, und die Form des Irrtums ist der nützliche Teil. Mit der Option anchors auf ihrem Standardwert false und der Seite in Ruhe tut ein Klick auf einen seiteninternen Link alles, was ein Klick tun soll.

Die URL bekommt den Hash, :target greift, die Seite kommt bei 1.336 px zur Ruhe, das deklarierte scroll-margin-top des Ziels wird beachtet, und ein Tab-Druck danach führt innerhalb des Abschnitts weiter, statt zum Link zurückzuspringen. Nichts ist kaputt, weil nichts abgefangen wurde — der Anchor-Handler der Library enthält überhaupt kein preventDefault, und die Fragment-Navigation des Browsers läuft darunter weiter.

Es gibt genau einen Zustand, in dem es scheitert, und ein Leser erreicht ihn in etwa einer Sekunde. Während eine Lenis-Animation bereits läuft, trifft der Klick mitten in der Kurve ein: auf der Testseite hatte das Mausrad ein Ziel von 2.500 px gesetzt, der Klick landete bei 1.052 px mit isScrolling im Zustand smooth, der Browser führte seinen Sprung auf 1.336 aus, und das nächste Animationsframe überschrieb ihn. Die Seite kam bei 2.500 zur Ruhe, mit dem korrekten Hash in der Adresszeile — eine URL, die über die Position des Lesers um 1.164 px lügt.

Ein seiteninterner Link, vier Konfigurationen der gepinnten Library, dazu der Browser allein. Synthetische Seite, 1.400 px Füllmaterial, Ziel mit scroll-margin-top von 120px, Viewport 1200 mal 800, Klicks per Skript ausgelöst, damit nichts anderes die Seite bewegt.
Konfiguration URL nach dem Klick Kommt zur Ruhe bei (px)
keine Library, nichts abgefangen Hash gesetzt, :target greift 1.336
anchors false, Klick in Ruhe Hash gesetzt, :target greift 1.336
anchors false, Klick mitten in der Animation Hash gesetzt, :target greift 2.500
anchors true, Klick in Ruhe Hash gesetzt, :target greift 1.456
anchors true, Klick mitten in der Animation Hash gesetzt, :target greift 1.456

Der Mechanismus ist eine einzige Zeile. Lenis gleicht seine interne Position mit dem echten Scroll-Offset des Dokuments ab, aber nur innerhalb eines Guards, der verlangt, dass isScrolling false oder der String native ist — lenis.mjs:640 in der mitgelieferten Kopie. Mitten in der Animation wird der Fragment-Sprung des Browsers als Rauschen gelesen und verworfen, und das Frame danach schreibt die eigene Zahl der Library zurück. Issue #150 im Tracker des Projekts beschreibt das Symptom von außen, in den Worten des Melders: die Seite navigiert erst dann zur gewünschten Stelle, wenn die Scrollbewegung aufhört.

Zeile vier und fünf sind die Heilung, die ihren eigenen Defekt mitbringt. anchors auf true zu setzen behebt das Timing und setzt den Leser 120 px tiefer ab als der Browser, weil dieser Pfad ein Element auf seine Bounding-Rect-Oberkante plus den animierten Scroll auflöst und scroll-margin-top nie liest. Ein Bug gegen einen anderen getauscht, und genau darum geht es in diesem ganzen Artikel.

Wie sich die Animation anfühlt, auf der diese Zeilen reiten — Settle-Frames, Spitzengeschwindigkeit und was sie im Leerlauf kostet — ist gemessen in Lenis gegen natives Scrollen, gemessen.

HTML Standard §7.4.6.4, read 2026-08-26

Was macht der Browser, wenn du auf einen Hash klickst?

Fünf Dinge, und nur eines davon ist Scrollen. Der Algorithmus scroll to the fragment im HTML Standard ist kurz genug, um ihn in einer Minute zu lesen, und er ist die Spezifikation, gegen die jeder selbst geschriebene Handler antritt — meist ohne sie gelesen zu haben.

Der Schritt, mit dem niemand rechnet, ist der letzte. Gemessen an der Kontrollzeile lässt ein nativer Fragment-Klick document.activeElement auf dem body-Element stehen — der Fokus wandert nicht auf ein nicht fokussierbares Ziel — und trotzdem landet der nächste Tab-Druck auf einem Link innerhalb des Abschnitts statt hinter dem geklickten Link. Der Browser hat den Startpunkt der sequenziellen Fokusnavigation verschoben, ein eigener Zustand, den keine DOM-Eigenschaft nach außen gibt.

  1. Das Zielelement des Dokuments setzen Das ist der Zustand, den die Pseudoklasse :target liest. Er wird hier gesetzt und nirgends sonst — nicht von der URL, nicht von pushState, nicht davon, location.hash per Skript zu setzen.
  2. Den ancestor revealing algorithm ausführen Ein geschlossenes details-Element um das Ziel herum öffnen, mit hidden-until-found markierte Inhalte sichtbar machen. Ein Koordinaten-Scroll zu einem Element in einem eingeklappten Bereich kommt bei einem Ziel an, das immer noch nicht sichtbar ist.
  3. Das Ziel in den sichtbaren Bereich scrollen Mit behavior auto, block start, inline nearest — und unter Beachtung der Scroll-Snap-Area des Elements statt seiner Border-Box, und genau da kommt scroll-margin ins Spiel. Das ist der einzige Schritt, den überhaupt jemand nachbaut.
  4. Die Fokussierungsschritte ausführen Mit dem Viewport des Dokuments als Ersatzziel. Bei einem einfachen section-Element ist das Ersatzziel genau das, was passiert, und deshalb steht activeElement danach auf dem body.
  5. Den Startpunkt der sequenziellen Fokusnavigation verschieben Das entscheidet, wohin der nächste Tab-Druck geht. Es ist unsichtbar für activeElement, unsichtbar für Fokus-Events, und es ist der Grund, warum ein nativer Anker schon ohne tabindex am Ziel barrierefrei ist.
  6. Und, außerhalb des Algorithmus, die Navigation festhalten Der Fragment-Klick ist eine Navigation im selben Dokument: die URL ändert sich und ein Eintrag in der Session-History entsteht, sodass Zurück zum vorherigen Hash führt statt zur vorherigen Seite.
Der Algorithmus, dem Inhalt nach wörtlich, und was ein abfangender Handler mit jedem Schritt anstellt. Die letzten beiden baut kein Snippet nach.

Hidde de Vries hat dieselbe Abweichung 2017 gemessen und klar benannt: der Fokus wurde auf body verschoben, nicht auf das Ziel, und der Browser merkt sich trotzdem die Position des verlinkten Elements, sodass die weitere Tastaturnavigation von dort aus fortfährt. Die Naht ist bis heute nicht geschlossen — im HTML-Repository liegen drei offene Threads zu Fragment-Navigation und Fokusreihenfolge.

Diese Lücke zwischen dem, was activeElement sagt, und dem, was Tab tut, ist der Weg, auf dem gute Absichten zur Regression werden. Jemand schreibt einen Test, liest activeElement nach einem nativen Ankerklick, sieht das body-Element, schließt daraus, der Browser sei kaputt, und repariert ihn zu etwas, das wirklich schlechter ist. Der nächste Abschnitt misst, wie viel schlechter.

4 handlers · 1200×800 · one Tab press

Was kostet preventDefault genau?

Vier Handler wurden auf eine Seite gesetzt, jeder an einen identischen Link auf ein identisches Ziel gebunden, und jeder wurde an sechs Messwerten geprüft: wo die Seite endete, was die URL sagte, ob ein History-Eintrag entstand, ob :target griff, was document.activeElement enthielt, und wohin ein einzelner Tab-Druck als Nächstes führte. Die letzte Spalte ist die, die in den Blogbeiträgen nie auftaucht.

Zeile A ist der Browser ohne jedes Abfangen. Zeile B ist das Snippet, das jede Suche als Erstes zurückgibt. Zeile C ist die korrigierte Fassung, die man ausliefert, nachdem jemand einen Bug über die URL gemeldet hat. Zeile D ist die Form, die dieses Repository verwendet, in 131 Zeilen src/runtime/scroll/anchors.js.

Chromium 151.0.7922.34, 2026-08-26. Das Ziel ist ein Abschnitt mit scroll-margin-top von 120px, dessen Border-Box-Oberkante bei 1.456 liegt. Der als innerhalb markierte Link steht im Zielabschnitt; der als danach markierte Link ist das nächste fokussierbare Element in Quelltextreihenfolge nach dem geklickten.
Handler URL, History, :target Nächster Tab landet auf Landet bei (px)
A — Browser-Standard, nichts abgefangen Hash gesetzt, length 3, :target greift dem Link im Abschnitt 1.336
B — preventDefault plus scrollIntoView, behavior smooth kein Hash, length 2, :target null dem Link nach dem geklickten 1.336
C — preventDefault plus pushState plus scrollTo Hash gesetzt, length 3, :target null dem Link nach dem geklickten 1.456
D — C plus focus mit preventScroll und einem temporären tabindex Hash gesetzt, length 3, :target null dem Link im Abschnitt 1.456

Nur Zeile D bildet den Browser nach, und sie braucht dafür einen expliziten focus-Aufruf, weil sich der Startpunkt der sequenziellen Fokusnavigation nicht per Skript setzen lässt. Echten Fokus auf das Ziel zu verschieben ist der nächstbeste Ersatz: er setzt den Leser in den Abschnitt hinein, und genau das nennt der Modulkommentar dieses Repositorys als den Teil, den jede Smooth-Scroll-Umsetzung falsch macht.

Beachte, was Zeile D immer noch nicht zurückholt. :target ist in jeder abgefangenen Zeile null, auch in der guten, und die Seite landet 120 px tiefer als der Browser es täte. Beides sind dauerhafte Kosten des Abfangens, und ehrlich ist es, sie zu benennen, statt die Zeile als sauberen Sieg lesen zu lassen.

src/runtime/scroll/anchors.js:70-96

Den Hash zurückzuschreiben holt die URL zurück und sonst nichts

pushState statt replaceState, denn Zurück soll zum vorherigen Hash führen und nicht zur vorherigen Seite, und so verhält sich ein echter Anker. Dieses Modul schreibt es zuerst, vor dem Scroll und vor dem Fokuswechsel, und die Reihenfolge ist Fehlertoleranz und nicht Geschmack: die URL ist das, was ein Leser kopiert und ein Crawler verfolgt, also wird sie gesetzt, bevor irgendetwas läuft, das werfen kann.

Die zwei Dinge, die pushState nicht tut, sind beide gemessen. Es feuert hashchange null Mal — die Spezifikation hält ausdrücklich fest, dass die URL and history update steps weder eine Navigation noch eine History-Traversierung sind — also hört jeder Listener, der über hashchange auf Bewegung in der Seite reagiert, an dem Tag auf zu feuern, an dem ein Handler zu abfangen beginnt. Und es lässt :target null, in Chromium 151 wie in WebKit 26.5, weil das Zielelement des Dokuments vom Fragment-Algorithmus gesetzt wird und von nichts sonst.

src/runtime/scroll/anchors.js:70-96, mit ausgeschriebener Pfeilfunktion. Erst die History, dann die Bewegung, dann der Fokus — und dieselben drei, ohne Animation, für eine Traversierung.
event.preventDefault()
 
// History ZUERST, damit die URL stimmt, selbst wenn unten etwas wirft, und
// damit Zurück zum vorherigen Hash führt und nicht zur vorherigen Seite.
if (view.location.hash !== `#${id}`) {
  view.history.pushState(null, '', `#${id}`)
}
 
// Unter `reduce` ist das ein Sprung ohne Interpolation — dieselbe Reise,
// nur ohne den Weg.
scroll.scrollTo(target, { immediate: env.prefersReducedMotion })
moveFocusTo(target)
 
// Zurück und Vorwärts zwischen Hashes müssen die Seite ebenfalls bewegen:
// das pushState oben heißt, der Browser scrollt nicht für uns.
function onPopState() {
  const id = decodeURIComponent(view.location.hash.slice(1))
  if (!id) return
  const target = doc.getElementById(id)
  if (!target) return
  scroll.scrollTo(target, { immediate: true })
  moveFocusTo(target)
}

Der popstate-Handler existiert, weil pushState dem Browser die Traversierung weggenommen hat, und er bewegt absichtlich sofort statt animiert: ein Zurück ist keine Reise, sondern eine Korrektur, und es zu animieren zwingt den Leser, tausend Pixel Landschaft anzusehen, um einen Fehlgriff rückgängig zu machen. Die Sprache der Spezifikation ist hier in beide Richtungen erlaubend — ein User Agent sollte versuchen, die Scroll-Position eines Eintrags wiederherzustellen, und sollte ohne Wiederherstellung zurückkehren, sobald der Nutzer das Dokument selbst gescrollt hat — und genau deshalb ist es vertretbar, den Handler zu schreiben, statt ihn für überflüssig zu halten.

Ein engerer Fall gehört getrennt betrachtet. Ein Scroll-Spy, der den Hash umschreibt, während der Leser an jeder Überschrift vorbeikommt, sollte replaceState verwenden und nicht pushState: er verfolgt eine Position, statt ein Ziel festzuhalten, und den Zurück-Button mit zwanzig Einträgen zu füllen, die niemand gewählt hat, ist ein eigener Defekt.

src/runtime/scroll/anchors.js:104-131

Wie verschiebst du den Fokus, ohne gegen die eigene Animation zu kämpfen?

Drei Details, und jedes existiert wegen eines gemessenen Fehlers. Der tabindex minus eins wird nur gesetzt, wenn das Element nicht ohnehin Fokus annehmen kann, damit ein Link oder ein Button als Ziel kein Attribut bekommt, das es nicht braucht. Der focus-Aufruf übergibt preventScroll, denn ohne das springt der Browser sofort zum Element und die eben gestartete Animation hat nichts mehr zu animieren. Und das Attribut wird bei blur wieder entfernt, damit im DOM kein Zustand liegen bleibt, den die Runtime geschrieben und nie aufgeräumt hat.

Das Letzte ist eine Regel im Barrierefreiheits-Vertrag dieses Repositorys, K-8, und sie ist eine Regel wegen eines Defekts auf der alten Seite: der tabindex minus eins am Menü-Panel wurde beim Öffnen gesetzt und nie zurückgenommen. Es gibt ein externes Argument für dieselbe Form — ein dauerhafter tabindex minus eins am main-Element wurde als Ursache dafür gemeldet, dass der Zurück-Button unter iOS nicht mehr funktioniert, und das ist ein guter Grund, das Attribut nur so lange zu halten, wie der Fokus dauert.

src/runtime/scroll/anchors.js:104-131, mit der Pfeilfunktion als benannte Funktion ausgeschrieben. Siebenundzwanzig Zeilen in der Datei, elf davon die eigentliche Arbeit.
function moveFocusTo(target) {
  // Nur die Fokussierbarkeit leihen, die das Element nicht schon hat.
  const focusable =
    target.hasAttribute('tabindex') || isNativelyFocusable(target)
 
  if (!focusable) target.setAttribute('tabindex', '-1')
 
  // preventScroll ist tragend: ohne es springt der Browser sofort zum
  // Element, und die Animation dieses Moduls hat nichts mehr zu animieren.
  target.focus({ preventScroll: true })
 
  // K-8: was die Runtime schreibt, entfernt die Runtime auch.
  if (!focusable) {
    target.addEventListener('blur', function drop() {
      target.removeAttribute('tabindex')
    }, { once: true })
  }
}

Ob der verschobene Fokus einen Ring zeichnet, hängt am Ende davon ab, wie der Link ausgelöst wurde, und die Engines sind sich uneins. In Chromium 151 lässt ein Mausklick das Ziel fokussiert zurück, mit :focus-visible false und ohne Outline, und Tab gefolgt von Enter lässt es fokussiert zurück, mit :focus-visible true und gezeichneter Outline — das ist genau das gewünschte Verhalten, erreicht ohne eine einzige Regel dafür. Headless WebKit 26.5 zeichnete den Ring auch auf dem Mauspfad; diese Abweichung ist gegen das ausgelieferte Safari nicht verifiziert und Firefox wurde gar nicht gemessen, also gilt die Chromium-Zeile als gemessen und der Rest als offen.

Der Vertrag, gegen den dieses Modul geschrieben ist, hat auch dafür eine Regel. K-7: Fokus, den die Runtime verschiebt, muss durch das Lesen von document.activeElement nach einem echten Tastendruck belegbar sein, nie dadurch, dass focus aufgerufen wurde. Die alte Seite hat diesen Satz mit einer Wiederholungsschleife über dreißig Frames bezahlt, die nur existierte, weil focus stillschweigend nichts tut, wenn ein Element gerade durch visibility hidden hindurchgeht — eine halbe Sekunde Polling, um einen Aufruf zu umgehen, der Erfolg meldete und nichts tat.

3 declarations · 120 px · 2026-08-26

Warum landet der Abschnitt immer noch unter dem festen Header?

Weil scroll-margin eine Eigenschaft davon ist, ein Element in den sichtbaren Bereich zu scrollen, und ein Koordinaten-Scroll bekommt kein Element. Die Spezifikation CSS Scroll Snap verlangt von User Agents, die Scroll-Snap-Area des Elements heranzuziehen statt nur seiner Border-Box, wenn sie entscheiden, was sichtbar werden soll — auch bei ausgeschaltetem Snapping, was die Fragment-Navigation des Browsers und scrollIntoView einschließt. Wer window.scrollTo eine Zahl übergibt, gibt dem Browser nichts, worauf er einen Margin anwenden könnte.

Die Lücke ist exakt der deklarierte Margin, und sie ist in beiden gemessenen Engines dieselbe.

  • 1.336 Deep Link und scrollIntoView beide beachten die Scroll-Snap-Area
  • 1.456 scrollTo mit einer Koordinate eine Zahl hat keinen Margin zu lesen
  • 120 die Lücke, in Pixeln exakt der deklarierte Wert, jedes Mal
  • 3 Deklarationen in diesem Repository und der Klickpfad liest keine davon
Ein Element mit scroll-margin-top von 120px, Border-Box-Oberkante bei 1.456. Chromium 151, 2026-08-26; WebKit 26.5 stimmt auf ein Pixel genau überein.

Das ist der Abschnitt, in dem der Artikel einen Defekt im eigenen Haus benennt. Alle drei Deklarationen hier stehen in Abschnitts-Stylesheets — den Rechtstexten und den Fallstudienseiten — und der Kommentar neben einer davon sagt warum: an den Abschnitten deklariert statt in der Scroll-Runtime, damit es auch dann hält, wenn nichts läuft. Diese Begründung ist richtig, und genau deshalb verfehlt der erweiterte Klickpfad sie. Jede dieser Deklarationen erreicht einen Deep Link, einen Reload und eine Seite mit abgeschaltetem JavaScript, und keine davon erreicht einen Klick, weil der Klick in einem Scroll auf eine aufgelöste Koordinate endet, ohne übergebenen Offset. Bestätigt durch das Lesen beider Codepfade; die Größe der Lücke auf den Live-Routen ist nicht gemessen, nur die 120 px auf der synthetischen Seite.

Das forensische Audit der alten Seite hat genau diese Form vorhergesagt und als ungetestete Falle abgelegt: ein Klick in der Seite und ein Deep Link auf dieselbe id landen an verschiedenen Stellen, weil der Klickpfad eine berechnete Header-Höhe verwendet und das CSS scroll-margin-top. Zwei Offset-Systeme, die nie zusammenkommen.

Und wenn du den Header selbst abziehst, dann miss ihn, statt ihn anzunehmen. Das Spacing-Token dieses Repositorys galt als die Höhe des Headers, bis es nachgemessen wurde: die Leiste maß 79,2 px gegen 70,1 px des Tokens bei 390 Breite, und 68,8 px gegen 59,1 px bei 768. Eine Kontaktseite, die auf dieser Annahme gebaut war, wurde unter der Leiste gerendert.

Die Fallstudienseiten sind der Ort, an dem diese Deklarationen am meisten arbeiten — acht Kapitelanker und eine Sticky-Leiste auf jedem von den zehn Builds.

src/runtime/scroll/scroll-controller.js:37-42

Was ändert reduzierte Bewegung an einem Anker?

Sie nimmt den Weg weg und behält die Reise. Unter einer reduce-Präferenz bewegt der Anker die Seite weiterhin, schreibt weiterhin den Hash, verschiebt weiterhin den Fokus — er kommt nur sofort an, und die Zusage steht an vier voneinander unabhängigen Stellen, damit keine einzelne davon diejenige sein kann, die versagt.

Der Grund für die Redundanz ist, dass ein Smooth Scroll unter reduce kein kosmetischer Übergriff ist; es ist genau die Bewegung, die der Besucher nicht sehen wollte, geliefert von der einen Interaktion auf der Seite, die garantiert eine lange Strecke zurücklegt.

immediate auf dem Ankerpfad
Der Klick-Handler reicht die reduce-Präferenz direkt als immediate-Flag durch, sodass derselbe Codepfad beide Fälle bedient und es keinen zweiten Zweig gibt, den man vergessen könnte.
behavior auto, nie smooth
Der native Pfad des Controllers scrollt mit behavior auto und nie mit smooth, weil ein glatter programmatischer Scroll genau das wieder einführt, was die Präferenz entfernen wollte.
scroll-behavior auf initial
Das Dokument-Stylesheet deklariert scroll-behavior als initial statt smooth, damit es keinen zweiten, nicht abbrechbaren Animator auf demselben Offset gibt, gegen den die Runtime kämpfen müsste.
die Reset-Ebene, mit Ausrufezeichen
Der pauschale reduce-Block liegt in der Reset-Ebene, damit ihn nichts versehentlich überspezifizieren kann, und er erzwingt scroll-behavior auto mit einem important-Flag.
gar keine Library
Unter reduce baut dieses Projekt den Smooth Scroller überhaupt nicht. Es läuft nichts, was einen Anker gleiten lassen könnte, und das ist die stärkste Form, die die Zusage annehmen kann.
Vier Stellen, an denen dieselbe Zusage steht, und ein Aufruf, der bewusst nicht gemacht wird.

Die alte Seite hat dieselbe Präferenz auf einem Weg respektiert, den man sich merken sollte. Sie fragte sechshundert Animationsframes lang ab, ob eine globale Variable auftaucht, und zerstörte dann die Instanz — weil der naheliegende Aufruf, sie zu stoppen, gemessen die Seite völlig unscrollbar zurückließ: null Pixel am Mausrad, null Pixel auf der Ende-Taste. Eine Präferenz, umgesetzt als Abriss statt als Pause, weil Pausieren schlimmer war als die Bewegung.

Dieselbe Präferenz hat eine deutlich schwerere Aufgabe, wo die Bewegung ein Canvas ist und kein Scroll-Offset — dieses Argument steht in reduzierte Bewegung für WebGL, Rive und Canvas.

a05-scroll.md §12 · 689 anchors

Was solltest du dich weigern zu bauen?

Vier Absagen, jede mit einer Messung dahinter statt einer Vorliebe. Die erste kostet am meisten, wenn man sie spät entdeckt: setze nie zwei Animatoren auf denselben Scroll-Offset. Das alte abbod.de tat genau das, und die rückwärts erschlossene Spezifikation des Drittanbieter-Moduls, das es auslieferte, lohnt das Lesen gerade deshalb, weil dieses Modul kompetent war.

Es rief preventDefault und stopPropagation auf, es schrieb den Hash mit pushState, es maß den festen Header und rechnete ihn heraus, es verschob den Fokus mit preventScroll und stellte den tabindex danach wieder her. Es tat alle vier Dinge, die ein Handler dir laut diesem Artikel schuldet. Was es nicht wissen konnte, war die andere Animationsschleife, die dieselbe Scroll-Position auf derselben Seite schrieb, über 689 Hash-Anker hinweg.

ein Sprung über 200 px 731 ms
ein Sprung über 1.000 px 1.317 ms
ein Sprung über 3.000 px 1.800 ms
Wie lange die Ankeranimation der alten Seite dauerte, nach ihrer eigenen Dauerkurve. Die Formel ist ein Logarithmus der Distanz, ein kurzer Sprung ist also kaum schneller als ein langer.Rückwärts erschlossen aus dem abbod.de vor dem Rebuild; der Multiplikator, der sie hätte verkürzen können, kommt im gebauten Output null Mal vor.

Die dritte Absage betrifft Instrumente. Diese Seite veröffentlicht ihre Barrierefreiheits-Zahl, statt eine saubere zu behaupten: axe-core 4.11.0 über 72 Dokumente in zwei Breiten, 144 Durchläufe, fünf Verstöße, allesamt Farbwerte an einem einzigen Element. Keiner davon hätte ein Anker sein können, der die Seite bewegt und nicht den Fokus, und der Messbericht sagt in seiner eigenen Liste des Nichtabgedeckten warum — der Tastaturdurchgang, weil axe keine Tabulatortaste drücken kann. Ein Link, der einen Tastaturnutzer zurücklässt, besteht jeden automatischen Prüfer, den es gibt.

Die vierte betrifft die Frage, wie viel davon du überhaupt bauen solltest. Das Scroll-Subsystem ist hier als wichtig registriert und nie als unverzichtbar, und der Kopf des Anker-Moduls formuliert den Test: lösche dieses Modul und jeder Anker auf der Seite funktioniert weiter. Was du einem Fragment-Klick hinzufügst, sollte eine Verbesserung über einem Primitiv sein, das bereits funktioniert, denn das Primitiv ist das eine, das du nicht korrekter machen kannst — du kannst es nur anders anfühlen lassen und dich dabei vier Verhalten kosten.

Die fünf übrig gebliebenen Verstöße aus diesem Durchlauf, und die 707, die sich als Artefakt einer Messung mitten in der Animation herausstellten, sind aufgedröselt in axe-Kontrast-Falschmeldungen.

Barrierefreies Tastaturverhalten ist eines der vier Dinge, die dieses Studio als Teil des Builds behandelt und nicht als Audit hinterher — siehe die vier Disziplinen.

Fragen

Macht Lenis Ankerlinks kaputt?

Nicht so, wie der Satz nahelegt. Gemessen gegen das gepinnte 1.1.20 in Chromium 151, mit der Option anchors auf ihrem Standardwert und der Seite in Ruhe, funktioniert ein seiteninterner Link vollständig: der Hash wird gesetzt, :target greift, scroll-margin-top wird beachtet und Tab führt innerhalb des Abschnitts weiter. Es scheitert in genau einem Zustand, nämlich während eine eigene Animation bereits läuft, weil die Library einen nativen Scroll nur dann zurückliest, wenn sie nicht mitten in einer Animation ist. Auf der Testseite setzte das den Leser 1.164 px hinter den Abschnitt, mit dem korrekten Hash in der Adresszeile.

Brauchen meine Ankerziele einen tabindex minus eins?

Nur wenn du den Klick abfängst. Ohne Abfangen bleibt document.activeElement auf dem body-Element und ein einzelnes Tab landet trotzdem im Zielabschnitt, weil der Browser den Startpunkt der sequenziellen Fokusnavigation verschoben hat und nicht den Fokus. In dem Moment, in dem du preventDefault aufrufst, hebst du diesen Schritt auf und musst ihn explizit ersetzen. Setze das Attribut lieber beim Klick und entferne es bei blur, statt es ins Markup zu backen — ein dauerhaftes am main-Element wurde als Ursache dafür gemeldet, dass der Zurück-Button unter iOS nicht mehr funktioniert.

Ist pushState oder replaceState für einen Sprung in der Seite richtig?

pushState, wenn Zurück sich wie ein echter Anker verhalten und nicht die Seite verlassen soll. Zwei Dinge tut es nicht, beide gemessen: es feuert nie hashchange, was die Spezifikation ausdrücklich festhält, und es bringt :target nicht zum Greifen, weil das Zielelement des Dokuments allein vom Fragment-Algorithmus gesetzt wird. Nimm replaceState, wo du eine Position verfolgst statt ein Ziel festzuhalten — ein Scroll-Spy, der den Hash beim Vorbeiscrollen an Überschriften umschreibt, sollte den Zurück-Button nicht mit Einträgen füllen, die niemand gewählt hat.

Kann ich stattdessen scroll-behavior smooth im CSS verwenden?

Nicht neben einem JavaScript-Smoother. Es setzt einen zweiten, nicht abbrechbaren Animator auf denselben Scroll-Offset und die beiden kämpfen; im Tracker der Library liegt ein Bericht darüber, dass das Scrollen dadurch komplett kaputtgeht, inklusive des Falls, dass ein Hash in der URL den Smooth Scroll des Browsers beim Laden ohne jede Nutzergeste auslöst. Diese Seite deklariert scroll-behavior aus diesem Grund als initial und führt ihren Sprung unter reduzierter Bewegung über die Runtime aus, wo er abgebrochen werden kann.

Findet ein Barrierefreiheits-Audit einen kaputten Ankerfokus?

Nein. Der letzte veröffentlichte Durchgang auf dieser Seite ließ axe-core 4.11.0 über 72 Dokumente in zwei Breiten laufen — 144 Durchläufe — und meldete fünf Verstöße, jeden davon einen Farbwert. Der Bericht listet, was der Durchgang nicht erreichen konnte, und der erste Punkt ist der Tastaturdurchgang, weil axe keine Tabulatortaste drücken kann. Das einzige Instrument, das diesen Defekt findet, ist ein Blick auf document.activeElement nach einem echten Tastendruck.

Quellen

In diesem Repository gemessen

  • src/runtime/scroll/anchors.js Die ganze Antwort in 131 Zeilen: die Guard-Klauseln, das zuerst geschriebene pushState, die sofortige Bewegung unter reduce, die Fokusfunktion und der Teardown, der beide Listener entfernt.
  • src/runtime/scroll/lenis-options.js Die acht gesetzten Optionen, der anchors-Wert mit seiner Begründung und die zehn bewusst geerbten Standardwerte, die das Projekt nicht noch einmal hinschreibt.
  • src/styles/base/document.css Die Deklaration scroll-behavior initial, mit der Kollision zweier Animatoren als Begründung danebengeschrieben.
  • docs/evidence/legacy-forensics/a05-scroll.md Der Ankerbesitzer der Seite vor dem Rebuild, vollständig rückwärts erschlossen, die Zahl von 689 Ankern, die Dauer-Stichproben und die ungetestete Falle, die dieser Artikel misst.
  • contracts/published-measurements.json Der axe-core-Durchlauf: 72 Dokumente, zwei Breiten, 144 Durchläufe, fünf Verstöße, und die Liste dessen, was er nicht erreichen konnte.

Geprüft gegen

Alaa Abbod

Geschrieben von

Alaa Abbod

Creative Developer — Herne, Deutschland

Designer und Entwickler, der barrierefreie Websites, Mobile Apps, Onlineshops und visuelle Identitäten als eine Arbeit baut, von Hand. Diese Seite erscheint in Englisch, Deutsch und Arabisch aus einer Quelle, und daher kommen die meisten dieser Fragen.

Bitte dreh dein Gerät,
Diese Seite ist hochkant gebaut.