A Fistful of $$

written by Tobie on March 11th, 2007 @ 04:00 AM

Thanks to Andrew’s and Christophe’s fantastic work, Prototype’s $$ utility is not only faster, it now also supports virtually all CSS3 selectors!

And that, my friends, is great news, for it opens up a whole new realm of nifty, unobtrusive JavaScript techniques.

Let’s start with some simple websites enhancement using Prototype version 1.5.1_rc1 and the latest build of script.aculo.us Effects which you’ll have to download from trac.

auto-scrolling page navigation

I am sure you have at least once drooled over the spiffy auto-scrolling navigation featured over at wollzelle - at least I have!

Now, when the website was launched, Prototype probably didn’t even have a $$ utility. So inline scripting was favored:

<a href="#about" onclick="new Effect.ScrollTo('about',{offset:-140}); return false">About</a>

Does the job, but could look better (no offense intended Thomas!).

Let’s see how we can achieve exactly the same result while keeping our HTML spotless, i.e. no onclick attributes, no extra class names to serve as hooks, just the basic mark-up:

<a href="#about">About</a>

$$ magic

Our first task will be to find a way to collect all the links which point to different sections of the current page: we don’t want to “scroll” to a link on another page, do we?

That turns out to be pretty easy: they all start with the hash symbol (#).

So how do we go about that? CSS3 selectors to the rescue:

$$('a[href^=#]')

This simple rule will select all <a> elements with an href attribute which starts with #. Attributes are enclosed in square brackets [], and the operator ^= means starts with (hinting at regex syntax).

Is that enough ? Well, I thought so, until I added a link with href="#" on the example page, a common (not so good) practice to create hooks for obtrusive JavaScript techniques.

So lets guard against this just in case, and find out more about the :not() selector while doing so:

$$('a[href^=#]:not([href=#])')

This rule simply means: find all <a> elements with an href attribute that starts with '#', but that is not equal to '#'. In other words, we’ll simply reject links that don’t point to a valid id.

adding observers

The $$ utility returns an array of DOM elements, we’ll therefore use the power of Enumerables to iterate over this array and set relevant event observers on each one of the elements we have collected:

$$('a[href^=#]:not([href=#])').each(function(element){
  Event.observe(element, 'click', doSomething);
});

Note that, as The $$ utility returns an array of extended elements the following, more object orientated approach also works:

$$('a[href^=#]:not([href=#])').each(function(element){
  element.observe('click', doSomething);
});

What have we done here? We’ve iterated over each element that met the criteria required by our CSS3 rule and have set them to react to an click event by triggering a dummy function doSomething, which we haven’t defined yet. So lets’ have a closer look at that now.

defining our callback function

What do HTML anchors normally do? They simply scroll the viewport so that the element whose id (or name) is defined by the HTML anchor’s href is brought to the top of it. e.g., clicking on a link whose href is set to '#about' will bring the element with id 'about' to the top of the current window.

script.aculo.us’ Effect.ScrollTo does just that, but with a nice and smooth animation that’s not only aesthetically pleasing, but also a lot better UI-wise, as it clearly indicates to the user whether he is being moved further down the page or if he is taken back up.

Let’s pursue:

$$('a[href^=#]:not([href=#])').each(function(element) {
  element.observe('click', function() {
    new Effect.ScrollTo(this.readAttribute('href').substr(1));
  }.bindAsEventListener(element))
});

Now, the only thing we’ve done here is replace our dummy callback function doSomething by the following anonymous function:

function() {
  new Effect.ScrollTo(this.readAttribute('href').substr(1));
}.bindAsEventListener(element)

This requires a bit of explanation. We use Prototype’s Function#bindAsEventListener method to bind the element to the callback function. This enables us to refer to the element by using the this keyword inside the callback function. If you find this hard to understand, don’t worry, it takes a while to sink in. Make sure to read through the doc a couple of times.

Inside the callback function, we’ve created a new instance of Effect.ScrollTo to which we pass the id of the element we want to scroll to (reading the element’s href attribute like so: this.readAttribute('href').substr(1)).

Finding out where to scroll to

This would be quite easy, if it wasn’t for the peculiar way IE handles attributes: this.href will give us the absolute path rather than the relative one (e.g., 'http://tobielangel.com/index.html#main' instead of just '#main').

There a few solutions to handle this issue:

Using Prototype’s Element.readAttribute method. (Note how we use the native string method String#substr to remove the '#' symbol from the start of the string):

this.readAttribute('href').substr(1);

Directly using the href property, splitting it after the hash symbol and grabing the last element of the returned array (thanks to Array#last(http://prototypejs.org/api/array/last)):

this.href.split('#').last();

Or using the native hash property of the element which only returns what’s beyond '#':

this.hash.substr(1);

It’s the solution we’ll settle for (it’s shorter, that’s all).

Done ? not yet.

Preventing default action

Let’s go back to Thomas’ original implementation:

<a href="#about" onclick="new Effect.ScrollTo('about',{offset:-140}); return false">About</a>

Notice the return false statement at the end of the onclick attribute ? It’s there to prevent the link from carrying its default action (following the link) thus ruining your carefully crafted effect.

Luckily, Prototype has a cross-browser method to deal with that: Event.stop. Event.stop takes one argument, the event itself, which you must not forget to pass to your callback function, like so:

function(event) { // <--- don't forget this!
  new Effect.ScrollTo(this.hash.substr(1));
  Event.stop(event);
}.bindAsEventListener(element)

Nearly there

Lets have a look at what we have so far:

$$('a[href^=#]:not([href=#])').each(function(element) {
  element.observe('click', function(event) {
    new Effect.ScrollTo(this.hash.substr(1));
    Event.stop(event);
  }.bindAsEventListener(element))
});

Since we are referencing DOM elements, we have to wait for the document to be fully loaded before this code is evaluated. There are a number of ways to do this. The best, by far, would be to use LowPro’s onReady callback, but as it implies adding yet another library to our page, and as a slightly modified version of onReady will make it into a future release of Prototype (probably version 1.6), we’ll stick to using a more traditional window.onload event.

So, without further ado, this is what our final code will look like:

Event.observe(window, 'load', function() {
  $$('a[href^=#]:not([href=#])').each(function(element) {
    element.observe('click', function(event) {
      new Effect.ScrollTo(this.hash.substr(1));
      Event.stop(event);
    }.bindAsEventListener(element))
  })
});

For the lazy ones amongst you, its available for download right here.

You just need to include it in the head of your document, below prototype.js and effects.js.

<script src="/javascripts/prototype.js" type="text/javascript" charset="utf-8"></script>
<script src="/javascripts/effects.js" type="text/javascript" charset="utf-8"></script>
<script src="/javascripts/auto-scroll.js" type="text/javascript" charset="utf-8"></script>

I’ve also put up a small demo for your scrolling pleasure.

Comments

  • su6z3r0
    su6z3r0 says:

    Thank you Tobie for that very thorough explanation.

    What exactly means “virtually all”? Which CSS3 selectors are not supported?

    Mon, March 12 at 04:35 AM

  • Dasifen
    Dasifen says:

    Thanks for this link. I’m the guy from the prototype google group, fyi. This was incredibly helpful.

    Tue, March 13 at 09:30 AM

  • Mislav
    Mislav says:

    This would make a nice tutorial for pjs.org – why not turn it to one? We need to write more for that section. Practical ideas and uses like these are killer

    Tue, March 13 at 09:30 AM

  • Marco Gomes
    Marco Gomes says:

    Hi, do you know jQuery? It also supports any CSS3 and XPath selectors by a long time ago. I think that it is better than Prototype for my use of JS libs.

    Sorry for bad english.

    from Brazil, Marco Gomes

    Tue, March 13 at 10:08 AM

  • The problem with having Event.stop(event); is that it means that the named fragment isn’t added to the URL so if you send the link to someone else they’ll land at the top of the page. You could set it before stopping with window.location.hash.

    Tue, March 13 at 10:12 AM

  • seb
    seb says:

    I really love this new $$ function, never seen that before. Great job

    Tue, March 13 at 10:15 AM

  • kangax
    kangax says:

    Tobie,

    Invoking Effect.ScrollTo too fast few times results in unpleasant jumping of a page and in the end completely breaks. How would you stop event handling while function is being executed to prevent such behavior?

    Tue, March 13 at 10:21 AM

  • jeroen
    jeroen says:

    kangax,

    Normally you use queues in scriptaculous to prevent such behavior:

    new Effect.ScrollTo(this.hash.substr(1), {queue: ‘end’});

    Untested, but should work. See http://wiki.script.aculo.us/scriptaculous/show/EffectQueues

    Tue, March 13 at 11:02 AM

  • Cristian
    Cristian says:

    Only last night I was looking at the Mootools SmoothScroll but the thought of adding yet another library to my website put me off.

    This is exactly what I was looking for, thanks for such a great article.

    Wed, March 14 at 16:56 PM

  • Andy Baker
    Andy Baker says:

    All I seem to be doing nowadays is leaving comments on other people’s sites saying ‘nice but it breaks the back button’ ;-)

    I know your article wants to keep things simple but you should at least have a warning that there is a major drawback in the implementation shown, before all the copy and paste coders (me included) march off with your script. (Assuming that you agree it’s ‘major’. I certainly would.)

    Any pointers on the easiest way to fix back button support for this kind of technique?

    Thu, March 15 at 07:59 AM

  • Isaiah
    Isaiah says:

    Excellent stuff mate, thanks for such great article :D

    Thu, March 15 at 08:42 AM

  • Alex
    Alex says:

    Is there a way to have this scroll a div instead of the whole page?

    Effect.Scrollto seems to be page only.

    Thu, March 15 at 11:58 AM

  • Alastair
    Alastair says:

    Hey Tobie..,. excellent tutorial. I have just implemented it into a web app I’m working on.

    What is the best way to modify the duration of the scroll?

    Wed, April 04 at 12:06 PM

  • nkraf
    nkraf says:

    hi Tobie, this is so cool! ..thanks so much for putting this up i have a question, can i have it scroll horizontally, instead?

    thank you

    Tue, April 17 at 23:21 PM

  • Alastair
    Alastair says:

    Sorry! Should have just looked at the effect.js file!

    Tue, April 24 at 12:04 PM

  • Natn Smiley
    Natn Smiley says:

    Your CSS3 rule is good.But effect no work.

    Wed, May 09 at 14:39 PM

  • Akash Takyar
    Akash Takyar says:

    Very interesting way to handle Auto-Scrolling. Thanks for sharing.

    Sun, May 13 at 15:29 PM

  • DL
    DL says:

    Very cool (and usable) effect. Thanks!

    Sun, May 20 at 10:25 AM

Comments are closed