Mark Ku's Blog

1. Problems caused by the rubber-band behavior

When developing mobile web, you often run into iOS's rubber-band bounce. Combined with pull-to-refresh and pull-up-to-load-more, the UX can actually be quite nice. But in practice every scrollable area gets this bounce, which can lead to scrolling jank or even getting stuck.

  • When the scroll position is at the top, you get the spring effect Mobile app UI with horizontally scrolling categories and news feed
  • When the scroll position is at the bottom, you get the spring effect Mobile news app screenshot showing football articles and bottom navigation
  • With multiple nested scroll areas, scrolling past the boundary becomes unsmooth and can even freeze.

2. Common solutions

On native iOS, a single line of code disables the bounce. On the web, it is more troublesome.

Option 1. Use CSS fixed

Our existing mobile architecture converts every px to rem via PostCSS, so it is hard to compute pixel-perfect values. fixed does not fit.

Option 2. Hand-rolled JavaScript

Globally blocking and adding exceptions one by one is too tedious — too many places to change and too time-consuming.

Option 3. Use the inobounce JS library

Option 4. Use a virtual scroller (only works in specific scenarios)

3. How to use inobounce.js

Step 1. Enable inobounce on iOS devices

if (this.isiOS) {
  inobounce.enable()
}

Step 2. CSS for the scrollable container

  1. overflow-x or overflow-y set to auto or scroll
  2. -webkit-overflow-scrolling set to touch

4. After adopting inobounce, most rubber-band issues went away, but new problems showed up. So I decided to modify the inobounce source directly based on its existing logic.

Issue 1. Touch does not work when emulating iOS in Chrome. Investigation showed that the -webkit-overflow-scrolling CSS property has no effect in Chrome.

var isDesktopDebugMode = window.navigator.vendor === 'Google Inc.'
      var scrolling = style.getPropertyValue('-webkit-overflow-scrolling') === 'touch' || isDesktopDebugMode
CSS code highlighting invalid -webkit-overflow-scrolling: touch property
CSS code highlighting invalid -webkit-overflow-scrolling: touch property

Issue 2. inobounce.js does not support horizontal scrolling. We can mirror the vertical-scroll logic to add horizontal support.

5. The final modified inobounce.js

/*! iNoBounce - v0.2.1
 * https://github.com/lazd/iNoBounce/
 * Copyright (c) 2013 Larry Davis <[email protected]>; Licensed BSD */
(function(global) {
  // Stores the Y position where the touch started
  var startY = 0
  var startX = 0

  // Store enabled status
  var enabled = false

  var supportsPassiveOption = false
  try {
    var opts = Object.defineProperty({}, 'passive', {
      get: function() {
        supportsPassiveOption = true
      }
    })
    window.addEventListener('test', null, opts)
  } catch (e) {}

  var handleTouchmove = function(evt) {
    // Get the element that was scrolled upon
    var el = evt.target

    // Allow zooming
    var zoom = window.innerWidth / window.document.documentElement.clientWidth
    if (evt.touches.length > 1 || zoom !== 1) {
      return
    }

    // Check all parent elements for scrollability
    while (el ! document.body && el ! document) {
      // Get some style properties
      var style = window.getComputedStyle(el)

      if (!style) {
        // If we've encountered an element we can't compute the style for, get out
        break
      }

      // Ignore range input element
      if (el.nodeName = 'INPUT' && el.getAttribute('type') = 'range') {
        return
      }

      // chrome 在桌面版模擬 ios webkit-overflow-scrolling 屬性沒有作用
      var isDesktopDebugMode = window.navigator.vendor === 'Google Inc.'
      var scrolling = style.getPropertyValue('-webkit-overflow-scrolling') === 'touch' || isDesktopDebugMode

      var overflowY = style.getPropertyValue('overflow-y')
      var scrollableY = overflowY = 'auto' || overflowY = 'scroll'
      var height = parseInt(style.getPropertyValue('height'), 10)
      var width = parseInt(style.getPropertyValue('width'), 10)

      // Determine if the element should scroll

      var isScrollableY = scrolling && scrollableY

      var canScrollY = el.scrollHeight > el.offsetHeight // 能不能滑

      var curY = evt.touches ? evt.touches[0].screenY : evt.screenY

      if (isScrollableY && canScrollY) {
        // Get the current Y position of the touch

        // Determine if the user is trying to scroll past the top or bottom
        // In this case, the window will bounce, so we have to prevent scrolling completely
        var isAtTop = startY <= curY && el.scrollTop === 0
        var isAtBottom =
          startY >= curY && el.scrollHeight - el.scrollTop === height

        // Stop a bounce bug when at the bottom or top of the scrollable element
        if (isAtTop || isAtBottom) {
          console.log('prevent')
          evt.preventDefault()
        }

        // No need to continue up the DOM, we've done our job
        return
      }

      // 橫向捲動

      var overflowX = style.getPropertyValue('overflow-x')
      var scrollableX = overflowX = 'auto' || overflowX = 'scroll'
      var isScrollableX = scrolling && scrollableX
      var canScrollX = el.scrollWidth > el.offsetWidth

      if (isScrollableX && canScrollX) {
        // debugger
        // Get the current X position of the touch
        var curX = evt.touches ? evt.touches[0].screenX : evt.screenX

        // Determine if the user is trying to scroll past the top or bottom
        // In this case, the window will bounce, so we have to prevent scrolling completely
        var isAtLeft = startX <= curX && el.scrollLeft === 0
        var isAtRight =
          startX >= curX && el.scrollWidth - el.scrollLeft === width

        // Stop a bounce bug when at the bottom or top of the scrollable element
        if (isAtLeft || isAtRight) {
          evt.preventDefault()
        }

        // No need to continue up the DOM, we've done our job
        return
      }

      // Test the next parent
      el = el.parentNode
    }

    // Stop the bouncing -- no parents are scrollable
    evt.preventDefault()
  }

  var handleTouchstart = function(evt) {
    // Store the first Y position of the touch
    startY = evt.touches ? evt.touches[0].screenY : evt.screenY
    startX = evt.touches ? evt.touches[0].screenX : evt.screenX
  }

  var enable = function() {
    // Listen to a couple key touch events
    window.addEventListener(
      'touchstart',
      handleTouchstart,
      supportsPassiveOption ? { passive: false } : false
    )
    window.addEventListener(
      'touchmove',
      handleTouchmove,
      supportsPassiveOption ? { passive: false } : false
    )
    enabled = true
  }

  var disable = function() {
    // Stop listening
    window.removeEventListener('touchstart', handleTouchstart, false)
    window.removeEventListener('touchmove', handleTouchmove, false)
    enabled = false
  }

  var isEnabled = function() {
    return enabled
  }

  // Enable by default if the browser supports -webkit-overflow-scrolling
  // Test this by setting the property with JavaScript on an element that exists in the DOM
  // Then, see if the property is reflected in the computed style
  var testDiv = document.createElement('div')
  document.documentElement.appendChild(testDiv)
  testDiv.style.WebkitOverflowScrolling = 'touch'
  var scrollSupport =
    'getComputedStyle' in window &&
    window.getComputedStyle(testDiv)['-webkit-overflow-scrolling'] === 'touch'
  document.documentElement.removeChild(testDiv)

  if (scrollSupport) {
    enable()
  }

  // A module to support enabling/disabling iNoBounce
  var iNoBounce = {
    enable: enable,
    disable: disable,
    isEnabled: isEnabled
  }

  if (typeof module !== 'undefined' && module.exports) {
    // Node.js Support
    module.exports = iNoBounce
  }
  if (typeof global.define === 'function') {
    // AMD Support
    (function(define) {
      define('iNoBounce', [], function() {
        return iNoBounce
      })
    })(global.define)
  } else {
    // Browser support
    global.iNoBounce = iNoBounce
  }
})(this)

