Ankerlinks lijken op het eerste gezicht bedrieglijk simpel: klik op een knop, scroll naar de kop en klaar. Maar als je ze ooit hebt moeten bouwen, ben je misschien het probleem met de actieve ankerlink tegengekomen.
Het probleem is dat koppen onderaan de pagina te laag kunnen staan om naar de gewenste positie te scrollen. In de visualisatie kan de kop van de conclusie de triggerlijn nooit bereiken. Dat is natuurlijk funest voor de gebruikerservaring. We hebben een oplossing nodig.
Let op: Conclusie kan nooit actief worden.
Introductie
Velit est mollit ex do ex et aute consequat nisi enim incididunt aliqua quis. Velit esse incididunt pariatur deserunt amet mollit reprehenderit incididunt occaecat amet nulla excepteur amet deserunt. Aute sint adipisicing incididunt cupidatat. Ipsum id velit irure reprehenderit et. Laboris ad cupidatat aliqua labore dolore adipisicing consequat ullamco quis irure ipsum incididunt. Eu ad sunt quis minim exercitation. Aliqua cillum reprehenderit eu aute. Dolor reprehenderit minim fugiat ullamco duis irure.
Het probleem
Eu incididunt consectetur incididunt eu amet ea. Occaecat ut aute non pariatur dolore ut irure sint consequat in deserunt quis ad labore. Consectetur nostrud eu sint sit proident ut ullamco cupidatat eiusmod exercitation nostrud et. Eu eu est dolor. Ad velit esse adipisicing proident velit ipsum laboris magna ad.
Middenstuk
Cupidatat sit dolore sunt culpa culpa mollit reprehenderit mollit laborum velit aliquip qui tempor enim nostrud. Dolor enim do fugiat voluptate sint. Nisi velit est id et exercitation et consequat ea proident voluptate. Aliqua id sint nostrud deserunt exercitation nisi officia labore consectetur ea. Do fugiat cupidatat fugiat adipisicing. Esse aute pariatur culpa exercitation do. Magna eiusmod sit nisi.
Conclusie
Magna minim cillum consequat dolor id labore velit minim. Mollit do aute labore ullamco duis magna dolore amet velit aliqua reprehenderit nulla aute quis. Id duis irure est. Reprehenderit et labore cillum pariatur do consequat est duis.
Voordat we de opties bekijken, maken we eerst een wat abstractere visualisatie. Hier beweegt een viewport omlaag over de pagina, met de triggerlijn op 25vh vanaf de bovenkant van de viewport. Deze visualisatie gebruiken we om de verschillende aanpakken te vergelijken.
Hotfix: extra ruimte
De eenvoudigste oplossing is extra padding toevoegen. We berekenen de hoogte van die padding door het verschil te nemen tussen de laatste kop en het laagste punt dat de ankertrigger kan bereiken.
Perfect, toch? Nou ja, soms is het designteam niet zo blij met willekeurige extra padding. Dus we zoeken nog even verder.
Praktisch: verschuif de triggerlijn
Misschien kunnen we, in plaats van extra padding toe te voegen, de triggerlijn verschuiven. Ook dat is vrij simpel: bereken hoe ver de laatste kop van de onderkant staat en zet de triggerlijn daar ook neer.
Het nadeel is dat wanneer een gebruiker op een ankerlink klikt, de kop helemaal onderaan de viewport kan belanden. De meeste mensen houden wat ze lezen in de bovenste helft van het scherm. We moeten dus verder zoeken.
Goed: verplaats de triggerpunten
In plaats van de triggerlijn te verschuiven, kunnen we de koppen omhoog verplaatsen. We gebruiken dan niet de werkelijke locaties van de koppen als triggerpunten, maar maken virtuele koppen en schuiven die omhoog. Een virtuele kop is niet zichtbaar in het artikel; het is alleen de positie waarmee we de actieve status bepalen.
Je zou kunnen zeggen dat dit vrijwel hetzelfde is als de triggerlijn verschuiven, en conceptueel klopt dat ook. Toch geeft denken in triggerpunten ons meer mentale speelruimte, omdat we per kop een andere correctie kunnen toepassen op basis van de positie.
De voorbeeldvisualisaties tonen nu de locaties van deze virtuele koppen. De echte kop blijft op dezelfde plek in het artikel staan, terwijl we laten zien waar het triggerpunt ligt.
In dit voorbeeld verschijnt een probleem: de eerste kop staat nu te ver omhoog. Het nuttige aan de aanpak met virtuele koppen is dat losse triggerpunten makkelijk aan te passen zijn. Maar wat is een goede manier om dat te doen?
Geweldig: verplaats triggerpunten fractioneel
Als we erover nadenken, hoeven we niet alle triggerpunten met dezelfde afstand te verplaatsen. Er zijn maar een paar voorwaarden:
- De koppen moeten bereikbaar zijn.
- De koppen moeten in dezelfde volgorde blijven.
We kunnen aan die voorwaarden voldoen door de triggerpunten fractioneel te verplaatsen. De eerste kop beweegt niet, de laatste kop beweegt omhoog met precies de volledige afstand die nodig is om bereikbaar te worden, en de andere koppen bewegen proportioneel omhoog op basis van hun positie tussen de eerste en laatste kop.
Nu komen we ergens. Dit is een degelijke oplossing. Misschien wil je hier stoppen voordat je productmanager je vragend aankijkt en zich afvraagt hoe het fixen van ankerlinks opeens een epic van drie weken is geworden.
Fantastisch: maak een eigen mappingfunctie
Hoewel de fractionele oplossing werkt, heeft ze ook nadelen. We kozen een triggerlijn die op 25% vanaf de bovenkant van de viewport ligt. Het zou mooi zijn om de afwijking van die ideale lijn over alle koppen te minimaliseren. Hoe dichter de triggers bij deze semi-willekeurig gekozen lijn plaatsvinden, hoe beter de gebruikerservaring waarschijnlijk is.
Laten we de mean squared error minimaliseren van het verschil tussen de oorspronkelijke posities van de koppen en hun virtuele posities. We gebruiken MSE omdat het grote afwijkingen zwaar bestraft. Daardoor duwt het systeem richting een toestand waarin de meeste virtuele koppen dicht bij hun oorspronkelijke plek blijven, terwijl we nog steeds aan de bereikbaarheidsrestricties voldoen. De restrictie dat koppen in volgorde moeten blijven geldt ook nog steeds.
Het resultaat is dat alle bereikbare punten op hun oorspronkelijke positie blijven staan. Maar nu klonteren koppen samen onderaan. Dat is logisch: mean squared error minimaliseren kijkt alleen naar nabijheid tot de oorspronkelijke positie. Er is geen kracht die samenklonteren tegengaat. We moeten iets definiëren dat virtuele triggerpunten stimuleert om een bepaalde afstand tot elkaar te houden, idealiter gerelateerd aan hun oorspronkelijke tussenruimte.
Zijspoor: minimalisatiefuncties
Om dit idee te verkennen, moeten we Python erbij pakken. De kern van de optimalisatie is een lossfunctie met twee concurrerende termen:
- Anchor penalty: hoe ver een virtuele kop van de oorspronkelijke locatie is verplaatst.
- Section penalty: hoeveel de grootte van elke virtuele sectie verschilt van de oorspronkelijke sectiegrootte.
We combineren deze tot een totale losswaarde:
De gewichten bepalen de afweging, met w_anchor + w_section = 1.
We definiëren restricties om virtuele koppen binnen de paginagrenzen te houden, te zorgen dat de eerste kop niet omhoog zweeft en de koppen in volgorde te houden.
Als we die optimalisatie draaien, krijgen we een grafiek van hoe de locaties van virtuele koppen veranderen naarmate de section penalty toeneemt. Het resultaat is een nuttige controle: de fractionele verplaatsing is precies waar de optimizer op uitkomt wanneer de section penalty dominant is.


