Menu

Change language

GitHub repo

Scroll Progress

scroll-progressui

Slim reading-progress bar pinned to the top of the viewport or of any supplied scrollable container. Scales horizontally with scroll depth; supports fixed and absolute (contained) modes, lerp smoothing with a prefers-reduced-motion fallback, custom thickness, and any CSS color or gradient.

Installation

$npx uipkge-ng@latest add scroll-progress
Manifest:https://uipkge.dev/r/angular/scroll-progress.jsonInstalls to:src/app/components/ui/scroll-progress/

Examples

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
height

Bar thickness in pixels.

number3optional
color

Any CSS color or gradient — applied to `background` verbatim.

stringvar(--primary)optional
position

`fixed` pins to the viewport; `absolute` fills a positioned scrollable parent.

'fixed''absolute'
fixedoptional
container

Scrollable element to measure. Defaults to the window/document.

HTMLElement | nullnulloptional
smooth

Lerp-smooth the displayed value toward the real progress each frame.

booleantrueoptional
classstring—optional

Files installed (2)

  • src/app/components/ui/scroll-progress/scroll-progress.component.ts4.7 kB
    import {
      Component,
      Input,
      OnChanges,
      OnDestroy,
      OnInit,
      SimpleChanges,
      booleanAttribute,
      signal,
      ChangeDetectionStrategy,
    } from '@angular/core'
    import { cn } from '@/lib/utils'
    
    export type ScrollProgressPosition = 'fixed' | 'absolute'
    
    /**
     * Angular port of UIPKGE ScrollProgress (React `ScrollProgress`). A single aria-hidden bar
     * scaled horizontally by scroll depth of the window (default) or of `container`. Listens to
     * scroll + resize, syncs instantly on mount / container swap, lerps toward the target each
     * frame (0.18) when `smooth`, and tracks 1:1 under prefers-reduced-motion. Progress is a
     * signal: scroll handlers and animation frames repaint in zoneless apps.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-scroll-progress, [ui-scroll-progress]',
      standalone: true,
      host: {
        '[attr.data-uipkge]': '""',
        '[attr.data-slot]': '"scroll-progress"',
        '[attr.aria-hidden]': '"true"',
        '[attr.data-position]': 'position',
        '[attr.data-smooth]': 'smooth ? "true" : null',
        '[class]': 'hostClass',
        '[style.height.px]': 'height',
        '[style.background]': 'color',
        '[style.transform]': '"scaleX(" + progress() + ")"',
        '[style.will-change]': '"transform"',
      },
      template: ``,
    })
    export class UiScrollProgressComponent implements OnInit, OnChanges, OnDestroy {
      /** Bar thickness in pixels. */
      @Input() height = 3
      /** Any CSS color or gradient — applied to `background` verbatim. */
      @Input() color = 'var(--primary)'
      /** `fixed` pins to the viewport; `absolute` fills a positioned scrollable parent. */
      @Input() position: ScrollProgressPosition = 'fixed'
      /** Scrollable element to measure. Defaults to the window/document. */
      @Input() container: HTMLElement | null = null
      /** Lerp-smooth the displayed value toward the real progress each frame. */
      @Input({ transform: booleanAttribute }) smooth = true
      @Input('class') className?: string
    
      readonly progress = signal(0)
      private display = 0
      private target = 0
      private raf: number | null = null
      private detach: (() => void) | null = null
      private attached = false
    
      get hostClass(): string {
        return cn(
          'block pointer-events-none top-0 left-0 z-50 w-full origin-left',
          this.position === 'fixed' ? 'fixed' : 'absolute',
          this.className,
        )
      }
    
      ngOnInit(): void {
        this.attach()
      }
    
      /** Swapping `container` re-attaches the listeners to the new element. */
      ngOnChanges(changes: SimpleChanges): void {
        if (this.attached && changes['container']) this.attach()
      }
    
      ngOnDestroy(): void {
        this.detach?.()
        this.detach = null
      }
    
      private read(): number {
        const clamp01 = (value: number) => Math.min(1, Math.max(0, value))
        const el = this.container
        if (el) {
          const max = el.scrollHeight - el.clientHeight
          return max > 0 ? clamp01(el.scrollTop / max) : 0
        }
        const doc = document.documentElement
        const scrollTop = window.scrollY || doc.scrollTop || document.body.scrollTop || 0
        const max = doc.scrollHeight - doc.clientHeight
        return max > 0 ? clamp01(scrollTop / max) : 0
      }
    
      private stopLoop(): void {
        if (this.raf !== null) {
          cancelAnimationFrame(this.raf)
          this.raf = null
        }
      }
    
      private readonly tick = (): void => {
        this.display += (this.target - this.display) * 0.18
        this.progress.set(this.display)
        if (Math.abs(this.target - this.display) < 0.001) {
          this.display = this.target
          this.progress.set(this.target)
          this.raf = null
          return
        }
        this.raf = requestAnimationFrame(this.tick)
      }
    
      private readonly sync = (): void => {
        this.target = this.read()
        const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
        if (!this.smooth || reducedMotion) {
          this.stopLoop()
          this.display = this.target
          this.progress.set(this.target)
          return
        }
        if (this.raf === null) this.raf = requestAnimationFrame(this.tick)
      }
    
      private attach(): void {
        this.attached = true
        this.detach?.()
        this.detach = null
        if (typeof window === 'undefined') return
        // EventTarget union keeps addEventListener compatible across HTMLElement | Window.
        const bound: EventTarget = this.container ?? window
        bound.addEventListener('scroll', this.sync, { passive: true })
        window.addEventListener('resize', this.sync)
        // Sync instantly on mount / container swap so the bar never animates up from zero.
        this.target = this.read()
        this.display = this.target
        this.progress.set(this.target)
        this.detach = () => {
          bound.removeEventListener('scroll', this.sync)
          window.removeEventListener('resize', this.sync)
          this.stopLoop()
        }
      }
    }
    
  • src/app/components/ui/scroll-progress/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/scroll-progress.json