6. Conclusion

On the web, perfectly avoiding rubber-band bounce seems impossible. A better approach is to redesign the layout: pin the top with CSS fixed so that when the page snaps back, there is something covering the top, and the scroll feels less weird.

inobounce.js can solve:

  • Pull-down rubber-band on first load
  • Scroll-stuck issues across multiple nested scroll areas

What it cannot solve yet:

  • While actively scrolling, when you hit the top or bottom you still see one rubber-band bounce. JS cannot prevent the scroll from going to a negative offset; the bounce-prevention only kicks in after scrollTop lands back at 0.

Author

Mark Ku

擁有 10+ 年經驗的資深軟體工程師,現為 AI 應用 Builder,專注於大型平台架構與簡化複雜系統設計,從電商系統到訂閱與收費平台,結合 AI Agent、AI 整合與自動化開發,打造高效率且可持續演進的產品技術基礎。Read More

Found this useful?

The author's free tools, daily podcasts and newsletter are all here.

Mark Ku · This article is licensed under CC BY 4.0. Credit the author and link back to the original when reusing it.

Comments

Subscribe to Newsletter

Subscribe to get new posts delivered instantly — never miss a tech share.

By submitting, you agree to receive emails. You can anytime.

Popular Posts

View all
Mark Ku
··602

Oracle Cloud Always Free Tier: Linux Host and Static IP for a $0 Cloud Solution

Oracle Cloud Always Free Tier: Linux Host and Static IP for a $0 Cloud Solution
Mark Ku
··490

Say Goodbye to Postman's Fee Trap! A Hands-on Guide to Bruno, the Open-Source Git-Native API Testing Powerhouse.

Say Goodbye to Postman's Fee Trap! A Hands-on Guide to Bruno, the Open-Source Git-Native API Testing Powerhouse.
Mark Ku
··333

A Free, Open-Source, Notion-like Knowledge Base — A Complete Guide to Deploying and Backing Up Outline Wiki

A Free, Open-Source, Notion-like Knowledge Base — A Complete Guide to Deploying and Backing Up Outline Wiki
Mark Ku
··264

Training Your Own AI Voice: Hardware Requirements, Open-Source Model Comparison, and LoRA Fine-Tuning

Training Your Own AI Voice: Hardware Requirements, Open-Source Model Comparison, and LoRA Fine-Tuning
Mark Ku
··221

Building an Efficient API Management Platform: Deploying Kong Gateway from Scratch - Part 1

Building an Efficient API Management Platform: Deploying Kong Gateway from Scratch - Part 1
Mark Ku
··215

Setting Up Samba on Ubuntu to Share Folders with Windows 11

Setting Up Samba on Ubuntu to Share Folders with Windows 11