cross browser custom scrollbars with jQuery and CSS3 – replicating the fading scrollbars from OS X Lion or Mountain Lion


cross browser custom scrollbars with jQuery and CSS3 – replicating the fading scrollbars from OS X Lion or Mountain Lion

just another cross browser custom scrollbars

Cross browser custom scrollbars

Many people might ask themselves: “Why..?”
Others might think: “That is soo ugly” or “Don’t change the systems’s native elements.”

Demo Source

But let me explain

We are living in a time where webapps have grown in popularity. Not only that but also has it gotten more and more important to customize each little bit of a webapp.

I for myself really like those overall styled apps as long as it doesn’t affect the performance and basic usage.
There are “a lot” of plugins that replace scrollbars, checkboxes, radiobuttons, selects etc…

Checkboxes and radiobuttons can be nicely styled with pure CSS in most browsers (IE8 and lower fail here)

Selects are very hard to style so a plugin is needed. There are quite a few good ones out there.

So what about Scrollbars?

I guess if there’s anything that really has too many plugins it’s scrollbars.
Here’s a list of a few popular ones:
nanoscroller
nicescroll
Tiny Scrollbar
antiscroll
jsScrollPane

So.. why did I decide to write another one?

One reason is, that I actually always wanted to write a scrollbar plugin because I really like writing JavaScripts where interactions are translated 😉

But the main reason is because of all plugins that I’ve seen, I haven’t found one that does it the way I think is “the cool” way.

Most Plugins use the mousewheel.js by Brandon Aaron. They listen to the mousewheel events and then translate them to a div’s top CSS-vaue.

Translate = fun? … Yeah.. but not like that.
If I scroll a div I could simply listen to the scroll instead. This removes a dependency and also give a way better native feeling.
Gives a feeling? No, even better it IS native.

So what we will do is allow native scrolling, listen to the event and translate it to a scrollbar that is dynamically created by our script.
Translate = fun? Yeah… like that it is!

The concept

The main concept is to wrap the content to be scrolled in a div that actually does the scrolling for us. This will allow to have full flexibility when styling or interacting with our content.
This div is wrapped in another div which is just a bit slimmer than the scrolling-area. This way if we add overflow: hidden; to it, it will hide the original scrollbar. Now we can add our custom scrollbar which will be moved by the listener or provide listeners that will be passed to the content. Instead of moving a static content by it’s position we make use of as many native features as make sense.

The approach

We will write a plugin to modify our DOM and the style our elements with CSS (CSS3 in this case).

So let’s set up a basic plugin frame:

The code

(function ($) {
    $.fn.customScrollBar = function (options) {
        // make sure to handle each call separately
        $(this).each(function (){
            // set some default options

            var defaults = {};
            // extend the options
            options = $.extend(defaults, options);
        });
    }
});

and then let’s add some options.
We will allow to add a theme-name, add optional triggers (top-down-arrows) and some events that we can listen for (e.g. scrollstart or scrollend)

(function ($) {
    $.fn.customScrollBar = function (options) {
        // set some default options
        $(this).each(function (){
            var defaults = {
                theme: 'custom-scroll-bar',
                arrows: true,
                init: function(e,ui){
                    // initialized scrollbar
                },
                scrollstarted: function(e,ui){
                    // the scroll has started
                },
                scrollended: function(e,ui){
                    // the scroll has ended
                },
                thumbclick: function(e,ui){
                    // the thumb was clicked
                }
            };
            // extend the options
            options = $.extend(defaults, options);
        });
    }
});

Ok now we can call our plugin on a jQuery-object.
We will need to set some variables. For one reason to cache some elements and others for our calculations and interactions.

// set the variables
var thisElement = $(this);
 var $body = $('body');
var $window = $(window);
// for later use
var clickY = 0;
var $dragging = null;
var scrollTriggerFunction;
var deltaY;
var clickYY;
var scrolling;
var showArrows = "";
var scrollEnded;

The next step will be to wrap our element with our two divs

// wrap our element
thisElement.wrap('<div class=\"scroll-wrapper\" >');
thisElement.wrap('<div class=\"scroll-area\" >');
</div></div>

More variables. These ones need to be called after we wrapped our element.

// get new elements and dependent calculations
var $scrollArea = thisElement.parent();
var $scrollWrapper = $scrollArea.parent();
var thisHeight = parseInt((thisElement.outerHeight()),10);
var scrollAreaHeight = parseInt(($scrollArea.outerHeight()),10);
var thisScroll = parseInt(($scrollArea.scrollTop()),10);

To calculate the correct offset we need to find out the factor between our div’s height and the maximum-height of the wrapper

// the factor will be used for calculating the relation between
// our events and elements
var factor = thisHeight / scrollAreaHeight;
var scrollBarHeight = parseInt((scrollAreaHeight / factor),10);

Now we can read out a few options and create our scrollbar (if it is needed, similar to overflow: auto)

if (options.arrows) {
    showArrows =  '<span class=\"scroll-trigger top\"></span><span class=\"scroll-trigger bottom\"></span>';
}
var newScrollBar = '<div class=\"scroll-track\">'
                 + '<div class=\"scroll-bar\"></div>'
                 + showArrows
                 + '</div> ';
