shadimage
morphiq
componentelastic-railsource: ok · hit

Elastic Rail

A scroll progress rail that bulges with overscroll and twangs back. It obeys the wave equation, so its pitch follows f = (1/2L)·√(T/μ) — a longer rail rings lower, with nothing tuned per page.

npx shadcn add "https://morphiq.prathampatel.design/r/elastic-rail.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files4 · source cached 10/9/2026, 12:43:22 PM

src/components/ElasticRail/index.js
export { default } from './ElasticRail.jsx'
src/components/ElasticRail/ElasticRail.jsx
'use client'

import { useEffect, useLayoutEffect, useRef } from 'react'
import './ElasticRail.css'

/* =========================================================
   ElasticRail
   A scroll progress rail that bows when you push past the
   end of the page, and twangs back when you let go.

   The rail is a string fixed at both ends. Let go of, it
   obeys the wave equation, discretised:

     ẍᵢ = (T/μ)·(xᵢ₋₁ − 2xᵢ + xᵢ₊₁)/Δx² − damping·ẋᵢ

   with Δx the real spacing of the nodes along the rail. That
   Δx² is the whole point: leave it out and every rail rings
   at the same pitch whatever its length. With it, the pitch
   is not a constant someone chose. For a string of length L
   under tension T with mass per length μ, the fundamental is

     f = (1/2L)·√(T/μ)

   so a longer rail rings lower. On a tall page the rail is
   long and the twang is slow and deep; on a short one it is
   short and sharp. Nothing has to be tuned per page — the
   geometry does it, which is the whole reason to use a
   string rather than a spring.

   What bows it is the page being pushed past its end, and
   only some platforms report that. Safari lets the page run
   elastically past the end and says how far, so there the
   rail bows by give × that distance and nothing more is
   needed. Everywhere else the scroll position stops dead at
   the end and a rail that waited for it to go past would
   never move. So where the platform has no overscroll the
   rail stands in for it: a wheel, a finger or a scroll key
   still pushing once the page is against its end is taken
   as x px of push, and resisted the way a rubber-banding
   page resists it,

     e = c·x·R / (R + c·x)        c = 0.55

   — c px of overscroll per px at first, stiffer the further
   it goes, and never more than R, the reach. A finger is
   read as it is. A wheel has no finger to read: each notch
   arrives over a few hundredths of a second rather than in
   one frame, and a push that is not kept up leaks away, so a
   steady scroll into the end holds the rail bowed, a single
   notch knocks it, and a page thrown at its end lands as one
   swell rather than a second of momentum. Only pushes that
   were going to the tracked scroller count: one that starts
   in a scroller nested inside it, with somewhere left to
   go, is that scroller's own.

   Pushed, the string is on the hand: it sits exactly where
   the push puts it, in its fundamental — half a sine, anchor
   to anchor — and is not simulated, because a held string
   does not ring. The naive version sets the shape on every
   scroll event and lets the simulation run underneath, and
   the two fight: the bow sags away while the page is still
   held past its end, then jumps back out on the next event.
   A string caught mid-ring is drawn onto the hand over a few
   hundredths of a second from where it was and how fast it
   was going, rather than jumping to it.

   The hand has let go when the push falls back faster than a
   hand would lower it — a page springing home, a wheel gone
   quiet — or when the finger lifts. A push eased back slowly
   lowers the string with it and nothing rings. On release
   the string leaves from where it is with the hand's own
   velocity, fitted over the last 70 ms of the push rather
   than taken from one event, and from there the wave
   equation has it, stepped in slices no longer than 1/240 s
   so it rings the same at 60 Hz and 120 Hz. The loop stops
   the moment it has settled.

   The fill follows the page over about 60 ms rather than
   being set to it, so a jump to an anchor or the far end
   sweeps the rail instead of blinking across it, and a
   notched wheel does not step it.

   The rail is drawn as a quadratic through the displaced
   nodes rather than a polyline, so the curve stays smooth at
   the twenty-odd nodes the simulation actually needs.

   It takes no pointer and no focus: it reports, it is not a
   control. It is a progressbar to assistive tech, with the
   page's position as its value. Under reduced motion it is
   the progress bar and nothing else — straight, and set to
   the page rather than eased. Off screen it keeps its value
   and draws nothing.
   ========================================================= */

const clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n)
const finite = (n, d) => (Number.isFinite(n) ? n : d)

/* Whatever measures and draws runs as a layout effect, so the first paint
   already has the rail on it rather than one empty frame before it. Plain
   useEffect on the server, where there is nothing to lay out and older Reacts
   warn about it. */
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect

/* Mass per px of rail. Chosen so the default tension rings a viewport-tall
   rail, about 800px, near 1.6 Hz; a rail half as long rings twice as high. */
const MU = 8.3e-4

const STEP = 1 / 240   // s — the longest slice the string is stepped in
const RESIST = 0.55    // px of stand-in overscroll per px of push, at first
const SWELL = 0.045    // s — how long a wheel notch takes to arrive at the string
const LEAK = 0.14      // s — how quickly a wheel's push relaxes when it is not kept up
const PICKUP = 0.035   // s — how quickly a string caught mid-ring comes onto the hand
const KEEP = 0.88      // the hand has let go once the push is below this much of its crest
const SLIP = 0.25      // s — a push eased back more slowly than this is lowered, not let go
const REGRIP = 0.15    // of the last crest: how far a let-go push must rise again to take hold
const FAINT = 0.25     // px of overscroll below which nothing is pushing
const TRAIL = 70       // ms of the push the release velocity is fitted over
const FAST = 4000      // px/s — the fastest a hand is taken to leave the string
const STILL = 0.2      // px — a ring smaller than this is over
const FOLLOW = 0.06    // s — how quickly the fill follows the page
const LINE = 100 / 3   // px per line, for a wheel that reports lines: three to a notch
const LATCH = 250      // ms — a wheel gesture belongs to the scroller it started on this long
const KEYS = { ArrowDown: 40, ArrowUp: -40, PageDown: 100, PageUp: -100, End: 100, Home: -100 }

