A List Apart

Menu
Issue № 399

Radio-Controlled Web Design

by Published in CSS, HTML, JavaScript · 18 Comments

Interactive user interfaces are a necessity in our responsive world. Smaller screens constrain the amount of content that can be displayed at any given time, so we need techniques to keep navigation and secondary information out of the way until they’re needed. From tabs and modal overlays to hidden navigation, we’ve created many powerful design patterns that show and hide content using JavaScript.

Article Continues Below

JavaScript comes with its own mobile challenges, though. Network speeds and data plans vary wildly, and every byte we deliver has an impact on the render speed of our pages or applications. When we add JavaScript to a page, we’re typically adding an external JavaScript file and an optional (usually large) library like jQuery. These interfaces won’t become usable until all the content, JavaScript files included, is downloaded—creating a slow and sluggish first impression for our users.

If we could create these content-on-demand patterns with no reliance on JavaScript, our interfaces would render earlier, and users could interact with them as soon as they were visible. By shifting some of the functionality to CSS, we could also reduce the amount of JavaScript needed to render the rest of our page. The result would be smaller file sizes, faster page-load times, interfaces that are available earlier, and the same functionality we’ve come to rely on from these design patterns.

In this article, I’ll explore a technique I’ve been working on that does just that. It’s still a bit experimental, so use your best judgment before using it in your own production systems.

Understanding JavaScript’s role in maintaining state

To understand how to accomplish these design patterns without JavaScript at all, let’s first take a look at the role JavaScript plays in maintaining state for a simple tabbed interface.

See the demo: Show/hide example

Let’s take a closer look at the underlying code.

<div class="js-tabs">

    <div class="tabs">
        <a href="#starks-panel" id="starks-tab"
            class="tab active">Starks</a>
        <a href="#lannisters-panel" id="lannisters-tab"
            class="tab">Lannisters</a>
        <a href="#targaryens-panel" id="targaryens-tab"
            class="tab">Targaryens</a>
    </div>

    <div class="panels">
        <ul id="starks-panel" class="panel active">
            <li>Eddard</li>
            <li>Caitelyn</li>
            <li>Robb</li>
            <li>Sansa</li>
            <li>Brandon</li>
            <li>Arya</li>
            <li>Rickon</li>
        </ul>
        <ul id="lannisters-panel" class="panel">
            <li>Tywin</li>
            <li>Cersei</li>
            <li>Jamie</li>
            <li>Tyrion</li>
        </ul>
        <ul id="targaryens-panel" class="panel">
            <li>Viserys</li>
            <li>Daenerys</li>
        </ul>
    </div>

</div>

Nothing unusual in the layout, just a set of tabs and corresponding panels that will be displayed when a tab is selected. Now let’s look at how tab state is managed by altering a tab’s class:

...

.js-tabs .tab {
    /* inactive styles go here */
}
.js-tabs .tab.active {
    /* active styles go here */
}

.js-tabs .panel {
    /* inactive styles go here */
}
.js-tabs .panel.active {
    /* active styles go here */
}

...

Tabs and panels that have an active class will have additional CSS applied to make them stand out. In our case, active tabs will visually connect to their content while inactive tabs remain separate, and active panels will be visible while inactive panels remain hidden.

At this point, you’d use your preferred method of working with JavaScript to listen for click events on the tabs, then manipulate the active class, removing it from all tabs and panels and adding it to the newly clicked tab and corresponding panel. This pattern is pretty flexible and has worked well for a long time. We can simplify what’s going on into two distinct parts:

  1. JavaScript binds events that manipulate classes.
  2. CSS restyles elements based on those classes.

State management without JavaScript

Trying to replicate event binding and class manipulation in CSS and HTML alone would be impossible, but if we define the process in broader terms, it becomes:

  1. User input changes the system’s active state.
  2. The system is re-rendered when the state is changed.

In our HTML- and CSS-only solution, we’ll use radio buttons to allow the user to manipulate state, and the :checked pseudo-class as the hook to re-render.

