---
title: "Extending inobounce.js to Support Horizontal Scrolling and Handle iOS Bounce Behavior"
description: "How to extend the iNoBounce.js source to add horizontal scroll support on top of preventing iOS rubber-band bounce, and to fix the issue when emulating iOS in Chrome desktop."
canonical_url: "https://blog.markkulab.net/en/post/extnd-inobounce-js"
author: "Mark Ku"
author_url: "https://blog.markkulab.net/en/author/mark-ku"
site: "Mark Ku's Tech Notes"
date_published: "2021-12-13 01:01:01 +0800"
category: "Frontend"
tags: ["inobounce", "ios", "bounce", "mobile web", "javascript", "scroll", "frontend", "webkit"]
language: "en"
license: "CC BY 4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
attribution: "when reusing or quoting, credit the author and link back to the original"
---

# Extending inobounce.js to Support Horizontal Scrolling and Handle iOS Bounce Behavior

## 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](https://blog.markkulab.net/content/markku/posts/extnd-inobounce-js/images/bcjrxLU.png)
* When the scroll position is at the bottom, you get the spring effect
![Mobile news app screenshot showing football articles and bottom navigation](https://blog.markkulab.net/content/markku/posts/extnd-inobounce-js/images/6I9hQ8Y.png)
* 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](https://blog.markkulab.net/content/markku/posts/extnd-inobounce-js/images/mRsCoi5.png)


### 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 <lazdnet@gmail.com>; 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.

---

## About this article and its author

Originally published on [Mark Ku's Tech Notes](https://blog.markkulab.net/en/post/extnd-inobounce-js)

License: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — when reusing or quoting, credit the author and link back to the original

### About the author

**[Mark Ku](https://blog.markkulab.net/en/author/mark-ku)** — Software Solution Provider

- 10+ years senior software engineer, now an AI Builder
- Focused on large-platform architecture — North-American e-commerce, AI SaaS subscription billing
- Combining AI Agents and automation to build evolvable product foundations

### Free tools built by the author

All of these are free to use:

- [Free PDF Sign Tool](https://blog.markkulab.net/en/tools/pdf-sign): Online PDF sign tool — draw, type, or upload a signature, then drag, resize, and download. Everything runs in your browser; nothing is uploaded.
- [VS Code Refactory](https://blog.markkulab.net/en/tools/refactory): Refactory is a VS Code refactoring extension: 34 actions plus a 37-rule code-smell inspection layer with a Code Health dashboard, across 18 languages, backed by 534 tests. It learns your repo's conventions: where interfaces live, where DI is registered, whether 'use client' belongs. It ranks files by git churn × complexity so you know what to fix first, and hands any smell to the Claude Code already on your machine. Free to use, and your source never leaves your computer.
- [DB-Kit Database Manager](https://blog.markkulab.net/en/tools/db-kit): DB-Kit is a lightweight, cross-platform database manager built with Tauri + Rust + React. Manage MySQL, MariaDB, PostgreSQL, SQL Server, Oracle, SQLite, MongoDB, Redis, Kafka, Elasticsearch and RabbitMQ from one consistent interface: passwords encrypted in the OS keychain, SSH tunnels, full CRUD, a visual query builder, stacked multi-statement result sets, cross-connection data transfer and compare/sync, Excel / CSV import & export, visualized execution plans, ER diagrams, scheduled backups, SQL stress testing with p50–p99 latency percentiles, a 15-rule SQL review engine, Kafka message browsing with monitoring & alerts, a bilingual UI (Traditional Chinese / English), a built-in AI assistant (natural-language SQL, AI review and tuning advice) and the dbk CLI. Free and open source (MIT), with installers for Windows, macOS and Linux.
- [VS Code Super Mermaid](https://blog.markkulab.net/en/tools/super-mermaid): Super Mermaid is a VS Code extension for beautiful Mermaid diagrams out of the box: auto-colored live preview, mouse pan & zoom, high-res PNG / SVG export, 21 templates and multiple themes. Free and open source (MIT).
- [React Super Mermaid](https://blog.markkulab.net/en/tools/react-super-mermaid): react-super-mermaid is an open-source React component library: render beautiful Mermaid diagrams with a single <MermaidViewer>, with built-in colorful / sketch themes, pan & zoom, in-diagram search, and high-res SVG / PNG export. Lightweight, SSR-safe, fully typed. Free and open source (MIT).
- [Jira / Confluence Super Mermaid](https://blog.markkulab.net/en/tools/jira-super-mermaid): An Atlassian Forge app: write Mermaid syntax directly inside a Jira issue or a Confluence page and get flowcharts, sequence diagrams, state machines and Gantt charts. 11 diagram types, SVG / PNG export, light and dark themes, full CJK support. Runs on Atlassian: your diagrams live in your own site and the app calls no third-party service. Free, coming soon to the Atlassian Marketplace.
- [Mermaid Live Preview](https://blog.markkulab.net/en/tools/mermaid-preview): Write Mermaid in your browser, see it render instantly, and share the whole diagram as a single link. No sign-up, nothing uploaded to a server, and mermaid.live share links work as-is.
- [React Intl Phone Number](https://blog.markkulab.net/en/tools/react-intl-phone-number): react-intl-phone-number is an open-source React component: framework-agnostic and antd-free, with E.164 in/out, a searchable flag / country-code dropdown, configurable validation levels (strict / mobile-strict / loose), themeable CSS, and i18n — phone logic powered by google-libphonenumber. Lightweight and fully typed. Free and open source (MIT).
- [Uptime Kuma Cluster](https://blog.markkulab.net/en/tools/uptime-kuma-cluster): Turn single-node Uptime Kuma into a highly available cluster: OpenResty + Lua smart load balancing, shared MariaDB state, health checks and automatic failover, plus cluster-management REST APIs. One Docker Compose command to start. Free and open source (MIT).
- [Special Education](https://blog.markkulab.net/en/education): Learning materials crafted for special education students

### Daily podcasts

- [Mark's Tech Insights — Daily AI News](https://blog.markkulab.net/en/category/tech-news): Daily curated AI and tech trends. Catch the latest developments via audio summaries — covering AI applications, software architecture, DevOps, and engineering practice. — RSS: https://blog.markkulab.net/feed.xml
- [AI股市蝦聊](https://blog.markkulab.net/en/category/ai-stock-chat): Every trading day, an AI-analyzed take on the Taiwan stock market, delivered as a two-host conversation covering the session and the next-day outlook. — RSS: https://blog.markkulab.net/ai-stock-chat/feed.xml
- [開源好物週報](https://blog.markkulab.net/en/category/open-source-weekly): A weekly two-host pick of free open-source tools surfaced from real Hacker News, GitHub, and Reddit buzz — what pain they solve and the fastest way to get started. — RSS: https://blog.markkulab.net/open-source-weekly/feed.xml

### Newsletter

[Subscribe to the newsletter](https://blog.markkulab.net/en/subscribe) — Be the first to know about new posts. No spam, unsubscribe anytime.
