jQuery Plugin: Table of Contents with Smooth Scrolling

Hey guys, have you noticed that pretty box on WordPress codex that gives us a preview about what we can see on a page? So, I haven’t seen too many blogs use this kind of feature and it is really useful for our readers, since they can just skip to the content that they are interested in and avoid wasting time. Wikipedia has a table of contents that makes it easier for readers to skip around, right?

I’m not the very first to do something like this with jQuery. But our goal in here is to develop a complete jQuery plugin, from start to finish, with options, and that is easy to customize. And, of course, something that I hope is useful to you

So, let’s rock!

STOC – Smooth Table of Contents jQuery plugin

Since there are a lot of “tocs” around the web, our plugin will be called STOC and the main features are:

  • Automatically adds the table of contents to target element
  • You can select to search just a part of you page
  • You can select what is the first heading we will have to search (h1, h2…)
  • You can select the “depth” of the search
  • SubItems are made of sublists inside parent item
  • You can select which text will display before the table (title)
  • You can select whether ol or ul to you listing
  • You can enable / disable smooth scrolling

Here is how it should look in our demo:

Here’s a running Demo. You can also download all files here.

Planning and planning – Before code, let’s think about it

The main idea is to have a jQuery plugin that generates a table of content inside the target element. To have this working we need some basic customization with these options:

  • Where to search – If the table is generated based on entire page, just a section content
  • Depth of H’s – How many “levels” of titles we will have in our search
  • Start tag – Which level of heading will be the first on set (h1, h2, h3)
  • Title if the box – What to display as box’s title
  • List type – whether to have ordered or unordered list

The hardest thing when we are doing something just for fun is to define the scope. Actually it is hard too when we have a “real” project, but when it comes to pet projects it is harder because you just can’t measure accurately what will bring you the expected revenue (fun).

So, what do I do in these cases is list anything that I could do on it, and just cut down what will take too much time and will not be so good to do.

In this case, for example I listed these features:

  • Customizable via options – I think this one was essential, so I just kept it
  • Smooth scrolling - This one I didn’t see in any other plugin / snippet. It would be good to have, so I kept it.
  • Accordion for hierarchy - I found this idea really cool, but useless, I drop it.
  • Preview of the text on hover - I’ve stolen this idea of one site but actually didn’t find it useful also, so I drop it.

So what I’ve done here is to define which of the cool features had big potential to be a waste of time. Even if I had more time to code I would never use them, just because they haven’t the expected benefit (fun x time).

Now that we now what we want to do, let’s start to code.

Basic plugin structure with options

First we need to create our file. The standard for jQuery plugins files names is jQuery.PLUGINNAME.js so, our file will be jquery.stoc.js.

We also have all our options defined above, so we need now to save a variable for each one of them, so our user can send his own parameters.

Here is our commented code:

/*
This line creates our function "wrapped" by jQuery container, so we won't have any problem with others libraries
*/
(function($){
/*
Here the standard is $.fn.PLUGINNAME. so when we call $(element).stoc() jquery will run this code
Pay attention that we pass options to our funcion, so when user defines it we can extend our plugin
*/
	$.fn.stoc = function(options) {
	//Our default options
	var defaults = {
		search: "body", //where we will search for titles
		depth: 6, //how many hN should we search
		start: 1, //which hN will be the first (and after it we go just deeper)
		stocTitle: "<h2>Contents</h2>", //what to display before our box
		listType: "ul", //could be ul or ol
		smoothScroll: 1
	};

	//let's extend our plugin with default or user options when defined
	var options = $.extend(defaults, options);

	return this.each(function() {
		//our functions here
		alert("I'm a beta tester alert box!");
	});
};
})(jQuery);