The solution has its roots in Chris Coyier’s checkbox hack, which I was introduced to via my colleague Scott O’Hara in his morphing menu button demo. In both cases, checkbox inputs are used to maintain two states without JavaScript by styling elements using the :checked pseudo-class. In this case, we’ll be using radio buttons to increase the number of states we can maintain beyond two.

Wait, radio buttons?

Using radio buttons to do something other than collect form submission data may make some of you feel a little uncomfortable, but let’s look at what the W3C says about input use and see if we can ease some concerns:

The <input> element represents a typed data field, usually with a form control to allow the user to edit the data. (emphasis mine)

“Data” is a pretty broad term—it has to be to cover the multitude of types of data that forms collect. We’re allowing the user to edit the state of a part of the page. State is just data about that part of the page at any given time. This may not have been the intended use of <input>, but we’re holding true to the specification.

The W3C also states that inputs may be rendered wherever “phrasing content” can be used, which is basically anywhere you could put standalone text. This allows us to use radio buttons outside of a form.

Radio-controlled tabs

So now that we know a little more about whether we can use radio buttons for this purpose, let’s dig into an example and see how they can actually remove or reduce our dependency on JavaScript by modifying the original tabs example.

Add radio buttons representing state

Each radio button will represent one state of the interactive component. In our case, we have three tabs and each tab can be active, so we need three radio buttons, each of which will represent a particular tab being active. By giving the radio buttons the same name, we’ll ensure that only one may be checked at any time. Our JavaScript example had the first tab active initially, so we can add the checked attribute to the radio button representing the first tab, indicating that it is currently active.

Because CSS selectors can only style sibling or child selectors based on the state of another element, these radio buttons must come before any content that needs to be visually manipulated. In our case, we’ll put our radio buttons just before the tabs div:

    <input class="state" type="radio" name="houses-state"
        id="starks" checked />
    <input class="state" type="radio" name="houses-state"
        id="lannisters" />
    <input class="state" type="radio" name="houses-state"
        id="targaryens" />

    <div class="tabs">
    ...

Replace click and touch areas with labels

Labels naturally respond to click and touch events. We can’t tell them how to react to those events, but the behavior is predictable and we can leverage it. When a label associated with a radio button is clicked or touched, the radio button is checked while all other radio buttons in the same group are unchecked.

By setting the for attribute of our labels to the id of a particular radio button, we can place labels wherever we need them while still inheriting the touch and click behavior.

Our tabs were represented with anchors in the earlier example. Let’s replace them with labels and add for attributes to wire them up to the correct radio buttons. We can also remove the active class from the tab and panel as the radio buttons will be maintaining state:

...
    <input class="state" type="radio" title="Targaryens"
        name="houses-state" id="targaryens" />

    <div class="tabs">
        <label for="starks" id="starks-tab"
            class="tab">Starks</label>
        <label for="lannisters" id="lannisters-tab"
            class="tab">Lannisters</label>
        <label for="targaryens" id="targaryens-tab"
            class="tab">Targaryens</label>
    </div>

    <div class="panels">
...

Hide radio buttons with CSS

Now that our labels are in place, we can safely hide the radio buttons. We still want to keep the tabs keyboard accessible, so we’ll just move the radio buttons offscreen:

...

.radio-tabs .state {
    position: absolute;
    left: -10000px;
}

...

Style states based on :checked instead of .active

The :checked pseudo-class allows us to apply CSS to a radio button when it is checked. The sibling selector ~ allows us to style elements that follow an element in the same level. Combined, we can style anything after the radio buttons based on the buttons’ state.

The pattern is #radio:checked ~ .something-after-radio or optionally #radio:checked ~ .something-after-radio .something-nested-deeper:

...

.tab {
    ...
}
#starks:checked ~ .tabs #starks-tab,
#lannisters:checked ~ .tabs #lannisters-tab,
#targaryens:checked ~ .tabs #targaryens-tab {
    ...
}

.panel {
    ...
}
#starks:checked ~ .panels #starks-panel,
#lannisters:checked ~ .panels #lannisters-panel,
#targaryens:checked ~ .panels #targaryens-panel {
    ...
}

