Skip to content

CSS-Only Tabs

Use Case

Tabs are one of those UI components on the internet that has a billion iterations already. Maybe l should call this "yet-another-tab-component" to really stand out.

Just about every UI/CSS framework imaginable has their own solution for HTML tabs, so why would you want to even think about another way to do it?

Well, not everyone has the option to use whatever frontend framework they would like; in fact, there are plenty of sites that don't use any framework at all (which sounds horrifying).

Back in 2014, I was working on a non-framework, vanilla PHP E-commerce site. I was 18 and very green, and performance was always an issue with that site. Over time I figured out how to properly optimize my resources, only load necessary assets, minimize everything, etc. - I even wound up writing my own backend CSS compiler & compressor, complete with PHP-driven CSS variables and functions (because I was unaware of SASS/LESS at the time).

I also quickly discovered how even on the most ancient of hardware, the browser computed CSS almost instantly, whereas the same functionality in JavaScript took noticeably longer. I'm sure my JS code was not exactly efficient seeing as I was still wet behind the ears, but I was convinced that the more I could do things exclusively with CSS, the better my site's performance would be. I never took any actual benchmarks on this - and in today's world the difference between a small bit of JS & CSS would probably be practically indistinguishable. Regardless, I developed a fascination for doing things in purely CSS where possible.

If you find yourself unable to use another frontend framework's solution, need to optimize efficiency to the max, or are just curious - this is for you.

Markup

HTML

html
<div class="tabs" style="display: flex; flex-direction: column; gap: 20px;">
    <input type="radio" id="tab-1" value="" checked />
    <input type="radio" id="tab-2" value="" />
    <input type="radio" id="tab-3" value="" />
    <ul class="tab-headers">
        <li class="tab-header">
            <label for="tab-1">Tab 1</label>
        </li>
        <li class="tab-header">
            <label for="tab-2">Tab 2</label>
        </li>
        <li class="tab-header">
            <label for="tab-3">Tab 3</label>
        </li>
    </ul>
    <div class="tab-con">
        Tab 1 Content
    </div>
    <div class="tab-con">
        Tab 2 Content
    </div>
    <div class="tab-con">
        Tab 3 Content
    </div>
</div>

CSS

css
.tabs input
{
    display: none;
}

.tab-headers 
{
    list-style: none!important;
    display: flex;
    align-items: center;
    gap: 20px;
    margin-top: 0px!important;
}

.tab-header
{
    margin-top: 0px!important;
}

.tab-header label:hover
{
    cursor: pointer;
}

.tabs input:nth-of-type(1):checked ~ .tab-headers .tab-header:nth-of-type(1) label,
.tabs input:nth-of-type(2):checked ~ .tab-headers .tab-header:nth-of-type(2) label,
.tabs input:nth-of-type(3):checked ~ .tab-headers .tab-header:nth-of-type(3) label,
.tabs input:nth-of-type(4):checked ~ .tab-headers .tab-header:nth-of-type(4) label,
.tabs input:nth-of-type(5):checked ~ .tab-headers .tab-header:nth-of-type(5) label,
.tabs input:nth-of-type(6):checked ~ .tab-headers .tab-header:nth-of-type(6) label,
.tabs input:nth-of-type(7):checked ~ .tab-headers .tab-header:nth-of-type(7) label,
.tabs input:nth-of-type(8):checked ~ .tab-headers .tab-header:nth-of-type(8) label,
.tabs input:nth-of-type(9):checked ~ .tab-headers .tab-header:nth-of-type(9) label,
.tabs input:nth-of-type(10):checked ~ .tab-headers .tab-header:nth-of-type(10) label
{
    border-bottom: 1px solid red;
}


.tab-con
{
    display: none;
}

.tabs input:nth-of-type(1):checked ~ .tab-con:nth-of-type(1),
.tabs input:nth-of-type(2):checked ~ .tab-con:nth-of-type(2),
.tabs input:nth-of-type(3):checked ~ .tab-con:nth-of-type(3),
.tabs input:nth-of-type(4):checked ~ .tab-con:nth-of-type(4),
.tabs input:nth-of-type(5):checked ~ .tab-con:nth-of-type(5),
.tabs input:nth-of-type(6):checked ~ .tab-con:nth-of-type(6),
.tabs input:nth-of-type(7):checked ~ .tab-con:nth-of-type(7),
.tabs input:nth-of-type(8):checked ~ .tab-con:nth-of-type(8),
.tabs input:nth-of-type(9):checked ~ .tab-con:nth-of-type(9),
.tabs input:nth-of-type(10):checked ~ .tab-con:nth-of-type(10)
{
    display: block;
}

