Sticky Section Counter — Native CSS scroll-timeline

Sticky Section Counter (scroll-timeline) · Scroll · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Native CSS counter (counter-reset / counter-increment / content: counter()) — not a JS-written number
steps(4) timing function forces clean, discrete integer jumps, never fractional blending
animation-timeline: scroll(root) drives both the counter and the vertical fill track
position: sticky keeps the whole counter pinned during the section scroll
Zero JavaScript scroll or intersection handling for the number itself
Responsive fallback collapses to a horizontal, non-sticky layout on narrow viewports
@supports fallback shows a static dash instead of a counter frozen at 01
Easily restyled with a custom @counter-style for roman numerals or letters

About this UI Snippet

Sticky Section Counter — CSS Counters Driven by animation-timeline: scroll()

Screenshot of the Sticky Section Counter (scroll-timeline) snippet rendered live

A sticky section counter — the small "02 / 05" indicator pinned beside long-form content on editorial sites and portfolios — normally requires JavaScript: an IntersectionObserver or scroll listener tracking which section is currently in view and writing a number into the DOM. This snippet reproduces the same effect using only position: sticky, native CSS counters, and animation-timeline: scroll(root) — the number in the corner is never written by JavaScript at all.

Sticky positioning does the pinning

.stc-counter uses position: sticky; top: 0; height: 100vh, so it stays pinned in the viewport as .stc-sections scrolls past beside it — this part is ordinary CSS with no scroll-driven animation involved, the same technique behind Sticky Sidebar.

A CSS counter driven by a scroll-bound keyframe animation

The actual number is a native CSS counter, initialized with counter-reset: sec-count 1 and rendered via content: counter(sec-count, decimal-leading-zero) on a ::before pseudo-element — this is the same mechanism browsers use for ordered-list numbering. What makes it scroll-driven is that a @keyframes animation on the counter's own element increments counter-increment at four evenly-spaced keyframe stops, using steps(4) as the animation's timing function so each stop is instant rather than eased. Binding that animation to animation-timeline: scroll(root) means the browser fires each counter-increment step at the corresponding point in the page's total scroll range — 25% of the way down the page, the counter jumps from 01 to 02, and so on.

Why steps() instead of linear

A linear timing function would blend between counter values in a way that makes no visual sense for a whole number — CSS counters do not interpolate fractionally. steps(4) forces the animation to jump discretely between its four increment stops, so the displayed number is always a clean integer, never a blended or rounded fractional counter value.

The accompanying fill track

A slim vertical track beside the number fills from 0% to 100% height using the exact same animation-timeline: scroll(root) binding, giving a continuous secondary progress cue alongside the discrete counter — similar in spirit to Scroll Timeline Dots but rendered as a single continuous bar instead of individual dot markers.

A structural limitation worth knowing

Because the counter increments are spaced evenly across the total scroll range (25%, 50%, 75%, 100%), this technique assumes all five sections are roughly equal height. If sections vary dramatically in length, the counter will drift out of sync with which section is actually centered in the viewport — an IntersectionObserver-based counter tracks the DOM directly and does not have this limitation, at the cost of requiring JavaScript.

Browser support

Chromium-based browsers (Chrome, Edge, Opera, Brave) support animation-timeline: scroll() today. Firefox and Safari support is still landing, so an @supports not (animation-timeline: scroll()) block swaps the counter for a static em dash and fills the track completely rather than leaving it stuck at 01.

Customizing it

Adjust the keyframe percentages in stc-increment to match your actual section proportions, add more counter-increment stops for additional sections, or swap the decimal-leading-zero counter style for upper-roman or a custom @counter-style for a different numeral system. Pair it with Scroll Spy Nav for a version that also highlights the matching nav link.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to work out the counter-increment keyframe math by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why steps(4) is required instead of a linear timing function for a CSS counter animation, and how animation-timeline: scroll(root) maps the keyframe percentage stops onto specific points in the page's total scroll distance. The same assistant is useful for extending the effect: ask it to generate the correct keyframe percentages automatically for sections of very different heights, add a name label beside the number that also swaps per section (driven by the same counter), or combine this counter with a scroll-spy nav that highlights the matching link. It's also worth asking whether an IntersectionObserver fallback would be worth adding for pages where section heights vary too much for the even-spacing assumption to hold. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a sticky "01 / 05" section index counter pinned beside a vertically scrolling list of full-height sections, where the number increments purely from native CSS — a CSS counter driven by animation-timeline: scroll(root) — with no JavaScript scroll or intersection handling for the counter value itself.