Let’s try a simple demo to see it working. Create this HTML in same folder as our plugin:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"><head>
	<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
	<title>Smooth Table Of Contents jQuery plugin - DEMO</title>
	<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js"></script>
	<script type="text/javascript" src="jquery.stoc.js"></script>
	<script type="text/javascript">
		$(function(){
			$("#items").stoc();
		});
	</script>
	<style type="text/css">
		body {
			background: #fafafa url(handmadepaper.png); //via subtlepatterns
		}
			#container {
				position: relative;
				top: 50px;
				width: 960px;
				margin: 0 auto;
				padding-bottom: 20px;
			}
			#container p, #container h1, #container h2, #container h3, #container h4, #container h5 {
				font-family: "arial";
				padding: 10px 20px 0;
				margin: 0
			}
			#items {
				float: right;
				width: 260px;
				padding-bottom: 10px;
				margin:0 0 10px 20px;
				/* rgba with ie compatibility */
				background-color: transparent;
				background-color: rgba(255,255,255,0.4);
				filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#20ffffff,endColorstr=#20ffffff);
				-ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#20ffffff,endColorstr=#20ffffff)";
			}
				#items ul {
					margin: 0 0 0 20px;
					padding: 0 0 5px;
					list-style-type: none;
				}
					#items ul ul {
						font-size: 90%;
					}
				#items ul a {
					font-family: "arial";
					text-decoration: none;
					color: #c10000;
				}
					#items ul a:hover { color: #ff0000 }
	</style>