Results


Tab 1 Content
Tab 2 Content
Tab 3 Content

Caveats

One of the primary caveats to this method is that the CSS is not dynamic; you have to have at least the same amount of corresponding lines of CSS as you do tabs themselves:

css
/* Active tab header styling */
.tabs input:nth-of-type(1):checked ~ .tab-headers .tab-header:nth-of-type(1) label
{
    border-bottom: 1px solid red;
}

/* Displays only the active tab */
.tabs input:nth-of-type(1):checked ~ .tab-con:nth-of-type(1)
{
    display: block;
}

Of course, this example would only account for a single tab... which you would never do. If you wanted to support three tabs, it would look like the following:

css
.tabs input:nth-of-type(1):checked ~ .tab-headers .tab-header:nth-of-type(1) label,
.tabs input:nth-of-type(2):checked ~ .tab-headers .tab-header:nth-of-type(2) label,
.tabs input:nth-of-type(3):checked ~ .tab-headers .tab-header:nth-of-type(3) label
{
    border-bottom: 1px solid red;
}

.tabs input:nth-of-type(1):checked ~ .tab-con:nth-of-type(1),
.tabs input:nth-of-type(2):checked ~ .tab-con:nth-of-type(2),
.tabs input:nth-of-type(3):checked ~ .tab-con:nth-of-type(3)
{
    display: block;
}

This is necessary in order to both only display the active tab contents while also styling the active tab header.

The reason for this is due to the nature of CSS; since CSS selector's are top-down only, there's not really a clean way that I could think of for selecting both the tab header and the tab content based on the checked radio input, other structuring the HTML in such a way that we could use the ::nth-of-type() selector. It would be nice if you could select an input based on a given label's for value, but that doesn't exist in CSS.

For that reason, the only real way to select both the tab header and tab body for the active radio input is to combine the general sibling selector ~ with the ::nth-of-type() pseudo selector. It's a bit messier than I would like, but if you don't want to worry about how many tabs your CSS can support, you can simply backfill with enough ::nth-of-type() lines to support all the tabs you want.

Bonus: Dynamic Tab Count

If you didn't care about styling the active tab header, you could structure this a bit differently and avoid the N+1 problem altogether.

HTML

html
<div class="bad_tabs" style="display: flex; flex-direction: column; gap: 20px;">
    <ul class="bad_tab-headers">
        <li class="bad_tab-header">
            <label for="bad_tab-1">Tab 1</label>
        </li>
        <li class="bad_tab-header">
            <label for="bad_tab-2">Tab 2</label>
        </li>
        <li class="bad_tab-header">
            <label for="bad_tab-3">Tab 3</label>
        </li>
    </ul>
    <input type="radio" name="bad_tab-inputs" id="bad_tab-1" value="" checked />
    <div class="bad_tab-con">
        Tab 1 Content
    </div>
    <input type="radio" name="bad_tab-inputs" id="bad_tab-2" value="" />
    <div class="bad_tab-con">
        Tab 2 Content
    </div>
    <input type="radio" name="bad_tab-inputs" id="bad_tab-3" value="" />
    <div  class="bad_tab-con">
        Tab 3 Content
    </div>
</div>

CSS

css
.bad_tabs input
{
    display: none;
}

.bad_tab-headers 
{
    list-style: none!important;
    display: flex;
    align-items: center;
    gap: 20px;
    margin-top: 0px!important;
}

.bad_tab-header
{
    margin-top: 0px!important;
}

.bad_tab-header label:hover
{
    cursor: pointer;
}

.bad_tab-con
{
    display: none;
}

.bad_tabs input + .bad_tab-con {
    display: none;
}

.bad_tabs input:checked + .bad_tab-con {
    display: block;
}

Result


Tab 1 Content
Tab 2 Content
Tab 3 Content

PS: Don't Do This

With this design, we could have a million tabs without having to change the CSS that drives the tab logic. However, it comes with a major tradeoff.

As you can see in the example above, the tab header styling remains the same regardless of which tab is active.

This is bad UI/UX.

It makes the page feel broken and unresponsive. Yes, the basic functionality of tabbing is working, but without clear indication of which tab you're actually looking at, it necessitates additional mental load just to retain the context of what you're looking at. Causing unnecessary mental load on your end user is never a good idea.