DEV Community

Sergiy Babich
Sergiy Babich

Posted on • Updated on

Accordion Tabs with pure CSS

Yep, this is yet another “article” about how amazing pure CSS is, and how you can do something cool without a single line of JavaScript. Why do I bother to write it? Because I am still fascinated by how amazing pure CSS is, and how you can do something cool without a single line of JavaScript.

Before we start — you can find demo of this code hosted here and the code itself hosted here. Feel free to explore and play around!

The final tiny disclaimer: all this is just for fun and to show you how amazing CSS is.

HTML structure

We will use very simple HTML markup to define our accordion tabs:

  • .tabs-container — wrapper element to host all tab related elements;
  • input.tab-actor — hidden radio-button to control tab content visibility;
  • label.tab-button — label linked to input, serving as tab button;
  • .tab-content — wrapper for any content you’ll feel worth putting into;

The tiniest example ever will look like this:

<div class="tab-container">
  <input type="radio" id="tab-1" name="tabs" class="tab-actor" checked />
  <label for="tab-1" class="tab-button">Lorem ipsum</label>
  <section class="tab-content">
    <div class="content"></div>
  </section>
</div>
Enter fullscreen mode Exit fullscreen mode

How this works

The main idea is to use a very simple yet powerful ability of HTML form controls to have a state and the ability to access this state with CSS pseudo-classes. Namely, I use :checked pseudo-class here. This means I style adjacent siblings of the checked input using + combinator.

To emulate tabs behaviour, I need to display only active tab content. By active I mean the closest adjacent to the checked radio.

The radio-button also should be hidden, leaving only the linked label visible and interactive.

Long story short, this is how these tabs are intended to work. Let’s write some basic CSS code for the tabs.

Some basic code

:root {
  --tab-button-order: 1;
  --tab-content-order: 10;
}

.tab-container {
  display: flex;
  flex-wrap: wrap;
}

.tab-actor {
  display: none;
}

.tab-button {
  order: var(--tab-button-order);
}

.tab-content {
  order: var(--tab-content-order);
  display: none;
}

