Process timeline
Every service business needs this section and most builds of it animate the wrong thing. The instinct is to reveal each step as it enters — which just makes a list appear. What makes it read as a process is the rail filling to the viewport centre: anchoring both start and end to center means the line tracks the reader rather than the section, so the fill is always exactly level with the sentence being read. The second idea is that the step states cost no tweens at all — toggleClass handles all four scroll directions in one line, and CSS transitions do the colour. Reaching for a tween per step gives you four callbacks to keep in sync and no benefit.
How to build
Interactions panel
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 · toggleClass
Build time
35 min. The rail alignment takes longer than the script.
No-code?
Yes, at a cost. The panel can scrub a scaleY and can toggle a class per step, so a six-step timeline is roughly seven interactions. This is one script that works for six steps or sixteen.
Webflow structure
element · class · attribute<b>Section</b> <i>section_process</i> <b>Div</b> <i>process_layout</i> ← grid, 0.55fr 1.45fr, align-items: start <b>Div</b> <i>process_side</i> ← position: sticky, top 16vh <b>Heading H3</b> <b>Text</b> <i>process_count</i> ← "01 / 06", tabular-nums <b>Div</b> <i>process_track</i> ← position: relative, padding-left 3.5rem <b>Div</b> <i>process_rail</i> ← absolute, left 7px, top/bottom inset, 1px wide <b>Div</b> <i>process_fill</i> ← absolute inset 0, scaleY(0), origin top <b>Collection Item</b> <i>process_step</i> <b>Div</b> <i>process_dot</i> <u>aria-hidden="true"</u> <b>Text</b> <i>process_no</i> <b>Heading H3</b> <i>process_title</i> <b>Paragraph</b> <i>process_body</i>
Styles to set in the Designer
process_fill | <code>transform: scaleY(0); transform-origin: top;</code> — scaleY on a 1px div, never <code>height</code>. Height animates layout on every frame of a scrub. process_rail | Inset the top and bottom by about 0.6rem so the line starts at the first dot rather than at the container edge. This is the difference between tidy and nearly-tidy. process_dot | Absolute, with a negative left equal to the track's <code>padding-left</code> minus the rail offset. Pin it to the <em>title's</em> vertical centre, not the step's — steps have different body lengths. process_dot::after | The filled centre: <code>transform: scale(0)</code> with a CSS transition. Driven by the <code>.is-active</code> class, so there is no tween per step. process_title | Dim by default, full colour under <code>.is-active</code>, with a <code>transition: color 0.5s</code>. CSS is the right tool here. process_side | <code>position: sticky; top: 16vh</code>. Needs <code>align-items: start</code> on the grid or it stretches full height and has no room to stick.
Building it with the Interactions panel
no code, free plan- Select process_fill → Interactions → trigger Scroll into view → While scrolling in view, with both Start and End anchored to the centre of the viewport across the whole process_track.
- Animate Scale Y from 0% to 100%, transform origin top — never height, on a scrubbed 1px div height would fight layout every frame.
- For each process_step: add a second Scroll into view interaction on the step itself, using Add Class on enter and Remove Class on leave-back to toggle .is-active.
- Repeat step 3 once per step. Six steps is six near-identical interactions, but each is a copy-paste job once the first one is right.
What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
- One interaction per step, hand-maintained — a sixteen-step process is sixteen near-identical interactions instead of one script that loops over an array. Add a seventeenth step and you're editing the Designer, not a registry.
- The panel can't update process_count ("03 / 06") in sync with the active step — that text write only happens in the script's onToggle callback.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesCSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const steps = gsap.utils.toArray(".process_step"); const track = document.querySelector(".process_track"); const fill = document.querySelector(".process_fill"); const count = document.querySelector(".process_count"); if (matchMedia("(prefers-reduced-motion: reduce)").matches) { gsap.set(fill, { scaleY: 1 }); steps.forEach(s => s.classList.add("is-active")); return; } // Both ends anchored to "center": the rail fills to wherever the reader's // eye is, not to where the section is. That is the entire effect. gsap.to(fill, { scaleY: 1, ease: "none", scrollTrigger: { trigger: track, start: "top center", end: "bottom center", scrub: true, invalidateOnRefresh: true } }); steps.forEach((step, i) => { ScrollTrigger.create({ trigger: step, // The same LINE for both ends. A step is active exactly while the 60% // line sits inside it, and because the steps tile, that is always // precisely one step. A band (top 62% / bottom 38%) lights two at once // on any step shorter than the band is tall. start: "top 60%", end: "bottom 60%", // One line instead of onEnter + onLeave + onEnterBack + onLeaveBack. // The CSS transition does the actual animating, for free. toggleClass: { targets: step, className: "is-active" }, onToggle: self => { if (self.isActive) { count.textContent = String(i + 1).padStart(2, "0") + " / " + String(steps.length).padStart(2, "0"); } } }); }); }); </script>
Gotchas that will cost you an hour
- Anchor the rail to center, not to top 80% / bottom 20%. With the usual viewport-edge anchors the line races ahead of the reader and is full before they have read step four.
- Use one line for both ends, not a band. start: "top 60%" with end: "bottom 60%" means a step is active exactly while that line is inside it — so with tiling steps, precisely one is ever lit. Widen it into a band (top 62% / bottom 38%) and any step shorter than the band lights up alongside its neighbour, so two dots glow and the counter disagrees with what looks active.
- toggleClass beats four callbacks. Hand-rolling onEnter/onLeave/onEnterBack/onLeaveBack means four places to keep in sync, and scrolling up will eventually leave two steps lit at once.
- Align the dot to the title, not the step. Steps have bodies of different lengths; centring the dot on the step makes it drift lower on every long one.
- Sticky side column needs align-items: start. A grid's default stretch makes the column full height, so it has no travel left and never sticks.
- With a CMS Collection List, refresh after load. Finsweet CMS Load or a “show more” changes the track height; call ScrollTrigger.refresh() or the rail fills to the old length.
Push it further
- Draw the rail as an SVG path with a curve instead of a straight div and scrub drawSVG — the SVG-draw lesson has the setup, and a curved process line is instantly more memorable.
- Fade each step's body text in on activation with a small y, but leave the title to CSS. Mixing the two deliberately is a real skill.
- Make the sticky side column swap its heading per step, driven by the same onToggle — the section then narrates itself.
Webflow Motion Lab — GSAP 3.15, all plugins free.
10
/ 30