</head>
<body id="page-1">
	<div id="container">
		<div id="items">
		</div>
		<h1>1 - Phasellus vulputate</h1>
		<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas metus est, egestas vel aliquet at, pellentesque nec lorem. Pellentesque molestie bibendum eros, eu suscipit nisi volutpat fringilla. Vivamus fringilla nisl ut ante commodo porta. Morbi ipsum nunc, sollicitudin ac pretium pretium, iaculis vel enim. Nulla cursus porta orci, sed vulputate magna feugiat et. Aliquam nibh massa, pharetra tincidunt vehicula ac, pellentesque vitae nibh. In lobortis semper eros fermentum pretium. Sed posuere, urna eget ornare luctus, mi lectus lacinia leo, sit amet faucibus orci ipsum sit amet ipsum. Maecenas sapien neque, ultrices a lacinia sit amet, fermentum non enim. Integer at venenatis orci. In hac habitasse platea dictumst.</p>
		[... lot of more lipsum text with h's here]
	</div>
</body>
</html>

If you create this file, when you load it you should see our pretty beta tester alert. So our plugin is being called (make sure that you add jquery before it, as I’ve done including api.googleapis…). if you want to overwrite any option defined before, you just have to pass it as .stoc({ OPTIONNAME: VALUE }) instead of just .stoc(). For example, to define our search just in #container, add .stoc({ search: "#container" }).

How should we select our headings?

Now we have to prepare our plugin to get all h’s that we have to (based in our options). What we can do is get all the headings we have to, and when we loop through each one of them we will discover which level it is. I think it is easier than trying to get the whole hierarchy for each “tree” of headings.

Since our current object can change as we run our code, we have also to “cache” our current object so we will always now which object we are modifying. Our code now will be:

   return this.each(function() {
		//"cache" our target and search objects
		obj = $(this); //target
		src = $(options.search); //search
		//let's declare some variables. We need this var declaration to create them as local variables (not global)
		var appHTML = "", tagNumber = 0, txt = "", id = "", before = "", after = "", previous = options.start, start = options.start, depth = options.depth, i = 0, srcTags = "h" + options.start, cacheHN = "";

		//which tags we will search
		while ( depth > 1) {
			start++; //we will just get our start level and numbers higher than it
			srcTags = srcTags + ", h" + start;
			depth--; //since went one level up, our depth will go one level down
		}

    });

If you alert you srcTags you will see something like this “h1, h2, h3, h4, h5, h6″. This is what we will pass to jQuery as the elements that we want to search for.

Building our table

We have all our elements, what we have to do is run a function on each one of them with the wonderful each() jQuery function.

Inside each element, we need to:

  • Know which level the current element is (tagNumber)
  • Set one id to this element, if it doesn’t have one
  • Get the elements text
  • Test if is its level is lower, higher or equal than previous element and open / close ul’s based on this
    • If element number is higher than previous means that we went down one level (e.g. from h2 to h3)
    • If element number is equals to previous means that we stay on same level (e.g. h4)
    • If element number is lower than previous means that we went up, but we don’t know how many levels (e.g. from h4 to h1)
  • Append element HTML to our target item

We also have to correct the last item because if it is not top-level it will let some uls open, and we don’t want it :D

Our commented code now will be:

/* our setup stuff here */
/*inside our return function */
	//which tags we will search
		while ( depth > 1) {
			start++; //we will just get our start level and numbers higher than it
			srcTags = srcTags + ", h" + start;
			depth--; //since went one level up, our depth will go one level down
		}
		src.find(srcTags).each(function() {
			//we will cache our current H element
			cacheHN = $(this);
			//if we are on h1, 2, 3...
			tagNumber = ( cacheHN.get(0).tagName ).substr(1);

			//sets the needed id to the element
			id = cacheHN.attr('id');
			if (id == "") { //if it doesn't have only, of course
				id = "h" + tagNumber + "_" + i;
				cacheHN.attr('id', id);
			}
			//our current text
			txt = cacheHN.text();

			switch(true) { //with switch(true) we can do comparisons in each case
				case (tagNumber > previous) : //it means that we went down one level (e.g. from h2 to h3)
						appHTML = appHTML + "<" + options.listType +"><li>"+ before +"<a href=\"#"+ id + "\">" + txt + "</a>";
						previous = tagNumber;
					break;
				case (tagNumber == previous) : //it means that stay on the same level (e.g. h3 and stay on it)
						appHTML = appHTML + "</li><li>"+ before +"<a href=\"#"+ id + "\">" + txt +  "</a>";
					break;
				case (tagNumber < previous) : //it means that we went up but we don't know how much levels  (e.g. from h3 to h2)
						while(tagNumber != previous) {
							appHTML = appHTML + "</" + options.listType +"></li>";
							previous--;
						}
						appHTML = appHTML + "<li>"+ before +"<a href=\"#"+ id + "\">" + txt + "</a></li>";
					break;
			}
			i++;
		});
		//corrects our last item, because it may have some opened ul's
		while(tagNumber != options.start) {
			appHTML = appHTML + "</" + options.listType +">";
			tagNumber--;
		}
		//append our html to our object
		appHTML = options.stocTitle + "<"+ options.listType + ">" + appHTML + "</" + options.listType + ">";
		obj.append(appHTML);

How to Make our STOC smoother

I’ve stolen CSS trick’s smooth scroll code, but I hope they don’t mind :D

What we have to do here is just put this (compressed) function to load when our smooth scroll in on (if the user doesn’t set it as 0).

/*all code above in here*/
	//append our html to our object
		appHTML = options.stocTitle + "<"+ options.listType + ">" + appHTML + "</" + options.listType + ">";
		obj.append(appHTML);

		//our pretty smooth scrolling here
		// acctually I've just compressed the code so you guys will think that I'm the man . Source: http://css-tricks.com/snippets/jquery/smooth-scrolling/
		if (options.smoothScroll == 1) {
			$(window).load(function(){
				function filterPath(string){return string.replace(/^\//,'').replace(/(index|default).[a-zA-Z]{3,4}$/,'').replace(/\/$/,'')}var locationPath=filterPath(location.pathname);var scrollElem=scrollableElement('html','body');obj.find('a[href*=#]').each(function(){var thisPath=filterPath(this.pathname)||locationPath;if(locationPath==thisPath&&(location.hostname==this.hostname||!this.hostname)&&this.hash.replace(/#/,'')){var $target=$(this.hash),target=this.hash;if(target){var targetOffset=$target.offset().top;$(this).click(function(event){event.preventDefault();$(scrollElem).animate({scrollTop:targetOffset},400,function(){location.hash=target})})}}});function scrollableElement(els){for(var i=0,argLength=arguments.length;i<argLength;i++){var el=arguments[i],$scrollElement=$(el);if($scrollElement.scrollTop()>0){return el}else{$scrollElement.scrollTop(1);var isScrollable=$scrollElement.scrollTop()>0;$scrollElement.scrollTop(0);if(isScrollable){return el}}}return[]}
			});
		}

Our final result is this:

(function($){
 $.fn.stoc = function(options) {
	//Our default options
	var defaults = {
		search: "body", //where we will search for titles
		depth: 6, //how many hN should we search
		start: 1, //which hN will be the first (and after it we go just deeper)
		stocTitle: "<h2>Contents</h2>", //what to display before our box
		listType: "ul", //could be ul or ol
		smoothScroll: 1
	};

	//let's extend our plugin with default or user options when defined
	var options = $.extend(defaults, options);

    return this.each(function() {
		//"cache" our target and search objects
		obj = $(this); //target
		src = $(options.search); //search
		//let's declare some variables. We need this var declaration to create them as local variables (not global)
		var appHTML = "", tagNumber = 0, txt = "", id = "", before = "", after = "", previous = options.start, start = options.start, depth = options.depth, i = 0, srcTags = "h" + options.start, cacheHN = "";

		//which tags we will search
		while ( depth > 1) {
			start++; //we will just get our start level and numbers higher than it
			srcTags = srcTags + ", h" + start;
			depth--; //since went one level up, our depth will go one level down
		}
		src.find(srcTags).each(function() {
			//we will cache our current H element
			cacheHN = $(this);
			//if we are on h1, 2, 3...
			tagNumber = ( cacheHN.get(0).tagName ).substr(1);

			//sets the needed id to the element
			id = cacheHN.attr('id');
			if (id == "") { //if it doesn't have only, of course
				id = "h" + tagNumber + "_" + i;
				cacheHN.attr('id', id);
			}
			//our current text
			txt = cacheHN.text();

			switch(true) { //with switch(true) we can do comparisons in each case
				case (tagNumber > previous) : //it means that we went down one level (e.g. from h2 to h3)
						appHTML = appHTML + "<" + options.listType +"><li>"+ before +"<a href=\"#"+ id + "\">" + txt + "</a>";
						previous = tagNumber;
					break;
				case (tagNumber == previous) : //it means that stay on the same level (e.g. h3 and stay on it)
						appHTML = appHTML + "</li><li>"+ before +"<a href=\"#"+ id + "\">" + txt +  "</a>";
					break;
				case (tagNumber < previous) : //it means that we went up but we don't know how much levels  (e.g. from h3 to h2)
						while(tagNumber != previous) {
							appHTML = appHTML + "</" + options.listType +"></li>";
							previous--;
						}
						appHTML = appHTML + "<li>"+ before +"<a href=\"#"+ id + "\">" + txt + "</a></li>";
					break;
			}
			i++;
		});
		//corrects our last item, because it may have some opened ul's
		while(tagNumber != options.start) {
			appHTML = appHTML + "</" + options.listType +">";
			tagNumber--;
		}
		//append our html to our object
		appHTML = options.stocTitle + "<"+ options.listType + ">" + appHTML + "</" + options.listType + ">";
		obj.append(appHTML);

		//our pretty smooth scrolling here
		// acctually I've just compressed the code so you guys will think that I'm the man . Source: http://css-tricks.com/snippets/jquery/smooth-scrolling/
		if (options.smoothScroll == 1) {
			$(window).load(function(){
				function filterPath(string){return string.replace(/^\//,'').replace(/(index|default).[a-zA-Z]{3,4}$/,'').replace(/\/$/,'')}var locationPath=filterPath(location.pathname);var scrollElem=scrollableElement('html','body');obj.find('a[href*=#]').each(function(){var thisPath=filterPath(this.pathname)||locationPath;if(locationPath==thisPath&&(location.hostname==this.hostname||!this.hostname)&&this.hash.replace(/#/,'')){var $target=$(this.hash),target=this.hash;if(target){var targetOffset=$target.offset().top;$(this).click(function(event){event.preventDefault();$(scrollElem).animate({scrollTop:targetOffset},400,function(){location.hash=target})})}}});function scrollableElement(els){for(var i=0,argLength=arguments.length;i<argLength;i++){var el=arguments[i],$scrollElement=$(el);if($scrollElement.scrollTop()>0){return el}else{$scrollElement.scrollTop(1);var isScrollable=$scrollElement.scrollTop()>0;$scrollElement.scrollTop(0);if(isScrollable){return el}}}return[]}
			});
		}
    });
 };
})(jQuery);

Are you hungry yet?

What about you help me with some improvements on this plugin? Do you have any tip? Anything that you can think of a better way to do?

Do you have any other features in mind? Or even another plugin idea that you’d like to see a tutorial on?

Share your thoughts with us! :D

Rochester Oliveira

I'm a web designer and entrepreneur from Itajubá (MG), Brasil. I love writing about obscure topics and doing some cool stuff. And also I do some FREE stuff, check it out: http://www.roch.com.br/

15 Smart Tools To Help You Build Your Freelance Business

Discover the awesome tools we use in making our clients comfortable and happy in learning new things every day.

Download Now

Comments

  1. says

    Hey,

    I fixed a bug in your plugin where it went to an infinite loop at this line:

    while(tagNumber != options.start) {
    appHTML = appHTML + “”;
    tagNumber–;
    }

    The tagnumber went under 0 so this loop never stopped and made the browsers to crash. You can find the fix here:
    https://gist.github.com/2719592

  2. Diego says

    sorry.
    Here is the code

    case (tagNumber < previous) : //it means that we went up but we don't know how much levels (e.g. from h3 to h2)
    appHTML = appHTML + "";
    while(tagNumber != previous) {
    appHTML = appHTML + "";
    previous--;
    }
    appHTML = appHTML + ""+ before +"" + txt + "";
    break;

    • Diego says

      Here i go again…

      case (tagNumber < previous) : //it means that we went up but we don't know how much levels (e.g. from h3 to h2)
      appHTML = appHTML + "”;
      while(tagNumber != previous) {
      appHTML = appHTML + “”;
      previous–;
      }
      appHTML = appHTML + “”+ before +”lessThan a h ref=\”#”+ id + “\”>” + txt + “Lessthan/a>”;
      break;

  3. Robin says

    Any chance of fixing this for the latest version of JQuery? The link come back as undefined and smooth scroll doesn’t work.

  4. says

    Great plugin!

    The best part about it is that there is nothing you have to hardcode. So, as long as there are H1s on the page, you are good to go :)

    Thank you so much!

  5. Alex C says

    Documented how to use your amazing plugin with SharePoint. Thanks again for this great article and the code :)

    • says

      Hi Alex,

      I’ve read it, and you won’t believe how glad I am. You really improved it, and your article is pretty well explained.

      Thanks A LOT!
      []’s
      Rochester

  6. Alex C says

    For some reason, on SharePoint 2010, it was appending “#undefined” to all the table of contents items. So I modified the JS to this:
    if (id == "" || typeof id === "undefined") { //if it doesn't have only, of course
    id = "h" + tagNumber + "_" + i;
    cacheHN.attr('id', id);
    }

    It now works! Just in case anyone else runs into this issue.

  7. Alex C says

    Amazing stuff! Thanks for sharing this. I was looking to add this to a SharePoint publishing page and thought of creating from scratch until I came across this. Very nice. Thank you for creating this :)

  8. says

    Thanks for sharing, it’s a very useful technique. I just want to add that a scroll to top will be a nice feature. But there’s so many options to implement this… it’s not a problem.

    I spent several years making internal manuals in various companies, and online classes too, it’s like making a mini-site with huge individual pages. I found that a non-fix vertical menu on a left sidebar was the more practical and intuitive way to implement a table of content. You want your reader focused on its reading, so the wiki format isn’t, in my feel, the best way to help somebody to learn and memorize something new. If you sum to this some tool-tips and modal windows, and your table of content, you have a fantastic and powerful tool for teaching.
    Once again, thanks for sharing this.

    • says

      Hey egiova,

      As Abdelhadi said below it would be really helpful. It is pretty easy to add it, is just append some code (like this “top” link) while you are in each() loop.

      I agree with you, this must to be the best way to wiki-like sites, and I think it is really helpful for tutorials or roundups, so readers can go and read just what they want to learn (like if you are in a jquery plugins roundup and just want to see the sliders)..

      Thank you, again!
      []’s

  9. Julian says

    nice code,and good description to develop own jquery plugins. very
    usefull, thanks!

  10. Abdelhadi Touil says

    Very nice plugin; thanks very much for sharing.
    I think it’ll be better if it adds automatically a “top” link ine the of each section. What do you think?
    (Sorry for my bad English)

    • says

      Hey Abdelhadi, It is a good idea, and pretty simple do do, since our smooth scrolling can apply to this link also..

      Thank you for the insight!
      []’s

    • says

      Hey wesley, I think we missed it. MIT is ok to me, since you can do anything you want with this code and it has no warranty.

      Thank you for pointing it out, next time I’ll include this in our .zip !

      []’s