.tab-actor:checked + .tab-button + .tab-content {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

Let’s go through each rule to understand what happens.

First, I create some CSS variables for an order property, and this is what the inside :root rule is. We’ll get back to this a bit later.

.tab-container {
  display: flex;
  flex-wrap: wrap;
}
Enter fullscreen mode Exit fullscreen mode

We employ flex layout. It allows us to use an unknown number of tabs because it automatically distributes its children. Otherwise, we need to put the fixed-width values manually.

By default, all flex items are cramped in one line, but we need the tab buttons placed at the top and content at the bottom. Using flex-wrap: wrap allows flex layout to put large elements to the next row.

<input type="radio" id="tab-1" name="tabs" class="tab-actor" checked />
<label for="tab-1" class="tab-button">Lorem ipsum</label>
Enter fullscreen mode Exit fullscreen mode

We link label to input using id attribute for the input and for attribute for the label. When the input-label pair has the same values of the attributes, clicking the label activates the input as we click directly on the input.

This allows us to hide input:

.tab-actor {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

Next, we add some black flex magic to get the layout we want.

HTML we have already written results in this:

[tab]
[content]
[tab]
Enter fullscreen mode Exit fullscreen mode

But what we need is the following:

[tab][tab]
[content]
Enter fullscreen mode Exit fullscreen mode

To achieve our goal we should use an order CSS property that orders (no pun intended) elements inside flex layout despite the actual position in the DOM-tree. The following code sets the order for .tab-button elements to be at the start of layout and .tab-content to be at:

.tab-button {
  order: var(--tab-button-order);
}

.tab-content {
  order: var(--tab-content-order);
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

.tab-content is hidden by default. We unhide active tab content using the code:

.tab-actor:checked + .tab-button + .tab-content {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

It’s a big selector, for sure, but it does all the magic. All content is hidden, and we want to display only the content corresponding to the activated tab button. This selector literally says the following:

Display the content after the button that follows the checked input

+ combinator selects immediately adjacent elements, that’s why the HTML code should follow this exact order.

Another approach is to use ~ combinator. It is also adjacent but not strict and selects all matching adjacent elements. Using ~ shortens the selector to:

.tab-actor:checked ~ .tab-content {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

Though in this case, the first tab activates all adjacent content. To avoid this, we need to specify which tab displays which content:

/* Don't write code like this. Please. */
.tab-actor.tab-1:checked ~ .tab-content.tab-1,
.tab-actor.tab-2:checked ~ .tab-content.tab-2,
.tab-actor.tab-3:checked ~ .tab-content.tab-3,
.tab-actor.tab-4:checked ~ .tab-content.tab-4 {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

Not that much optimization, to be honest.

Ok, now we have not that pretty but working tabs made with pure CSS and HTML.

Music time!

Or, to be precise, it’s time to convert tabs into the accordion.
Why?

On small screens, tabs aren’t the best option to display content, and the very layout we tried to avoid at the beginning comes in handy here:

Desktop:
[tab][tab]
[content]

Mobile:
[tab]
[tab]
[content]
Enter fullscreen mode Exit fullscreen mode

All we need is just to revert flex order and adjust the button width to small screens:

@media screen and (max-width: 480px) {
  .tab-button,
  .tab-content {
    order: initial;
  }

  .tab-button {
    width: 100%;
  }
}
Enter fullscreen mode Exit fullscreen mode

That’s it. It works.

Wait! There is more!

It’s all cool and great, but these tabs are so booooring. Let’s glam them up!

* {
  margin: 0;
  padding: 0;
}

body {
  font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
  max-width: 1280px;
  margin-inline: auto;
}

.tab-container {
  box-shadow: rgba(0, 0, 0, 0.5) 0 1px 2px;
  justify-content: center;
}

.tab-button {
  padding: 8px 16px;
  border-bottom: transparent 4px solid;
  transition: border-bottom-color 0.4s;
}

.content {
  padding: 16px;
}

.tab-actor:checked + .tab-button {
  border-bottom-color: rgb(82, 2, 136);
}
Enter fullscreen mode Exit fullscreen mode

We add some paddings, colours and a bit of animation. Looks great now! Though, as you can notice, content in the “mobile” mode switches dully, without a single spark of joy. Let’s add some sparks then:

@media screen and (max-width: 480px) {
  .tab-button {
    border-bottom: 1px solid #ccc;
    transition: none;
  }

  .tab-content {
    background-color: ivory;
  }

  .tab-container.full-height {
    height: 100vh;
    flex-direction: column;
  }

  .tab-container.full-height .tab-content {
    display: block;
    height: auto;
    flex: 0;
    overflow: hidden;

    transition: 300ms flex;
  }

  .tab-container.full-height .tab-actor:checked + .tab-button + .tab-content {
    flex: 1;
  }
}
Enter fullscreen mode Exit fullscreen mode

What happens here? We add a .full-height class to our .tab-container and sprinkled some fun CSS over it.

.tab-container.full-height {
  height: 100vh;
  flex-direction: column;
}
Enter fullscreen mode Exit fullscreen mode

Right here we tell our accordion to occupy exactly the full-screen height and order all children in column flex layout.

.tab-container.full-height .tab-content {
  display: block;
  height: auto;
  flex: 0;
  overflow: hidden;

  transition: 300ms flex;
}

.tab-container.full-height .tab-actor:checked + .tab-button + .tab-content {
  flex: 1;
}
Enter fullscreen mode Exit fullscreen mode

Now we cast some magic on .tab-content, allowing it to expand and collapse with a neat animation.

Epilogue

That’s, my friends, is how I met… Ah, sorry, it is how we can make responsive tabs that switch to the accordion layout on the fly without a single line of JS.


Edited by @Ulyanka_A

Discussion (8)

Collapse
violet profile image
Elena • Edited on

The simplest accordion element is with pure html:

<details open>
  <summary>Click here</summary>
  <p>These are the details</p>
</details>
Enter fullscreen mode Exit fullscreen mode

And you just toggle the open attribute on the details element to open and close it. The arrow marker can be customized using the css pseudo element ::marker to set the color, font size, etc.

Collapse
sergiybabich profile image
Sergiy Babich Author

Yet it is not transforming to tabs with CSS.
This is pure demo of CSS capabilities, not a practical guide to use in your projects.

Collapse
niyazpoyilan profile image
Niyaz Poyilan • Edited on

Nice post, liked the idea of CSS only. I had tried one similar for Responsive Tabs but it wasn't just CSS, a little bit of JS to I had to use. Here is a demo of it codepen.io/niyazpoyilan/pen/KKNQodr

I would take some inspiration from yours too if I could make it CSS only

Collapse
sergiybabich profile image
Sergiy Babich Author

Glad to be helpful!

Collapse
delanyoyoko profile image
delanyo agbenyo

That's great. And I think you'll still need JS to do it's closing

Collapse
sergiybabich profile image
Sergiy Babich Author

If you need to, yes.
But if you don’t, you don’t )

Collapse
tboswell profile image
Tony Boswell

Looks great, but you'd still need JS to make it A11y friendly...

Collapse
sergiybabich profile image
Sergiy Babich Author

Yes, definitely.
But that was not a goal of this tutorial.