Progress bar & section dock
Long pages need to tell you where you are, and this is the pattern that does it — on every report, case study and documentation site you have read this year. Three parts, each with one thing worth knowing. The progress bar is written on every scroll frame, so it uses quickSetter rather than a tween. The active-section state comes from one ScrollTrigger per section with a band anchored at 30%, not from measuring offsets by hand. And the anchor links are the part everyone gets wrong: if you have smooth scroll running, a native jump and Lenis both try to own the scroll position and the page visibly lurches. Anchor links must go through lenis.scrollTo(), with a native fallback for when it is not there.
How to build
Panel + script
Free plan gets you part of the way
The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.
How the two routes work →Plugins
ScrollTrigger · quickSetter · quickTo · Lenis
Build time
40 min. The dock indicator is the fiddly part.
No-code?
Partly. Webflow's panel can scrub a progress bar, and its native anchor links work. Neither knows about Lenis, so on a smooth-scrolled site you need the script for the jump behaviour regardless.
Webflow structure
element · class · attribute<b>Div</b> <i>progress_bar</i> ← position: sticky, top 0, z-index 30, 2px tall <b>Div</b> <i>progress_fill</i> ← scaleX(0), origin left <b>Div</b> <i>article_layout</i> ← grid 1.55fr 0.45fr, align-items: start <b>Div</b> <i>article</i> <b>Section</b> <i>article_section</i> <u>id="summary"</u> <b>Heading H2</b> <b>Rich Text</b> <b>Section</b> <i>article_section</i> <u>id="demand"</u> … <b>Div</b> <i>toc</i> ← position: sticky, top 14vh <b>Text</b> <i>toc_heading</i> <b>Div</b> <i>toc_list</i> ← position: relative, border-left 1px <b>Div</b> <i>toc_indicator</i> ← absolute, left -1px, 2px wide <b>Link</b> <i>toc_link</i> <u>data-target="summary"</u> <b>Link</b> <i>toc_link</i> <u>data-target="demand"</u> … <b>Text</b> <i>toc_percent</i>
Styles to set in the Designer
progress_bar | <code>position: sticky; top: 0; z-index: 30</code>. Sticky rather than fixed keeps it inside the article, so it cannot float over the footer. progress_fill | <code>transform: scaleX(0); transform-origin: left</code>. Driven by <code>quickSetter</code> — no tween, no easing, exactly one property write per frame. toc | <code>position: sticky; top: 14vh</code>, and the grid needs <code>align-items: start</code> or the column stretches full height and never sticks. toc_list | <code>position: relative</code> with a 1px left border. The indicator is positioned against this, so its <code>offsetTop</code> maths stays simple. toc_indicator | <code>position: absolute; left: -1px; width: 2px;</code> — it sits <em>on</em> the border line. Both its <code>y</code> and its <code>height</code> are animated, so it adapts to two-line entries. toc (mobile) | <code>display: none</code> under 900px. A table of contents on a phone is a wall of links between the reader and the article.
Building it with the Interactions panel
no code, free plan- Select progress_fill → Interactions → trigger Scroll into view → While scrolling in view, scoped to the whole article. Animate Scale X 0% → 100%, Ease None.
- For each toc_link, a native Webflow anchor link (href="#id") jumps to the matching section with no extra setup.
- For the active-section highlight: select each article_section → Scroll into view, Start at 30% from the top of the viewport → Add Class on the matching toc_link, Remove Class on leave.
What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
- None of this knows about Lenis. A native anchor jump and a running Lenis instance both try to own the scroll position at once, so on any site with smooth scroll enabled, panel-built anchor links will visibly lurch — routing the click through lenis.scrollTo() is script-only.
- The indicator that resizes to match a two-line TOC entry — animating both y and height together, off a measured offsetTop — isn't something the panel can compute; it only knows fixed From/To values, not runtime element measurements.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesarticle_section | id | summary | The anchor target. Set it in Element settings → ID, and match it in the link's data-target. toc_link | data-target | summary | Which section this link jumps to. Using a data attribute instead of <code>href="#id"</code> means the browser never does its own instant jump before Lenis can take over.
CSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const article = document.querySelector(".article"); const fill = document.querySelector(".progress_fill"); const pct = document.querySelector(".toc_percent"); const links = gsap.utils.toArray(".toc_link"); const ind = document.querySelector(".toc_indicator"); // ── 1 · the bar. Written every frame, so quickSetter, not gsap.to ─── const setFill = gsap.quickSetter(fill, "scaleX"); ScrollTrigger.create({ trigger: article, start: "top top", end: "bottom bottom", onUpdate: self => { setFill(self.progress); pct.textContent = Math.round(self.progress * 100) + "% read"; } }); // ── 2 · the dock. One trigger per section, band anchored at 30% ───── const setY = gsap.quickTo(ind, "y", { duration: 0.45, ease: "power3" }); const setH = gsap.quickTo(ind, "height", { duration: 0.45, ease: "power3" }); function activate(i) { links.forEach((l, n) => l.classList.toggle("is-on", n === i)); setY(links[i].offsetTop); setH(links[i].offsetHeight); // height too, or two-line entries clip } links.forEach((link, i) => { const target = document.getElementById(link.dataset.target); if (!target) return; ScrollTrigger.create({ trigger: target, start: "top 30%", end: "bottom 30%", onToggle: self => self.isActive && activate(i) }); // ── 3 · the jump. THIS is the part people get wrong. ───────────── // A native anchor jump and Lenis both try to own the scroll position, // and the page lurches. Hand it to Lenis when Lenis exists. link.addEventListener("click", e => { e.preventDefault(); if (window.lenis) window.lenis.scrollTo(target, { offset: -80 }); else target.scrollIntoView({ behavior: "smooth", block: "start" }); }); }); activate(0); }); </script>
Gotchas that will cost you an hour
- Route anchor links through Lenis. With smooth scroll running, a native #anchor jump sets scrollTop directly while Lenis is still animating toward its own target — the page snaps, then slides back. Always lenis.scrollTo(), with a fallback.
- Use data-target, not href="#id". The browser acts on a real hash before your handler can call preventDefault reliably in some setups, and you get an instant jump followed by a smooth one.
- quickSetter for the bar. gsap.to() inside onUpdate creates a tween per scroll frame. It works, it looks fine, and it quietly costs you a third of your frame budget on a long page.
- Animate the indicator's height as well as its y. Section titles wrap to two lines at some viewport width, and an indicator with a fixed height will clip them for exactly the visitors on that width.
- Anchor the active band at 30%, not at the viewport edge. At top 80% the dock highlights a section while the reader is still finishing the previous one.
- Sticky needs align-items: start. Grid's default stretch makes the dock column full height, leaving it no travel — it just sits there and everyone blames the JavaScript.
Push it further
- Update location.hash from onToggle so a reader can copy the URL mid-article and land in the right place.
- Add an estimated time remaining rather than a percentage — “4 min left” is a stronger reason to keep reading than “62%”.
- Drive the indicator with Flip instead of y/height so it morphs between entries of different sizes without any measurement at all.
Webflow Motion Lab — GSAP 3.15, all plugins free.
11
/ 30