export default function ElasticRail({
  nodes = 24,          // simulation nodes along the string
  tension = 5200,      // T — wave speed is √(T/μ), and the pitch follows from that and the length
  damping = 4.5,       // 1/s — the ring dies as e^(−damping·t/2)
  give = 0.42,         // px of bulge per px of overscroll
  reach = 120,         // px — the most overscroll the rail stands in for where the platform has none; 0 for none
  thickness = 3,       // stroke width, px
  progressColor,       // the fill; currentColor when unset
  trackColor,          // the track, drawn at 18%; currentColor when unset
  vertical = false,    // run top to bottom rather than left to right
  target,              // element to track; defaults to the page
  className = '',
  style,
  ...rest
}) {
  const rootRef = useRef(null)
  const svgRef = useRef(null)
  const trackRef = useRef(null)
  const fillRef = useRef(null)

  const live = useRef({})
  // Synced after every render and before the effect below, so the loop reads
  // current props without being torn down and restarted.
  useIsomorphicLayoutEffect(() => {
    live.current = { tension, damping, give, reach, vertical }
  })

  useIsomorphicLayoutEffect(() => {
    const root = rootRef.current
    const svg = svgRef.current
    const track = trackRef.current
    const fill = fillRef.current
    if (!root || !svg || !track || !fill) return

    const motion = window.matchMedia('(prefers-reduced-motion: reduce)')
    let still = motion.matches
    let seen = true

    const read = () => {
      const p = live.current
      return {
        T: Math.max(0, finite(p.tension, 5200)),
        d: Math.max(0.1, finite(p.damping, 4.5)),   // a string always loses something to the air
        give: finite(p.give, 0.42),
        reach: Number.isNaN(+p.reach) ? 120 : Math.max(0, +p.reach),   // Infinity is no limit at all
      }
    }

    const n = clamp(Math.round(finite(nodes, 24)), 4, 256)
    const y = new Float64Array(n)
    const v = new Float64Array(n)
    // The fundamental: half a sine, anchor to anchor. The shape a push holds
    // the string in, so that let go it rings in one clean mode.
    const s = new Float64Array(n)
    for (let i = 1; i < n - 1; i++) s[i] = Math.sin((i / (n - 1)) * Math.PI)
    // What a caught string had that the hand had not: how far off it stood
    // and how fast it was moving, node by node.
    const o0 = new Float64Array(n)
    const w0 = new Float64Array(n)

    let W = 0, H = 0, L = 0
    let top = 0, max = 0             // the scroll position and its range, as last read
    let progress = 0, shown = 0      // where the page is, and where the fill has got to
    let drawn = -1, pct = -1         // the fill and the aria value as last written
    let native = 0                   // overscroll the platform reports, px
    let travel = 0                   // a finger's travel past the end, px
    let wq = 0, wx = 0, wt = 0       // a wheel's push: still to arrive, arrived, and as of when
    let fingers = 0, fid = -1, fx = 0, fy = 0, shut = false
    let latched = false, latchDir = 0, latchT = -1e9

    // The hand. Holding, the string sits at bulge × s; crest is the push's
    // high-water mark, eased down behind a push that is being lowered. Let
    // go, trough is how far the push has fallen back since.
    let held = false, side = 1, bulge = 0, push = 0
    let crest = 0, trough = 0, regrip = FAINT, felt = 0
    let caught = 0, catching = false, slackest = 0
    let trail = []                   // [t, bulge] along the push
    let ringing = false
    let dirty = true                 // the string's shape has changed since it was drawn
    let raf = 0, last = 0
    let tick = 1 / 60                // the display's frame interval, as last measured

    const scroller = () => target || document.scrollingElement || document.documentElement

    const measure = () => {
      W = root.offsetWidth
      H = root.offsetHeight
      L = live.current.vertical ? H : W
      svg.setAttribute('viewBox', `0 0 ${Math.max(1, W)} ${Math.max(1, H)}`)
      dirty = true
    }

    const locate = () => {
      const el = scroller()
      max = Math.max(0, el.scrollHeight - el.clientHeight)
      top = el.scrollTop
      progress = max > 0 ? clamp(top / max, 0, 1) : 0
      // Past either end is the platform's own elastic overscroll. Under a
      // pixel is a fractional scroll position, not a push.
      const out = top < 0 ? top : top > max ? top - max : 0
      native = Math.abs(out) < 1 ? 0 : out
    }

    // x px of push past the end, as overscroll: RESIST of it at first, less
    // the further it goes, and never more than R.
    const band = (x, R) => (R > 0 ? (RESIST * x) / (1 + (RESIST * Math.abs(x)) / R) : 0)

    // A wheel's push as of t. What a notch brings arrives over SWELL and what
    // has arrived leaks away over LEAK; both in closed form, so it is the same
    // push however many frames it is read in.
    const relax = (t) => {
      const dt = Math.max(0, t - wt) / 1000
      wt = Math.max(wt, t)
      if (!wq && !wx) return
      const a = Math.exp(-dt / SWELL)
      const b = Math.exp(-dt / LEAK)
      wx = wx * b + wq * (LEAK / (LEAK - SWELL)) * (b - a)
      wq *= a
      if (Math.abs(wq) < 0.05 && Math.abs(wx) < 0.05) wq = wx = 0
    }

    // The hand's velocity over the end of the push, by least squares.
    const fling = (t) => {
      let m = 0, mt = 0, mb = 0
      for (const q of trail) if (q[0] >= t - TRAIL) { m++; mt += q[0]; mb += q[1] }
      if (m < 2) return 0
      mt /= m; mb /= m
      let tt = 0, tb = 0
      for (const q of trail) {
        if (q[0] < t - TRAIL) continue
        const d = q[0] - mt
        tt += d * d; tb += d * (q[1] - mb)
      }
      return tt > 1 ? clamp((tb / tt) * 1000, -FAST, FAST) : 0
    }

    // How far the caught string still stands off the hand, and how fast that
    // is closing: a critically damped approach from (o0, w0), in closed form.
    const fade = (t) => {
      const u = Math.max(0, t - caught) / 1000
      return [u, catching ? Math.exp(-u / PICKUP) : 0]
    }

    // The hand, at bulge b, takes the string. Whatever the string has that
    // the hand has not — how far off it stands, how fast it is going — is
    // kept, and closes over PICKUP, so the first frame draws exactly what was
    // there.
    const grab = (t, sg, b) => {
      held = true
      side = sg
      ringing = false
      caught = t
      trail = []
      slackest = 0
      for (let i = 1; i < n - 1; i++) {
        o0[i] = y[i] - b * s[i]
        w0[i] = v[i]
        v[i] = 0
        slackest = Math.max(slackest, Math.abs(o0[i]) + Math.abs(w0[i]) * PICKUP)
      }
      catching = slackest > 0.05
    }

    const release = (t) => {
      if (!held) return
      const vb = fling(t)
      const [u, e] = fade(t)
      for (let i = 1; i < n - 1; i++) {
        v[i] = vb * s[i] + e * (w0[i] - ((w0[i] + o0[i] / PICKUP) * u) / PICKUP)
      }
      held = false
      catching = false
      trough = Math.abs(push)
      regrip = Math.max(FAINT, REGRIP * crest)
      ringing = true
    }

    // Read the push as of t and decide whether the hand is on the string.
    const feel = (t) => {
      relax(t)
      const p = read()
      if (still || !seen || native) travel = wq = wx = 0
      const e = still || !seen ? 0 : native || band(travel + wx, p.reach)
      const a = Math.abs(e)
      const sg = e < 0 ? -1 : e > 0 ? 1 : side
      const dt = Math.max(0, t - felt) / 1000
      felt = Math.max(felt, t)
      // A finger on the glass is holding it for as long as it is there.
      const pinned = fingers > 0 && (native !== 0 || travel !== 0)

      if (held) {
        // Nothing left of it, unless that is a notch which has yet to arrive.
        const gone = a < FAINT && Math.abs(wq) < 0.5
        // A push that has vanished or changed ends is not somewhere the hand
        // went: the page was taken from under it. Fitting the release through
        // that reading would throw the string with a speed no hand had.
        const off = gone || sg !== side
        if (!off) {
          trail.push([t, p.give * e])
          while (trail.length > 2 && trail[0][0] < t - 200) trail.shift()
        }
        crest = a + Math.max(0, crest - a) * Math.exp(-dt / SLIP)
        if (off || (!pinned && a < crest * KEEP)) release(t)
      }
      push = e
      if (!held) {
        trough = Math.min(trough, a)
        if (a < FAINT) { trough = 0; regrip = FAINT }
        else if (pinned || sg !== side || a > trough + regrip) { crest = a; grab(t, sg, p.give * e) }
      }
      if (held) bulge = p.give * e
    }

    // Let go, if holding, before a push is taken away outright — a finger
    // lifted, a page that has left its end — so that the string leaves with
    // the hand's velocity rather than with the push's sudden absence.
    const drop = (t) => {
      release(t)
      travel = wq = wx = 0
    }

    const place = (t) => {
      const [u, e] = fade(t)
      if (catching && e * (1 + u / PICKUP) * slackest < 0.05) catching = false
      for (let i = 1; i < n - 1; i++) {
        y[i] = bulge * s[i] + (catching ? e * (o0[i] + (w0[i] + o0[i] / PICKUP) * u) : 0)
      }
      dirty = true
    }

    const rest0 = () => {
      y.fill(0)
      v.fill(0)
      ringing = false
      dirty = true
    }

    // The free string, advanced by dt. Returns whether it is still ringing.
    const ring = (dt) => {
      const { T, d } = read()
      const dx = Math.max(1, L) / (n - 1)
      const c2 = T / MU / (dx * dx)
      // The discrete string's own fundamental, to weigh speed against offset.
      const w1 = 2 * Math.sqrt(c2) * Math.sin(Math.PI / (2 * (n - 1)))
      if (!(w1 > 0)) return false               // no tension: nothing to carry it back
      // Explicit, so each slice is split under the stability limit c·Δt/Δx ≤ 1.
      const slices = Math.max(1, Math.ceil(dt / STEP))
      const parts = slices * Math.max(1, Math.ceil((Math.sqrt(c2) * dt) / slices / 0.9))
      if (!(parts <= 4096)) return false         // pitched far above anything a display can show
      const h = dt / parts
      for (let k = 0; k < parts; k++) {
        for (let i = 1; i < n - 1; i++) {
          v[i] += (c2 * (y[i - 1] - 2 * y[i] + y[i + 1]) - d * v[i]) * h
        }
        for (let i = 1; i < n - 1; i++) y[i] += v[i] * h
      }
      dirty = true
      let size = 0
      for (let i = 1; i < n - 1; i++) size = Math.max(size, Math.abs(y[i]), Math.abs(v[i]) / w1)
      return size >= STILL                       // NaN, should it ever come to that, is not ringing
    }

    const draw = () => {
      if (dirty) {
        dirty = false
        const vert = live.current.vertical
        const half = (vert ? W : H) / 2
        const dx = L / (n - 1)
        const at = (a, o) => (vert ? `${o.toFixed(2)},${a.toFixed(2)}` : `${a.toFixed(2)},${o.toFixed(2)}`)
        let d = `M${at(0, half)}`
        let pa = 0, po = half
        for (let i = 1; i < n; i++) {
          // Quadratic through the midpoint of each pair keeps a coarse node
          // count looking like a string rather than a chain of segments.
          const a = i * dx
          const o = half + y[i]
          d += `Q${at(pa, po)} ${at((a + pa) / 2, (o + po) / 2)}`
          pa = a; po = o
        }
        d += `L${at(pa, po)}`                    // and on to the far anchor
        track.setAttribute('d', d)
        fill.setAttribute('d', d)
      }
      if (shown !== drawn) {
        drawn = shown
        // The fill's length is normalised to 1, so the dash is the progress
        // whatever length the bowed path has come to.
        fill.setAttribute('stroke-dashoffset', (1 - shown).toFixed(5))
      }
    }

    // The value assistive tech reads is the page's, not the eased fill's, and
    // is kept whether or not the rail is on screen.
    const report = () => {
      const now = Math.round(progress * 100)
      if (now !== pct) { pct = now; root.setAttribute('aria-valuenow', String(now)) }
    }

    const frame = (now) => {
      raf = 0
      // Back-to-back frames measure the display, 60 Hz or 120 Hz alike. The
      // first frame after the loop has been idle advances one frame of that,
      // not the idle time; a stall mid-ring, like a return to the tab,
      // advances at most 1/30 s.
      const gap = last ? (now - last) / 1000 : 0
      const dt = gap > 0 ? Math.min(gap, 1 / 30) : tick
      if (gap > 0 && gap < 1 / 40) tick += (gap - tick) * 0.5
      last = now
      let busy = false

      feel(now)
      if (wq || wx) busy = true                  // a wheel's push, still arriving or leaking away
      if (held) {
        place(now)
        if (catching) busy = true
      } else if (ringing) {
        if (!still && seen && ring(dt)) busy = true
        else rest0()
      }

      // The fill follows the page, and is simply set to it when there is no
      // one to see it move or they would rather it did not.
      const k = still || !seen ? 1 : 1 - Math.exp(-dt / FOLLOW)
      shown += (progress - shown) * k
      if (Math.abs(progress - shown) * Math.max(1, L) < 0.25) shown = progress
      else busy = true

      report()
      if (seen) draw()
      if (busy) raf = requestAnimationFrame(frame)
      else last = 0
    }

    const kick = () => {
      if (!raf) { last = 0; raf = requestAnimationFrame(frame) }
    }

    const stamp = (e) => (e && e.timeStamp) || performance.now()

    const onScroll = (e) => {
      const t = stamp(e)
      locate()
      // A push lasts only while the page is against the end being pushed.
      const x = travel + wx + wq
      if ((x > 0 && top < max - 1) || (x < 0 && top >= 1)) drop(t)
      feel(t)
      kick()
    }

    // Whether a push from this element is some nested scroller's own: one with
    // somewhere left to go that way, or one that keeps its overscroll to
    // itself. A wheel gesture stays with whichever it started on, as the
    // browser's own scroll does, so the verdict holds while the events keep
    // coming. dir 0 asks for a finger, which may yet go either way.
    const barred = (from, dir, t) => {
      if (dir && dir === latchDir && t - latchT < LATCH) { latchT = t; return latched }
      const stop = target || document.body
      let no = false
      for (let el = from; el && el.nodeType === 1 && el !== stop && el !== document.documentElement; el = el.parentElement) {
        const range = el.scrollHeight - el.clientHeight
        if (dir && range < 1) continue
        const cs = getComputedStyle(el)
        // A finger on something that has claimed vertical drags is not scrolling.
        const ta = cs.touchAction
        if (!dir && ta !== 'auto' && ta !== 'manipulation' && !ta.includes('pan-y')) { no = true; break }
        if (range < 1 || (cs.overflowY !== 'auto' && cs.overflowY !== 'scroll')) continue
        if (!dir || (dir > 0 ? el.scrollTop < range - 1 : el.scrollTop >= 1) || cs.overscrollBehaviorY !== 'auto') { no = true; break }
      }
      if (dir) { latched = no; latchDir = dir; latchT = t }
      return no
    }

    // dy px of scroll asked for, by a wheel or a key, from this element.
    const shove = (dy, from, t) => {
      if (still || !seen || !Number.isFinite(dy) || !dy || !(read().reach > 0)) return
      locate()
      if (native) return                         // the platform is already saying how far
      const dir = dy > 0 ? 1 : -1
      relax(t)
      if ((wx + wq) * dir < 0) drop(t)           // a push back the other way is the hand coming off
      if (dir > 0 ? top < max - 1 : top >= 1) return
      if (barred(from, dir, t)) return
      // A string at rest is taken the moment the push is made, before any of
      // it has arrived, so that it rises with the push rather than after it.
      if (!held && !ringing) {
        const p = read()
        crest = 0
        grab(t, dir, p.give * band(travel + wx, p.reach))
      }
      wq += dy
      feel(t)
      kick()
    }

    const onWheel = (e) => {
      // Ctrl is a pinch on a trackpad, and a wheel someone else has taken is theirs.
      if (e.ctrlKey || e.defaultPrevented || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return
      const unit = e.deltaMode === 1 ? LINE : e.deltaMode === 2 ? scroller().clientHeight : 1
      shove(e.deltaY * unit, e.target, stamp(e))
    }

    // The keys that scroll, pressed with the page already against its end,
    // knock the rail as a wheel notch would. Keys being typed are not scrolling.
    const onKey = (e) => {
      if (e.defaultPrevented || e.altKey || e.ctrlKey) return
      const el = e.target
      if (!el || el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) return
      // Space scrolls only when nothing that takes it has the focus.
      const bare = el === document.body || el === document.documentElement || el === target
      const dy = e.key === ' ' ? (bare ? (e.shiftKey ? -100 : 100) : 0) : KEYS[e.key]
      if (dy) shove(dy, el, stamp(e))
    }

    const anchor = (e) => {
      fingers = e.touches.length
      if (fingers !== 1) return false
      const p = e.touches[0]
      const same = p.identifier === fid
      if (!same) { fid = p.identifier; fx = p.clientX; fy = p.clientY }
      return same
    }

    const onTouchStart = (e) => {
      const was = fingers
      anchor(e)
      // A second finger is a pinch, and the page keeps that.
      if (fingers > 1) { drop(stamp(e)); kick() }
      else if (!was) shut = barred(e.target, 0, stamp(e))
    }

    const onTouchMove = (e) => {
      const t = stamp(e)
      if (!anchor(e) || shut || e.defaultPrevented) return
      const p = e.touches[0]
      const dx = fx - p.clientX
      const dy = fy - p.clientY
      fx = p.clientX; fy = p.clientY
      locate()
      // The finger's travel past the end, once the page has nowhere left to
      // go that way; coming back, it gives up the push before anything else.
      const steep = Math.abs(dy) >= Math.abs(dx)
      if (still || !seen || native) travel = 0
      else if (travel > 0 || (dy > 0 && steep && top >= max - 1)) travel = Math.max(0, travel + dy)
      else if (travel < 0 || (dy < 0 && steep && top < 1)) travel = Math.min(0, travel + dy)
      feel(t)
      kick()
    }

    const onTouchEnd = (e) => {
      anchor(e)
      if (fingers) return
      fid = -1
      const t = stamp(e)
      if (e.type === 'touchcancel') trail = []   // taken away, not let go: nothing to carry
      // The finger was still where it last was when it lifted, so one that
      // stopped before lifting does not throw the string with a stale speed.
      else if (held) trail.push([t, bulge])
      release(t)
      travel = 0
      feel(t)
      kick()
    }

    const onMotion = () => { still = motion.matches; kick() }

    const ro = new ResizeObserver(() => { measure(); locate(); kick() })
    ro.observe(root)
    // The page growing or the scroller resizing moves the progress without a
    // scroll event to say so.
    if (target instanceof Element) ro.observe(target)
    else if (!target) ro.observe(document.documentElement)

    // Off screen there is no one to bow for: the string rests, and nothing is
    // drawn until the rail is back in view.
    const io = typeof IntersectionObserver === 'function'
      ? new IntersectionObserver((entries) => {
        seen = entries[entries.length - 1].isIntersecting
        if (seen) { dirty = true; drawn = -1 }
        kick()
      })
      : null
    io?.observe(root)

    // First paint: the rail as it stands, with no sweep up from empty.
    measure()
    locate()
    shown = progress
    report()
    draw()

    const src = target || window
    const quiet = { passive: true }
    src.addEventListener('scroll', onScroll, quiet)
    src.addEventListener('wheel', onWheel, quiet)
    src.addEventListener('keydown', onKey)
    src.addEventListener('touchstart', onTouchStart, quiet)
    src.addEventListener('touchmove', onTouchMove, quiet)
    src.addEventListener('touchend', onTouchEnd, quiet)
    src.addEventListener('touchcancel', onTouchEnd, quiet)
    window.addEventListener('resize', onScroll, quiet)
    motion.addEventListener?.('change', onMotion)

    return () => {
      ro.disconnect()
      io?.disconnect()
      src.removeEventListener('scroll', onScroll)
      src.removeEventListener('wheel', onWheel)
      src.removeEventListener('keydown', onKey)
      src.removeEventListener('touchstart', onTouchStart)
      src.removeEventListener('touchmove', onTouchMove)
      src.removeEventListener('touchend', onTouchEnd)
      src.removeEventListener('touchcancel', onTouchEnd)
      window.removeEventListener('resize', onScroll)
      motion.removeEventListener?.('change', onMotion)
      if (raf) cancelAnimationFrame(raf)
    }
  }, [nodes, target])

  return (
    <div
      ref={rootRef}
      className={`elastic-rail${vertical ? ' is-vertical' : ''} ${className}`.trim()}
      style={style}
      role="progressbar"
      aria-label="Reading progress"
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={0}
      {...rest}
    >
      <svg ref={svgRef} className="elastic-rail__svg" preserveAspectRatio="none" aria-hidden="true">
        <path ref={trackRef} fill="none" strokeWidth={thickness}
              stroke={trackColor || 'currentColor'} strokeOpacity="0.18" strokeLinecap="round" />
        <path ref={fillRef} fill="none" strokeWidth={thickness} pathLength="1"
              strokeDasharray="1" strokeDashoffset="1"
              stroke={progressColor || 'currentColor'} strokeLinecap="round" />
      </svg>
    </div>
  )
}
src/components/ElasticRail/ElasticRail.css
/* ElasticRail — the SVG overflows on purpose: the string bows past the rail's
   own box when the page is pushed past its end, and swings as far the other
   way on its way back, so leave it room where it is mounted — give × reach
   either side covers every push the rail stands in for. It takes no pointer:
   it reports the page's position, it is not a control. */

.elastic-rail {
  position: relative;
  width: 100%;
  height: 14px;
  pointer-events: none;
}

.elastic-rail.is-vertical { width: 14px; height: 100%; }

.elastic-rail__svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
src/components/ElasticRail/ElasticRail.d.ts
import type { ComponentPropsWithoutRef } from 'react'

export interface ElasticRailProps extends ComponentPropsWithoutRef<'div'> {
  /** Simulation nodes along the string, 4 to 256. Default 24. */
  nodes?: number
  /**
   * String tension T. Wave speed is √(T/μ) with μ = 8.3×10⁻⁴ per px, and the
   * pitch follows from that and the rail's own length: f = (1/2L)·√(T/μ).
   * Default 5200.
   */
  tension?: number
  /** How quickly the ring dies, 1/s: its amplitude falls as e^(−damping·t/2). Default 4.5. */
  damping?: number
  /** Px of bulge per px of overscroll. Negative bows it the other way. Default 0.42. */
  give?: number
  /**
   * The most overscroll, px, the rail stands in for on platforms that have
   * none of their own: a wheel, a finger or a scroll key pushing past the end
   * is resisted like a rubber-banding page and never comes to more than this.
   * 0 leaves only the platform's own elastic overscroll. Default 120.
   */
  reach?: number
  /** Stroke width, px. Default 3. */
  thickness?: number
  /** Colour of the fill. currentColor when unset. */
  progressColor?: string
  /** Colour of the track, drawn at 18% opacity. currentColor when unset. */
  trackColor?: string
  /** Run top to bottom rather than left to right. Default false. */
  vertical?: boolean
  /** Element to track. Defaults to the page. */
  target?: HTMLElement | null
}

declare const ElasticRail: (props: ElasticRailProps) => JSX.Element
export default ElasticRail

More from morphiq

Browse all components in this library →