Requirements:
- A two-column layout: a sticky counter panel (position: sticky, pinned for the full viewport height) beside a scrollable column of several full-height section elements, each with a heading and short paragraph.
- The counter's number must be rendered via a native CSS counter (counter-reset and counter-increment) displayed through content: counter(name, decimal-leading-zero) on a pseudo-element — not written into the DOM by JavaScript.
- Define a @keyframes animation on the counter element that increments counter-increment at evenly spaced percentage stops matching the number of sections, using a steps() timing function (not linear or eased) so the counter jumps discretely between whole numbers instead of trying to interpolate fractionally.
- Bind that keyframe animation via animation-timeline: scroll(root) so its stops correspond to how far the user has scrolled through the whole page, not to elapsed time.
- Add a slim vertical progress track beside the counter that fills from 0% to 100% height using the same animation-timeline: scroll(root) binding, as a continuous complement to the discrete counter.
- Add an @supports not (animation-timeline: scroll()) fallback that shows a static placeholder character instead of the counter and a fully filled track, rather than leaving either stuck at its initial state.
- Keep any JavaScript limited to computing the total section count for a "/ 05" label and a one-time feature-support check — it must never drive the counter or track itself.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Paste the HTML, CSS, and JSA sticky counter beside five full-height sections renders immediately.
  2. 2
    Scroll through the sectionsWatch the counter step from 01 to 05 and the vertical track fill as you pass each section boundary.
  3. 3
    Match your real section countUpdate counter-reset and add or remove counter-increment stops in @keyframes stc-increment to match your section count.
  4. 4
    Align stops to uneven sectionsIf sections vary in height, adjust the keyframe percentages so each stop lands where that section actually begins.
  5. 5
    Change the numeral styleSwap decimal-leading-zero in the counter() function for upper-roman, or define a custom @counter-style.
  6. 6
    Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.

Real-world uses

Common Use Cases

Editorial and long-form storytelling pages
Pair with a Scroll Company Timeline or narrative sequence so readers always know which chapter they are in.
Portfolio case-study pages
Number each project phase and keep the count visible without a JS scroll listener.
Learn CSS counters plus scroll-timeline
A focused demo of combining native CSS counters with animation-timeline for a non-obvious effect pairing.
Multi-step explainer or how-it-works sections
Show progress through a fixed sequence of steps as the reader scrolls, similar in spirit to Scroll Pin Steps.
Replace an IntersectionObserver section tracker
Removes the need for scroll-listener-based active-section tracking when sections are roughly equal height.
Related: Scroll Timeline Dots
See the Scroll Timeline Dots for a related native scroll-timeline milestone-marker pattern.
Related: Heading Underline Fill (view-timeline)
See the Heading Underline Fill (view-timeline) for a related scroll pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

The counter is initialized with counter-reset: sec-count 1 and displayed via content: counter(sec-count, decimal-leading-zero) on a pseudo-element — a native CSS feature normally used for ordered-list numbering. A @keyframes animation increments counter-increment at fixed percentage stops, and binding that animation to animation-timeline: scroll(root) ties those stops to specific points in the page's scroll range instead of to elapsed time.

CSS counters hold whole numbers and cannot interpolate fractionally between them. steps(4) forces the animation to jump discretely between its four increment stops rather than trying to blend, keeping the displayed number always a clean integer.

Because the keyframe stops are spaced evenly across the total scroll range (every 25%), the counter assumes roughly equal-height sections. If your sections vary a lot, either adjust each keyframe percentage to match your actual section proportions, or fall back to an IntersectionObserver-based counter that reads the DOM directly and is unaffected by uneven heights.

Yes. Because the animation is bound to a live scroll(root) timeline rather than triggered once, scrolling back up moves the animation's playback position backward and the counter-increment steps apply in reverse, decrementing the displayed number as expected.

The @supports not (animation-timeline: scroll()) block replaces the counter's content with a static em dash and fills the track fully, so Firefox and Safari users see a stable placeholder rather than a counter stuck at 01 or a track stuck empty.

Yes. Increase counter-reset's implicit total sections by adding matching entries to @keyframes stc-increment (for example a stop at every 1/N of 100% for N sections) and update the JS-driven "/ 05" total text, which is computed automatically from the number of .stc-section elements in the DOM.