UIDex

Scrollspy

متتبّع التمرير

IntersectionObserver

also calledon this page، in-page navigation، active section nav، page outline، anchor nav، jump links، سكرول سباي، قائمة «في هذه الصفحة»، فهرس جانبي متتبّع، محتويات الصفحة، مؤشّر القسم الحالي، روابط داخل الصفحة

A scrollspy is the list of headings for one document, where the link matching the section on screen is the only one marked, and that mark moves to the next link as the page scrolls. Two pieces make it up: anchors pointing at ids inside the same page, and a watcher that decides which one is current right now. It is not a breadcrumb trail, not a table of contents, and not a reading progress bar. Breadcrumbs describe where the page sits in the site tree and hold still for as long as you stay on it, while a scrollspy says nothing about the site and reports only your position inside one document. A table of contents is this same list with the tracking taken out, so its links jump and nothing knows where you got to. A reading progress bar gives a continuous fraction of the document and no target to click, where a scrollspy names one section you can travel to. Scroll without clicking anything: if the list holds still, what you have is a table of contents.

If you called it…

"the side list that lights up as i scroll""the on this page menu in docs""links that jump to a part of the same page""the outline that follows where i am reading""a thing that shows my place in a long page"

Live specimen

Interact with the demo. Every part is real and numbered.

التثبيت

ثبّت الحزمة من مدير الحزم الذي تستعمله، ثم استوردها في نقطة الدخول.

ملفّ الإعداد

كل الخيارات لها قيم افتراضية، فابدأ بملفّ فارغ وأضف ما تحتاجه فقط.

السمات

السمة الفاتحة هي الافتراضية، والداكنة تتبع إعداد النظام.

أسئلة متكرّرة

سؤالان يتكرّران: لماذا لا يظهر العنوان؟ ولماذا يتأخّر التعليم؟

في هذه الصفحة
RTLالقائمة عند النهاية المنطقية، وخطّ التعليم بـ border-inline-start

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build a scrollspy for a long documentation page. Give every h2 and h3 a stable id derived from its text and keep it, then build a link list inside a nav named with aria-labelledby pointing at the visible "On this page" heading, in document order, with every href pointing at a heading id so navigation still works with JavaScript off. Decide the active section with a single IntersectionObserver watching all the headings, with no scroll listener and no getBoundingClientRect read inside the scroll path. Set rootMargin to a band near the top of the reading area, something like "-88px 0px -65% 0px", so the mark moves when a heading reaches the reading zone instead of when it touches the bottom of the viewport, and handle a short final section by marking it once the document end is reached. Put aria-current="true" on exactly one link and clear it from the previous link in the same update, and never move focus to the new link. Give the headings a scroll-margin-block-start equal to the sticky header height, and switch smooth scrolling to an instant jump when prefers-reduced-motion is set. Pin the column with position: sticky and inset-block-start, give it a max-block-size with overflow-y: auto, and check that no ancestor has overflow set to hidden, auto, or scroll. Use logical properties throughout: place the column at the logical end of the article with grid tracks, draw the indicator with border-inline-start painted transparent on every link, clear the list padding with padding-inline-start: 0 before indenting nested levels with padding-inline-start, and never write left, right, or border-left. Disconnect the observer when the component unmounts.

How it behaves right-to-left

This section is ours alone.

Which side the column sits on

mirrors

The list belongs at the logical end of the article: the right side in English, the left side in Arabic. Build the layout with grid-template-columns: 1fr 15rem, or with a flex row, since grid column tracks run along the inline axis and reverse with direction on their own. Do not pin the column with position: absolute plus right: 0, and do not float: right, because both stay on the right after the flip and sit on top of the Arabic text.

The active indicator bar

mirrors

The thin bar marking the active link stands on the edge that faces the text: the left of the link in English, its right in Arabic. Write it as border-inline-start: 2px solid rather than border-left, draw it transparent on every link and colour only the active one, or the text shifts two pixels each time the section changes. Pair it with padding-inline-start so the gap between the bar and the word stays on the correct side.

Indenting nested levels

mirrors

An h3 link is indented under the h2 link above it, and that indent runs along the inline axis, so it belongs on padding-inline-start of the item or margin-inline-start of the nested list. The trap is the reset rather than the indent: the browser stylesheet already gives ol and ul a padding-inline-start of 40px, so a line reading padding-left: 0 removes none of it in Arabic and leaves a 40px gap on the right. Zero it with padding-inline-start: 0 and add your own indent back, and if you draw a guide line down the nested list, put it on border-inline-start.

The observer does not mirror

never mirrors

rootMargin takes four physical values in top, right, bottom, left order; it accepts no logical keyword and does not change with dir. That costs a vertical scrollspy nothing, because only the top and bottom values do any work, so set the two side values to zero and leave them. The same holds for boundingClientRect on each IntersectionObserverEntry: its left and right are physical viewport coordinates, so when you sort sections to pick the highest one, sort on top, the one value direction never touches.

The reading progress bar

mirrors

A progress bar above the list fills from the logical start, which in Arabic is the right. The number itself has nothing to do with direction, since it comes from scrollTop on the vertical axis, and only the painting flips. transform-origin accepts no logical keyword at all, so either animate inline-size from 0 to 100% on an element anchored with inset-inline-start: 0, or hand-write transform-origin: right under [dir="rtl"].

In code

Each row is one framework's word for the same thing. Take the row your project speaks.

HTML<a href="#install"> + <h2 id="install">The pair that makes navigation work before a line of JavaScript loads.
ARIAaria-current="true"Use true or location for a section inside the page; page would claim the link leads to the page you are already on.
DOMnew IntersectionObserver(cb, { rootMargin })Calls your function only on a crossing, which takes the work off the scroll path entirely.
CSSscroll-margin-block-start: 5remStops the sticky header swallowing the heading after a jump; the logical form of scroll-margin-top.
CSSposition: sticky + inset-block-startKeeps the column in view, and goes completely quiet inside any ancestor whose overflow is hidden, auto, or scroll.
CSSborder-inline-startThe property that moves the indicator bar to the correct side when the page flips.
Bootstrapdata-bs-spy="scroll"Where the name comes from; the current version runs on IntersectionObserver and exposes rootMargin as an option.

See also

Updated · 2026-08-19