// add the scrollbar
if (thisHeight > scrollAreaHeight){
    $scrollArea.parent().append(newScrollBar);
}

Now that the plugin has modified our DOM we can get the rest of the variables

// get new elements and dependents
var $scrollBar = $scrollWrapper.find('.scroll-bar');
var $scrollTrack = $scrollWrapper.find('.scroll-track');
var $scrollTriggerTop =  $scrollWrapper.find('.scroll-trigger.top');
var $scrollTriggerBottom =  $scrollWrapper.find('.scroll-trigger.bottom');
$scrollTrack.addClass(options.theme);
var thisMargin = parseInt(($scrollBar.css('margin-top')),10);
thisMargin += parseInt(($scrollBar.css('margin-bottom')),10);

Safety first… Let’s make sure the scrollbar is big enough to be visible

// make sure our scrollbar is visible
if (scrollBarHeight < thisMargin){
    factor = thisHeight / (scrollAreaHeight - thisMargin);
}

// make sure our scrollbar is visible
if (scrollBarHeight < 20){
    scrollBarHeight += thisMargin;
}

Now we are ready to give the scroll-bar-thumb a height.

// set the height of the scrollbar
$scrollBar.css({
    height: scrollBarHeight - thisMargin
});

Let’s create a function that can be called when the scroll has ended. This will be our first listener.

var scrollHasEnded = function (){
    $scrollTrack.removeClass("scrolling");
    options.scrollended(thisElement, $scrollTrack);
    scrolling = false;
};

And a second one when the scrollbar is processed

options.init(thisElement, $scrollTrack);

dirty hacking
Webkit has an issue when selecting the content of a div with overflow: hidden.
So we will force a scrollLeft to 0. tThis creates a steppy and ugly effect but is only called when the user selects to far to the right. Yet it is needed because otherwise the div will stay in the scrolled position, reveal the “real” scrollbar and hide important content

// dirty hack to prevent webkits drag-scroll
$scrollWrapper.on('scroll',function (){
    var $this = $(this);
    $this.scrollLeft(0);
});

The rest of the Javascript will be our interactions on the div or the scrollbar

            // handling the native mouse scroll
 
            $scrollArea.on('scroll', function (){
                if (!scrolling) {
                    options.scrollstarted(thisElement, $scrollTrack);
                }
                var $this = $(this);
                thisScroll = parseInt(($this.scrollTop()),10);
                clearTimeout(scrollEnded);
                scrollEnded = setTimeout(scrollHasEnded,200);
                $scrollTrack.addClass("scrolling");
                $scrollBar.css({
                    top: thisScroll / factor
                });
                scrolling = true;
 
            });
            $scrollTrack.on('mousedown', function (e){
                var $this = $(this);
                var thisOffset =  parseInt(($this.offset().top),10);
                var trackOffset =  parseInt(($this.find('.scroll-bar').position().top),10);
                var trackPosition =  $this.find('.scroll-bar').position().top / scrollBarHeight;
                var correctOffset = e.pageY - thisOffset - trackOffset;
                $this.addClass('clicked');
                options.thumbclick(thisElement, $scrollTrack);
                // prevent the cursor from changing to text-input
                e.preventDefault();
                // calculate the correct offset
                clickY = thisOffset + correctOffset;
                clickYY = thisOffset + thisMargin;
                if ($( e.target).hasClass('scroll-bar')) {
                    $dragging = $(e.target);
                }
 
 
            })
            // scroll to position if the track is clicked (but prevent
            // when triggers or the bar is clicked)
                .on('mousedown', '.scroll-track', function (e){
                    if (!$( e.target).hasClass('scroll-trigger') && !$( e.target).hasClass('scroll-bar')) {
                        deltaY = e.pageY - clickYY;
                        $scrollArea.stop(true,true).animate({scrollTop: deltaY * factor},1);
                    }
                });
 
 
            // scrolling via the triggers (up-down-arrows)
            // Top arrow
            $scrollTriggerTop.on('mousedown', function (){
                $scrollArea.stop(true,true).animate({scrollTop: "-=" + factor + "px"},factor);
                scrollTriggerFunction = setInterval(function (){
                    $scrollArea.stop(true,true).animate({scrollTop: "-=" + factor + "px"},factor);
                },1);
            });
            // Bottom arrow
            $scrollTriggerBottom.on('mousedown', function (){
                $scrollArea.stop(true,true).animate({scrollTop: "+=" + factor + "px"},factor);
                scrollTriggerFunction = setInterval(function (){
                    $scrollArea.stop(true,true).animate({scrollTop:  "+=" + factor + "px"},factor);
                },1);
            });
 
            // on mouseup or mouseleave we will kill all intervals and set
            // dragging to null to prevent leaking
            $body.on('mouseup mouseleave', function (){
                clearInterval(scrollTriggerFunction);
                $dragging = null;
                $scrollTrack.removeClass("clicked");
            })
            // on mosemove we will move our scrollbar if dragging is
            // active (after mousedown on scroll-track)
                .on('mousemove', function (e){
 
                    if ($dragging) {
                        deltaY = e.pageY - clickY ;
                        $scrollArea.stop(true,true).animate({scrollTop: deltaY * factor},factor);
                    }
                });

