Do What I Meant, Not What I Said

The sci-fi of my childhood promised computers that would do what I meant, not exactly what I said. You know that feeling — when you know what you want your computer to do, but no matter how hard you try it misunderstands. After more than a decade of programming the web I still run up against my browser blindly doing what I told it to do, not doing what I meant for it to do. These behaviors frustrate and break an otherwise elegant user experience.

Scrolling is one of these frustrating behaviors. You start scrolling on an element only to hit the bottom and have the outer context (often the <body>) start scrolling unexpectedly. It’s a classic example of the computer doing what you told it to do, not what you meant it to do.

This can be particularly problematic in web-apps where we want the body to scroll naturally, but we have nested contexts that need to scroll independently. A good example is sidebar navigation.

Here is a demo of the problem. Try scrolling in the upper-left view. When you get to the extreme top or bottom watch as the background (<body>) continues the scroll event. If you needed the information on the right as context for the work you sought to do on the left, this excess scrolling immediately frustrates the user experience.

See the Pen Nested Scrolling Contexts by Jon Beebe (@somethingkindawierd) on CodePen.

The fix seems simple — just trap the scroll event and cancel it. But alas, scroll is not cancelable! Time to start hacking.

I created a script to render the order of mouse events and found wheel occurs before scroll.

Turns out we can trap wheel events and cancel them. Canceling a wheel event prevents the scroll event. Awesome!

After this the only tricky part is discovering which direction the user is scrolling and if they’ve hit a boundary of the scroll. Only if they’re scrolling in the proper direction and have hit the boundary do we want to cancel the scroll.

I’ve made a handy little React ScrollLockMixin to encapsulate this behavior. For any scrollable component you can add the mixin and wire up the mount and dismount methods as appropriate. Automagically the browser seems to do what I mean.

var cancelScrollEvent = function (e) {
  e.stopImmediatePropagation();
  e.preventDefault();
  e.returnValue = false;
  return false;
};

var addScrollEventListener = function (elem, handler) {
  elem.addEventListener('wheel', handler, false);
};

var removeScrollEventListener = function (elem, handler) {
  elem.removeEventListener('wheel', handler, false);
};

var ScrollLockMixin = {
  scrollLock: function (elem) {
    elem = elem || this.getDOMNode();
    this.scrollElem = elem;
    addScrollEventListener(elem, this.onScrollHandler);
  },
  
  scrollRelease: function (elem) {
    elem = elem || this.scrollElem;
    removeScrollEventListener(elem, this.onScrollHandler);
  },
  
  onScrollHandler: function (e) {
    var elem = this.scrollElem;
    var scrollTop = elem.scrollTop;
    var scrollHeight = elem.scrollHeight;
    var height = elem.clientHeight;
    var wheelDelta = e.deltaY;
    var isDeltaPositive = wheelDelta > 0;

    if (isDeltaPositive && wheelDelta > scrollHeight - height - scrollTop) {
      elem.scrollTop = scrollHeight;
      return cancelScrollEvent(e);
    }
    else if (!isDeltaPositive && -wheelDelta > scrollTop) {
      elem.scrollTop = 0;
      return cancelScrollEvent(e);
    }
  }
};

Using the mixin is straightforward. Just don’t forget to remove the scroll events when the component unmounts from the dom.

var ScrollingView = React.createClass({
  mixins: [ScrollLockMixin],
  
  componentDidMount: function () {
    this.scrollLock();
  },
  
  componentWillUnmount: function () {
    this.scrollRelease();
  },
  
  render: function () {
    return <div>...</div>;
  }
});

Something like this is best explained with a demo, so here is a demo with the above mixin preventing the scroll event from leaking out into the outer context.

See the Pen React Scroll Lock & Nested Scrolling Contexts by Jon Beebe (@somethingkindawierd) on CodePen.

Mouse Wheel Events

The wheel event is fairly new and has support in most modern browsers.

  • Chrome 31
  • Firefox 17
  • IE 9.0
  • Safari 8 (maybe earlier, but I can’t verify)
  • Opera?

This is good enough for my current projects. And as this is not a feature, but an enhancement, I’m not worrying about supporting the older, non-standard DOMMouseScroll and mousewheel events. MDN warns not to use either in production sites.