...

Now when the tab labels are clicked, the appropriate radio button will be checked, which will style the correct tab and panel as active. The result:

See the demo: Show/hide example

Browser support

The requirements for this technique are pretty low. As long as a browser supports the :checked pseudo-class and ~ sibling selector, we’re good to go. Firefox, Chrome, and mobile Webkit have always supported these selectors. Safari has had support since version 3, and Opera since version 9. Internet Explorer started supporting the sibling selector in version 7, but didn’t add support for :checked until IE9. Android supports :checked but has a bug which impedes it from being aware of changes to a checked element after page load.

That’s decent support, but with a little extra work we can get Android and older IE working as well.

Fixing the Android 2.3 :checked bug

In some versions of Android, :checked won’t update as the state of a radio group changes. Luckily, there’s a fix for that involving a webkit-only infinite animation on the body, which Tim Pietrusky points out in his advanced checkbox hack:

...

/* Android 2.3 :checked fix */
@keyframes fake {
    from {
        opacity: 1;
    }
    to {
        opacity: 1
    }
}
body {        
    animation: fake 1s infinite;
}

...

JavaScript shim for old Internet Explorer

If you need to support IE7 and IE8, you can add this shim to the bottom of your page in a script tag:

document.getElementsByTagName('body')[0]
.addEventListener('change', function (e) {
    var radios, i;
    if (e.target.getAttribute('type') === 'radio') {
        radios = document.querySelectorAll('input[name="' +
            e.target.getAttribute('name') + '"]');
        for (i = 0; i < radios.length; i += 1) {
            radios[ i ].className = 
                radios[ i ].className.replace(
                    /(^|\s)checked(\s|$)/,
                    ' '
                );
            if (radios[ i ] === e.target) {
                radios[ i ].className += ' checked';
            }
        }
    }
});

This adds a checked class to the currently checked radio button, allowing you to double up your selectors and keep support. Your selectors would have to be updated to include :checked and .checked versions like this:

...

.tab {
    ...
}
#starks:checked ~ .tabs #starks-tab,
#starks.checked ~ .tabs #starks-tab,
#lannisters:checked ~ .tabs #lannisters-tab,
#lannisters.checked ~ .tabs #lannisters-tab,
#targaryens:checked ~ .tabs #targaryens-tab,
#targaryens.checked ~ .tabs #targaryens-tab {
    ...
}

.panel {
    ...
}
#starks:checked ~ .panels #starks-panel,
#starks.checked ~ .panels #starks-panel,
#lannisters:checked ~ .panels #lannisters-panel,
#lannisters.checked ~ .panels #lannisters-panel,
#targaryens:checked ~ .panels #targaryens-panel,
#targaryens.checked ~ .panels #targaryens-panel {
    ...
}

...

Using an inline script still saves a potential http request and speeds up interactions on newer browsers. When you choose to drop IE7 and IE8 support, you can drop the shim without changing any of your code.

Maintaining accessibility

While our initial JavaScript tabs exhibited the state management between changing tabs, a more robust example would use progressive enhancement to change three titled lists into tabs. It should also handle adding all the ARIA roles and attributes that screen readers and other assistive technologies use to navigate the contents of a page. A better JavaScript example might look like this:

See the demo: Show/hide example

Parts of the HTML are removed and will now be added by additional JavaScript; new HTML has been added and will be hidden by additional JavaScript; and new CSS has been added to manage the pre-enhanced and post-enhanced states. In general, our code has grown by a good amount.

In order to support ARIA, particularly managing the aria-selected state, we’re going to have to bring some JavaScript back into our radio-controlled tabs. However, the amount of progressive enhancement we need to do is greatly reduced.

If you aren’t familiar with ARIA or are a little rusty, you may wish to refer to the ARIA Authoring Practices for tabpanel.

Adding ARIA roles and attributes

First, we’ll add the role of tablist to the containing div.

<div class="radio-tabs" role="tablist">
  
    <input class="state" type="radio" name="houses-state"
        id="starks" checked />
    ...