our JavaScript to call the plugin

$(function (){

      var scrollMe = $('.lionScrollBar');
      scrollMe.customScrollBar({
        theme: 'lion-scroll-bar',
        arrows: false,
        init: function(e,ui){
          ui.addClass('scrolling');
          setTimeout(function(){
            ui.removeClass('scrolling');
          },600)
        }
      });


    });

… and the CSS behind it

.scroll-wrapper {
      height: 400px;
      width: 300px;
      overflow: hidden;
      position: relative;
      display: inline-block;
      margin: 50px 50px 0;
    }

    .lionScrollBar {
      padding: 0;
    }
    .scroll-wrapper .scroll-area {
      height: 100%;
      width: 100%;
      overflow: auto;
      padding-right: 20px;
      margin-right: 20px;
      position: relative;
    }
    .scroll-track {
      -webkit-user-select: none;
      -moz-user-select: none;
      -ms-user-select: none;
    }
    .lion-scroll-bar.scroll-track {
      position: absolute;
      top: 0;
      right: 0;
      width: 11px;
      -webkit-box-sizing: border-box;
      -moz-box-sizing: border-box;
      box-sizing: border-box;
      height: 100%;
      background: rgba(255,255,255,0);
      border: 1px solid transparent;
      border-width: 0 0 0 1px;
      -webkit-transition-duration: 0ms, 500ms;
      -webkit-transition-delay: 1000ms, 500ms;
      -webkit-transition-property: width, opacity;
      -moz-transition-duration: 0ms, 500ms;
      -moz-transition-delay: 1000ms, 500ms;
      -moz-transition-property: width, opacity;
      transition-duration: 0ms, 500ms;
      transition-delay: 1000ms, 500ms;
      transition-property: width, opacity;
      opacity: 0;
      overflow: hidden;
      width: 0;
    }
    .lion-scroll-bar.scroll-track.scrolling {
      opacity: 1;
      -webkit-transition-duration: 0ms, 50ms;
      -webkit-transition-delay: 0ms, 0ms;
      -webkit-transition-property: width, opacity;
      -moz-transition-duration: 0ms, 50ms;
      -moz-transition-delay: 0ms, 0ms;
      -moz-transition-property: width, opacity;
      transition-duration: 0ms, 50ms;
      transition-delay: 0ms, 0ms;
      transition-property: width, opacity;
      width: 10px;
    }
    .lion-scroll-bar.scroll-track.clicked, .lion-scroll-bar.scroll-track:hover {
      opacity: 1;
      -webkit-transition-duration: 200ms, 50ms;
      -webkit-transition-delay: 0ms, 0ms;
      -webkit-transition-property: width, opacity;
      -moz-transition-duration: 200ms, 50ms;
      -moz-transition-delay: 0ms, 0ms;
      -moz-transition-property: width, opacity;
      transition-duration: 200ms, 50ms;
      transition-delay: 0ms, 0ms;
      width: 15px;
      background: rgba(255,255,255,0.8);
      border-color: rgba(0,0,0,0.2);
      box-shadow: 20px 0 20px -20px rgba(0,0,0,0.2) inset;
    }

    .lion-scroll-bar.scroll-track .scroll-bar {
      position: absolute;
      z-index: 0;
      margin: 3px 0;
      top: 0;
      right: 2px;
      width: 7px;
      height: 14px;
      background: rgba(0,0,0,0.5);
      -webkit-border-radius: 7px;
      -moz-border-radius: 7px;
      border-radius: 7px;
      -webkit-transition: width 200ms;
      -moz-transition: width 200ms;
      transition: width 200ms;
    }

    .lion-scroll-bar.scroll-track.clicked .scroll-bar, .lion-scroll-bar.scroll-track:hover .scroll-bar {
      -webkit-border-radius: 10px;
      -moz-border-radius: 10px;
      border-radius: 10px;
      background: rgba(0,0,0,0.5);
      width: 10px;
    }

Restrictions

At the time of posting this plugin does not allow horizontal scrollbars but as time passes I will add this feature.
Nesting scrollbars causes an issue with the dimensions so for now it will not be possible.

Follow me

Gregor Adams

web developer at Codefights
Hi, I am a web-programmer and frontend-developer from Germany.
I specialize in CSS, HTML and JavaScript. As I am a huge fan and also a student of open-source, I try to help other programmers (especially newcomers) as much as I can. I am also very happy for "true" criticism. So feel free to ask any question you like and I will try to give the best possible answer.
Follow me

Hi, I am a web-programmer and frontend-developer from Germany. I specialize in CSS, HTML and JavaScript. As I am a huge fan and also a student of open-source, I try to help other programmers (especially newcomers) as much as I can. I am also very happy for "true" criticism. So feel free to ask any question you like and I will try to give the best possible answer.