{"rowid": 55, "title": "How Tabs Should Work", "contents": "Tabs in browsers (not browser tabs) are one of the oldest custom UI elements in a browser that I can think of. They\u2019ve been done to death. But, sadly, most of the time I come across them, the tabs have been badly, or rather partially, implemented.\nSo this post is my definition of how a tabbing system should work, and one approach of implementing that.\nBut\u2026 tabs are easy, right?\nI\u2019ve been writing code for tabbing systems in JavaScript for coming up on a decade, and at one point I was pretty proud of how small I could make the JavaScript for the tabbing system:\nvar tabs = $('.tab').click(function () {\n tabs.hide().filter(this.hash).show();\n}).map(function () {\n return $(this.hash)[0];\n});\n\n$('.tab:first').click();\nSimple, right? Nearly fits in a tweet (ignoring the whole jQuery library\u2026). Still, it\u2019s riddled with problems that make it a far from perfect solution.\nRequirements: what makes the perfect tab?\n\nAll content is navigable and available without JavaScript (crawler-compatible and low JS-compatible).\nARIA roles.\nThe tabs are anchor links that:\n\nare clickable\nhave block layout\nhave their href pointing to the id of the panel element\nuse the correct cursor (i.e. cursor: pointer).\n\nSince tabs are clickable, the user can open in a new tab/window and the page correctly loads with the correct tab open.\nRight-clicking (and Shift-clicking) doesn\u2019t cause the tab to be selected.\nNative browser Back/Forward button correctly changes the state of the selected tab (think about it working exactly as if there were no JavaScript in place).\n\nThe first three points are all to do with the semantics of the markup and how the markup has been styled. I think it\u2019s easy to do a good job by thinking of tabs as links, and not as some part of an application. Links are navigable, and they should work the same way other links on the page work.\nThe last three points are JavaScript problems. Let\u2019s investigate that.\nThe shitmus test\nLike a litmus test, here\u2019s a couple of quick ways you can tell if a tabbing system is poorly implemented:\n\nChange tab, then use the Back button (or keyboard shortcut) and it breaks\nThe tab isn\u2019t a link, so you can\u2019t open it in a new tab\n\nThese two basic things are, to me, the bare minimum that a tabbing system should have.\nWhy is this important?\nThe people who push their so-called native apps on users can\u2019t have more reasons why the web sucks. If something as basic as a tab doesn\u2019t work, obviously there\u2019s more ammo to push a closed native app or platform on your users.\nIf you\u2019re going to be a web developer, one of your responsibilities is to maintain established interactivity paradigms. This doesn\u2019t mean don\u2019t innovate. But it does mean: stop fucking up my scrolling experience with your poorly executed scroll effects. :breath:\nURI fragment, absolute URL or query string?\nA URI fragment (AKA the # hash bit) would be using mysite.com/config#content to show the content panel. A fully addressable URL would be mysite.com/config/content. Using a query string (by way of filtering the page): mysite.com/config?tab=content.\nThis decision really depends on the context of your tabbing system. For something like GitHub\u2019s tabs to view a pull request, it makes sense that the full URL changes.\nFor our problem though, I want to solve the issue when the page doesn\u2019t do a full URL update; that is, your regular run-of-the-mill tabbing system.\nI used to be from the school of using the hash to show the correct tab, but I\u2019ve recently been exploring whether the query string can be used. The biggest reason is that multiple hashes don\u2019t work, and comma-separated hash fragments don\u2019t make any sense to control multiple tabs (since it doesn\u2019t actually link to anything).\nFor this article, I\u2019ll keep focused on using a single tabbing system and a hash on the URL to control the tabs.\nMarkup\nI\u2019m going to assume subcontent, so my markup would look like this (yes, this is a cat demo\u2026):\n\n\n
\n \n
\n
\n \n
\n
\n \n
\nIt\u2019s important to note that in the markup the link used for an individual tab references its panel content using the hash, pointing to the id on the panel. This will allow our content to connect up without JavaScript and give us a bunch of features for free, which we\u2019ll see once we\u2019re on to writing the code.\nURL-driven tabbing systems\nInstead of making the code responsive to the user\u2019s input, we\u2019re going to exclusively use the browser URL and the hashchange event on the window to drive this tabbing system. This way we get Back button support for free.\nWith that in mind, let\u2019s start building up our code. I\u2019ll assume we have the jQuery library, but I\u2019ve also provided the full code working without a library (vanilla, if you will), but it depends on relatively new (polyfillable) tech like classList and dataset (which generally have IE10 and all other browser support).\nNote that I\u2019ll start with the simplest solution, and I\u2019ll refactor the code as I go along, like in places where I keep calling jQuery selectors.\nfunction show(id) {\n // remove the selected class from the tabs,\n // and add it back to the one the user selected\n $('.tab').removeClass('selected').filter(function () {\n return (this.hash === id);\n }).addClass('selected');\n\n // now hide all the panels, then filter to\n // the one we're interested in, and show it\n $('.panel').hide().filter(id).show();\n}\n\n$(window).on('hashchange', function () {\n show(location.hash);\n});\n\n// initialise by showing the first panel\nshow('#dizzy');\nThis works pretty well for such little code. Notice that we don\u2019t have any click handlers for the user and the Back button works right out of the box.\nHowever, there\u2019s a number of problems we need to fix:\n\nThe initialised tab is hard-coded to the first panel, rather than what\u2019s on the URL.\nIf there\u2019s no hash on the URL, all the panels are hidden (and thus broken).\nIf you scroll to the bottom of the example, you\u2019ll find a \u201ctop\u201d link; clicking that will break our tabbing system.\nI\u2019ve purposely made the page long, so that when you click on a tab, you\u2019ll see the page scrolls to the top of the tab. Not a huge deal, but a bit annoying.\n\nFrom our criteria at the start of this post, we\u2019ve already solved items 4 and 5. Not a terrible start. Let\u2019s solve items 1 through 3 next.\nUsing the URL to initialise correctly and protect from breakage\nInstead of arbitrarily picking the first panel from our collection, the code should read the current location.hash and use that if it\u2019s available.\nThe problem is: what if the hash on the URL isn\u2019t actually for a tab?\nThe solution here is that we need to cache a list of known panel IDs. In fact, well-written DOM scripting won\u2019t continuously search the DOM for nodes. That is, when the show function kept calling $('.tab').each(...) it was wasteful. The result of $('.tab') should be cached.\nSo now the code will collect all the tabs, then find the related panels from those tabs, and we\u2019ll use that list to double the values we give the show function (during initialisation, for instance).\n// collect all the tabs\nvar tabs = $('.tab');\n\n// get an array of the panel ids (from the anchor hash)\nvar targets = tabs.map(function () {\n return this.hash;\n}).get();\n\n// use those ids to get a jQuery collection of panels\nvar panels = $(targets.join(','));\n\nfunction show(id) {\n // if no value was given, let's take the first panel\n if (!id) {\n id = targets[0];\n }\n // remove the selected class from the tabs,\n // and add it back to the one the user selected\n tabs.removeClass('selected').filter(function () {\n return (this.hash === id);\n }).addClass('selected');\n\n // now hide all the panels, then filter to\n // the one we're interested in, and show it\n panels.hide().filter(id).show();\n}\n\n$(window).on('hashchange', function () {\n var hash = location.hash;\n if (targets.indexOf(hash) !== -1) {\n show(hash);\n }\n});\n\n// initialise\nshow(targets.indexOf(location.hash) !== -1 ? location.hash : '');\nThe core of working out which tab to initialise with is solved in that last line: is there a location.hash? Is it in our list of valid targets (panels)? If so, select that tab.\nThe second breakage we saw in the original demo was that clicking the \u201ctop\u201d link would break our tabs. This was due to the hashchange event firing and the code didn\u2019t validate the hash that was passed. Now this happens, the panels don\u2019t break.\nSo far we\u2019ve got a tabbing system that:\n\nWorks without JavaScript.\nSupports right-click and Shift-click (and doesn\u2019t select in these cases).\nLoads the correct panel if you start with a hash.\nSupports native browser navigation.\nSupports the keyboard.\n\nThe only annoying problem we have now is that the page jumps when a tab is selected. That\u2019s due to the browser following the default behaviour of an internal link on the page. To solve this, things are going to get a little hairy, but it\u2019s all for a good cause.\nRemoving the jump to tab\nYou\u2019d be forgiven for thinking you just need to hook a click handler and return false. It\u2019s what I started with. Only that\u2019s not the solution. If we add the click handler, it breaks all the right-click and Shift-click support.\nThere may be another way to solve this, but what follows is the way I found \u2013 and it works. It\u2019s just a bit\u2026 hairy, as I said.\nWe\u2019re going to strip the id attribute off the target panel when the user tries to navigate to it, and then put it back on once the show code starts to run. This change will mean the browser has nowhere to navigate to for that moment, and won\u2019t jump the page.\nThe change involves the following:\n\nAdd a click handle that removes the id from the target panel, and cache this in a target variable that we\u2019ll use later in hashchange (see point 4).\nIn the same click handler, set the location.hash to the current link\u2019s hash. This is important because it forces a hashchange event regardless of whether the URL actually changed, which prevents the tabs breaking (try it yourself by removing this line).\nFor each panel, put a backup copy of the id attribute in a data property (I\u2019ve called it old-id).\nWhen the hashchange event fires, if we have a target value, let\u2019s put the id back on the panel.\n\nThese changes result in this final code:\n/*global $*/\n\n// a temp value to cache *what* we're about to show\nvar target = null;\n\n// collect all the tabs\nvar tabs = $('.tab').on('click', function () {\n target = $(this.hash).removeAttr('id');\n\n // if the URL isn't going to change, then hashchange\n // event doesn't fire, so we trigger the update manually\n if (location.hash === this.hash) {\n // but this has to happen after the DOM update has\n // completed, so we wrap it in a setTimeout 0\n setTimeout(update, 0);\n }\n});\n\n// get an array of the panel ids (from the anchor hash)\nvar targets = tabs.map(function () {\n return this.hash;\n}).get();\n\n// use those ids to get a jQuery collection of panels\nvar panels = $(targets.join(',')).each(function () {\n // keep a copy of what the original el.id was\n $(this).data('old-id', this.id);\n});\n\nfunction update() {\n if (target) {\n target.attr('id', target.data('old-id'));\n target = null;\n }\n\n var hash = window.location.hash;\n if (targets.indexOf(hash) !== -1) {\n show(hash);\n }\n}\n\nfunction show(id) {\n // if no value was given, let's take the first panel\n if (!id) {\n id = targets[0];\n }\n // remove the selected class from the tabs,\n // and add it back to the one the user selected\n tabs.removeClass('selected').filter(function () {\n return (this.hash === id);\n }).addClass('selected');\n\n // now hide all the panels, then filter to\n // the one we're interested in, and show it\n panels.hide().filter(id).show();\n}\n\n$(window).on('hashchange', update);\n\n// initialise\nif (targets.indexOf(window.location.hash) !== -1) {\n update();\n} else {\n show();\n}\nThis version now meets all the criteria I mentioned in my original list, except for the ARIA roles and accessibility. Getting this support is actually very cheap to add.\nARIA roles\nThis article on ARIA tabs made it very easy to get the tabbing system working as I wanted.\nThe tasks were simple:\n\nAdd aria-role set to tab for the tabs, and tabpanel for the panels.\nSet aria-controls on the tabs to point to their related panel (by id).\nI use JavaScript to add tabindex=0 to all the tab elements.\nWhen I add the selected class to the tab, I also set aria-selected to true and, inversely, when I remove the selected class I set aria-selected to false.\nWhen I hide the panels I add aria-hidden=true, and when I show the specific panel I set aria-hidden=false.\n\nAnd that\u2019s it. Very small changes to get full sign-off that the tabbing system is bulletproof and accessible.\nCheck out the final version (and the non-jQuery version as promised).\nIn conclusion\nThere\u2019s a lot of tab implementations out there, but there\u2019s an equal amount that break the browsing paradigm and the simple linkability of content. Clearly there\u2019s a special hell for those tab systems that don\u2019t even use links, but I think it\u2019s clear that even in something that\u2019s relatively simple, it\u2019s the small details that make or break the user experience.\nObviously there are corners I\u2019ve not explored, like when there\u2019s more than one set of tabs on a page, and equally whether you should deliver the initial markup with the correct tab selected. I think the answer lies in using query strings in combination with hashes on the URL, but maybe that\u2019s for another year!", "year": "2015", "author": "Remy Sharp", "author_slug": "remysharp", "published": "2015-12-22T00:00:00+00:00", "url": "https://24ways.org/2015/how-tabs-should-work/", "topic": "code"}