Next, we’ll add the role of tab and attribute aria-controls to each radio button. The aria-controls value will be the id of the corresponding panel to show. Additionally, we’ll add titles to each radio button so that screen readers can associate a label with each tab. The checked radio button will also get aria-selected="true":

<div class="radio-tabs" role="tablist">
  
    <input class="state" type="radio" title="Starks"
        name="houses-state" id="starks" role="tab"
        aria-controls="starks-panel" aria-selected="true"checked />
    <input class="state" type="radio" title="Lanisters" 
        name="houses-state" id="lannisters" role="tab" 
        aria-controls="lannisters-panel" />
    <input class="state" type="radio" title="Targaryens" 
        name="houses-state" id="targaryens" role="tab" 
        aria-controls="targaryens-panel" />

    <div class="tabs">

We’re going to hide the visual tabs from assistive technology because they are shallow interfaces to the real tabs (the radio buttons). We’ll do this by adding aria-hidden="true" to our .tabs div:

    ...
    <input class="state" type="radio" title="Targaryens"
        name="houses-state" id="targaryens" role="tab"
        aria-controls="targaryens-panel" />

    <div class="tabs" aria-hidden="true">
        <label for="starks" id="starks-tab"
            class="tab">Starks</label>
    ...

The last bit of ARIA support we need to add is on the panels. Each panel will get the role of tabpanel and an attribute of aria-labeledby with a value of the corresponding tab’s id:

   ...
   <div class="panels">
        <ul id="starks-panel" class="panel active"
            role="tabpanel" aria-labelledby="starks-tab">
            <li>Eddard</li>
            <li>Caitelyn</li>
            <li>Robb</li>
            <li>Sansa</li>
            <li>Brandon</li>
            <li>Arya</li>
            <li>Rickon</li>
        </ul>
        <ul id="lannisters-panel" class="panel"
            role="tabpanel" aria-labelledby="lannisters-tab">
            <li>Tywin</li>
            <li>Cersei</li>
            <li>Jamie</li>
            <li>Tyrion</li>
        </ul>
        <ul id="targaryens-panel" class="panel"
            role="tabpanel" aria-labelledby="targaryens-tab">
            <li>Viserys</li>
            <li>Daenerys</li>
        </ul>
    </div>
    ...

All we need to do with JavaScript is to set the aria-selected value as the radio buttons change:

$('.state').change(function () {
    $(this).parent().find('.state').each(function () {
        if (this.checked) {
            $(this).attr('aria-selected', 'true');
        } else {
            $(this).removeAttr('aria-selected');
        }       
    });
});

This also gives an alternate hook for IE7 and IE8 support. Both browsers support attribute selectors, so you could update the CSS to use [aria-selected] instead of .checked and remove the support shim.

...

#starks[aria-selected] ~ .tabs #starks-tab,
#lannisters[aria-selected] ~ .tabs #lannisters-tab,
#targaryens[aria-selected] ~ .tabs #targaryens-tab,
#starks:checked ~ .tabs #starks-tab,
#lannisters:checked ~ .tabs #lannisters-tab,
#targaryens:checked ~ .tabs #targaryens-tab {
    /* active tab, now with IE7 and IE8 support! */
}

...

The result is full ARIA support with minimal JavaScript—and you still get the benefit of tabs that can be used as soon as the browser paints them.

See the demo: Show/hide example

That’s it. Note that because the underlying HTML is available from the start, unlike the initial JavaScript example, we didn’t have to manipulate or create any additional HTML. In fact, aside from adding ARIA roles and parameters, we didn’t have to do much at all.

Limitations to keep in mind

Like most techniques, this one has a few limitations. The first and most important is that the state of these interfaces is transient. When you refresh the page, these interfaces will revert to their initial state. This works well for some patterns, like modals and offscreen menus, and less well for others. If you need persistence in your interface’s state, it is still better to use links, form submission, or AJAX requests to make sure the server can keep track of the state between visits or page loads.