Inzichten
Naar die optimalisatiegrafiek staren bracht een gedachte op gang. Ten eerste wordt de noodzaak om sectieafstand te behouden vooral belangrijk richting het einde van de pagina, waar koppen gedwongen omhoog worden geschoven om bereikbaar te blijven. Ten tweede kunnen we eens kijken naar het gedrag van fractionele verplaatsing in een edge case.
Stel je voor dat je de hele Bijbel, van Genesis tot Openbaring, als één doorlopende webpagina rendert. Voor de techbro's onder ons kun je ook bedenken dat je alle essays van Paul Graham achter elkaar plakt. Stel nu dat de allerlaatste kop maar 200 pixels te laag staat om bij scrollen de triggerlijn te raken.
Is fractionele verplaatsing hier logisch? Het betekent dat je die 200 pixels benodigde lift uitsmeert over elke kop, helemaal terug tot het begin. Bij fractionele verplaatsing groeit de fout mee met de paginalengte. Als de pagina naar oneindig gaat, doet de fout dat ook. Dat is slordig, en gebruikers kunnen merken dat het vreemd aanvoelt.
De definitieve versie
Dit leidt tot het gewenste gedrag voor een slimmere mappingfunctie:
- Pas meer correctie toe op koppen dicht bij het einde van de pagina.
- Pas minder correctie toe op koppen dicht bij het begin van de pagina, of idealiter helemaal geen.
- De overgang tussen deze toestanden moet vloeiend zijn.
We hebben een functie nodig die de genormaliseerde positie x van een kop in [0, 1] omzet naar een correctiefactor y in [0, 1]. Deze factor bepaalt hoeveel van de maximaal benodigde lift wordt toegepast op de kop op positie x.
De mappingfunctie moet vier eigenschappen hebben:
- Ze moet bij nul beginnen:
f(0) = 0. - Ze moet bij één eindigen:
f(1) = 1. - De overgang moet rustig beginnen:
f'(0) = 0. - De overgang moet rustig eindigen:
f'(1) = 0.
Het blijkt dat we een functie uit computergraphics kunnen lenen: smoothstep. Smoothstep is een kubisch polynoom dat vloeiend van 0 naar 1 overgaat binnen het bereik [0, 1].
Maar wat als we niet willen dat de overgang meteen begint? Wat als de correctiefactor 0 moet blijven totdat x een bepaald punt bereikt, bijvoorbeeld a, en daarna vloeiend naar 1 moet bewegen tegen de tijd dat x 1 bereikt?
We kunnen x voorbewerken voordat we die aan de smoothstep-functie geven:
Daarna passen we de smoothstep-functie toe op deze begrensde en geschaalde input:
Zo kunnen we met a de genormaliseerde positie bepalen waarop de vloeiende opwaartse correctie begint. a = 0 geeft de oorspronkelijke smoothstep over het hele bereik. a = 0.5 betekent dat koppen in de eerste helft van de pagina helemaal niet bewegen, terwijl de correctie pas in de tweede helft oploopt.
Laten we a = 0.4 kiezen en kijken wat deze aangepaste smoothstep doet.
Prachtig.
Validatie
Zo, we zijn eindelijk klaar. We zijn onnodig diep gegaan om ankerlinks te fixen. Een waarlijk Carmackiaanse prestatie die nog generaties lang herinnerd zal worden. Laten we de lead designer vragen wat hij ervan vindt.

Wil je overengineerde ankerlinks voor je project? Neem contact op.