The second limitation is that there is a scope gap in what can be styled using this technique. Since you cannot place radio buttons before the <body> or <html> elements, and you can only style elements following radio buttons, you cannot affect either element with this technique.

The third limitation is that you can only apply this technique to interfaces that are triggered via click, tap, or keyboard input. You can use progressive enhancement to listen to more complex interactions like scrolling, swipes, double-tap, or multitouch, but if your interfaces rely on these events alone, standard progressive enhancement techniques may be better.

The final limitation involves how radio groups interact with the tab flow of the document. If you noticed in the tab example, hitting tab brings you to the tab group, but hitting tab again leaves the group. This is fine for tabs, and is the expected behavior for ARIA tablists, but if you want to use this technique for something like an open and close button, you’ll want to be able to have both buttons in the tab flow of the page independently based on the button location. This can be fixed through a bit of JavaScript in four steps:

  1. Set the radio buttons and labels to display: none to take them out of the tab flow and visibility of the page.
  2. Use JavaScript to add buttons after each label.
  3. Style the buttons just like the labels.
  4. Listen for clicks on the button and trigger clicks on their neighboring label.

Even using this process, it is highly recommended that you use a standard progressive enhancement technique to make sure users without JavaScript who interact with your interfaces via keyboard don’t get confused with the radio buttons. I recommend the following JavaScript in the head of your document:

<script>document.documentElement.className+=" js";</script>

Before any content renders, this will add the js class to your <html> element, allowing you to style content depending on whether or not JavaScript is turned on. Your CSS would then look something like this:

.thing {
    /* base styles - when no JavaScript is present
       hide radio button labels, show hidden content, etc. */
}

.js .thing {
    /* style when JavaScript is present
       hide content, show labels, etc. */
}

Here’s an example of an offscreen menu implemented using the above process. If JavaScript is disabled, the menu renders open at all times with no overlay:

See the demo: Show/hide example

Implementing other content-on-demand patterns

Let’s take a quick look at how you might create some common user interfaces using this technique. Keep in mind that a robust implementation would address accessibility through ARIA roles and attributes.

Modal windows with overlays

  • Two radio buttons representing modal visibility
  • One or more labels for modal-open which can look like anything
  • A label for modal-close styled to look like a semi-transparent overlay
  • A label for modal-close styled to look like a close button

See the demo: Show/hide example

Off-screen menu

  • Two radio buttons representing menu visibility
  • A label for menu-open styled to look like a menu button
  • A label for menu-close styled to look like a semi-transparent overlay
  • A label for menu-close styled to look like a close button

See the demo: Show/hide example

Switching layout on demand

  • Radio buttons representing each layout
  • Labels for each radio button styled like buttons

See the demo: Show/hide example

Switching style on demand

  • Radio buttons representing each style
  • Labels for each radio button styled like buttons

See the demo: Show/hide example

Content carousels

  • X radio buttons, one for each panel, representing the active panel
  • Labels for each panel styled to look like next/previous/page controls

See the demo: Show/hide example

Other touch- or click-based interfaces

As long as the interaction does not depend on adding new content to the page or styling the <body> element, you should be able to use this technique to accomplish some very JavaScript-like interactions.

Occasionally you may want to manage multiple overlapping states in the same system—say the color and size of a font. In these situations, it may be easier to maintain multiple sets of radio buttons to manage each state.

It is also highly recommended that you use autocomplete="off" with your radio buttons to avoid conflict with browser form autofill switching state on your users.

Radio-control the web?

Is your project right for this technique? Ask yourself the following questions:

  1. Am I using complex JavaScript on my page/site that can’t be handled by this technique?
  2. Do I need to support Internet Explorer 6 or other legacy browsers?

If the answer to either of those question is “yes,” you probably shouldn’t try to integrate radio control into your project. Otherwise, you may wish to consider it as part of a robust progressive enhancement technique.

Most of the time, you’ll be able to shave some bytes off of your JavaScript files and CSS. Occasionally, you’ll even be able to remove Javascript completely. Either way, you’ll gain the appearance of speed—and build a more enjoyable experience for your users.

About the Author

18 Reader Comments

Load Comments