Building a responsive timeline in CSS with Sass and BEM

A tutorial on working with Sass and BEM when building a more obscure than usual component.

By Luke Whitehouse on

CSS

Tutorial

Timeline's are funny components to build, especially when we're thinking responsively. In this post we'll look at how we might build such a component using the latest CSS techniques including pre-processing your CSS with Sass and using the BEM naming convention for your class names.

If you're unfamiliar with any of the above don't fret, we'll be breaking things down into bite-sized chunks so by the end of the post you'll have a basic understanding of them all.

Here's the timeline we'll be building. It's also the perfect opportunity to explain the correct Star Wars movie order, right?

https://codepen.io/_lukewh/pen/XRwEqx

Getting started

First things first, we want a solid foundation from which to work from, something that's cross-browser compliant and isn't too heavy on the load.

I'm going to be building this on Codepen so I'll be using Normalise.css and Autoprefixer which will ensure that I have a nice baseline to work from and I don't have to worry about vendor prefixes for CSS techniques where the support isn't quite there yet.

If you'd like to follow along with me on Codepen, I've setup a starter kit which will get you off the ground running. Go head and fork it into your own account and we can begin.

Structuring our component

Keeping things semantic, we're going to be building out our component from an ordered list (<ol>), where each item in our timeline is a separate list item (<li>).

<ol>
  <li></li>
  <li></li>
</ol>

Go ahead and put that in place of the comment shown below within the starter kit:

<!-- Timeline component goes here -->

Within each of our list items we'll need a heading and some text.

<li>
  <h2>This is where our heading will go</h2>
  <p>This is where our text will go</p>
</li>

Okay, looking good. You should now have something like the following.

<article class="container">
  <h1 class="section-title">The Star Wars Saga order</h1>

  <ol>
  <li>
    <h2>This is where our heading will go</h2>
    <p>This is where our text will go</p>
  </li>
  <li>
    <h2>This is where our heading will go</h2>
    <p>This is where our text will go</p>
  </li>
  </ol>
</article>

Let's start creating some markers for our CSS to latch onto. To do this, we need to understand the BEM methodology for naming our classes. Not that I'm at all biased, but I'd recommend reading through my last article on the subject, "Introducing BEM: The popular CSS naming convention".

Adding BEM classes to our component

If you've already got BEM experience on your Linked In profiles then that's great, let's use those skills to give our component some styling.

Add the 'block' class of .timeline to our <ol>.

<ol class="timeline">

As each <li> is an entry of our .timeline component, we'll give them the element class of .timeline__entry. Next, both the <h1> and <p> tags are also elements of our .timeline component so we'll prefix them with .timeline__ too, resulting in .timeline__heading and .timeline__text respectively.

Here's our HTML markup so far.

<article class="container">
  <h1 class="section-title">The Star Wars Saga order</h1>
  <ol class="timeline">
  <li class="timeline__entry">
    <h2 class="timeline__heading">This is where our heading will go</h2>
    <p class="timeline__text">This is where our text will go</p>
  </li>
  <li class="timeline__entry">
    <h2 class="timeline__heading">This is where our heading will go</h2>
    <p class="timeline__text">This is where our text will go</p>
  </li>
  <li class="timeline__entry">
    <h2 class="timeline__heading">This is where our heading will go</h2>
    <p class="timeline__text">This is where our text will go</p>
  </li>
  <li class="timeline__entry">
    <h2 class="timeline__heading">This is where our heading will go</h2>
    <p class="timeline__text">This is where our text will go</p>
  </li>
  </ol>
</article>

From this, we can already assume that .timeline__entry, .timeline__heading and .timeline__text must be within a component of .timeline, however, we cannot tell at a glance that the heading and text elements are required to be within the .timeline__entry element.

Grandchild elements?

Setting up elements within elements is a bit of a strange one, and something BEM admittedly struggles with. As such, there are a few implementations we can go with.

<!-- Option 1: Grandchild elements -->
<li class="timeline__entry">
  <h2 class="timeline__entry__heading"></h2>
  <p class="timeline__entry__text"></p>
</li>

<!-- Option 2: Dash separated elements -->
<li class="timeline__entry">
  <h2 class="timeline__entry-heading"></h2>
  <p class="timeline__entry-text"></p>
</li>

<!-- Option 3: New component -->
<li class="timeline__entry entry">
  <h2 class="entry__heading"></h2>
  <p class="entry__text"></p>
</li>

<!-- Option 4: Do nothing -->
<li class="timeline__entry">
  <h2 class="timeline__heading"></h2>
  <p class="timeline__text"></p>
</li>

The approach you choose should be a combination of personal preference and which approach suits the requirements of the application the best. Here's a quick run down of how I'd decide:

  • Option 1 vs Option 2: Deciding between these two is more of a personal preference than anything as neither option conforms to the BEM guidelines, although do make sense to the naked eye.
  • Option 3: I would opt to create a new component when the current component is complex enough. In reality, there's no reason the .entry and any of it's element styling should be coupled with the .timeline component, so it may make sense to separate your concerns.
  • Option 4: If there are only a couple of elements, or this component isn't large enough to warrant anything too complex, I'd just go ahead and omit any form of 'grandchild' naming conventions and stick to the regular ones.

I'll leave it up to you to decide which to use for this component, although to keep things simple for the remainder of this tutorial I'm going to go with Option 4 and not concern myself with 'grandchild' elements, whereas if this were a component of a much larger design system then I might opt to go with Option 3.

Mobile-first styling

OK, getting back on track...

Now that we have some structure to work with we can start adding in some styling to turn our component into an actual timeline. In true mobile-first spirit, we'll start with smaller screen sizes first.

Back within our code, locate the comment Your code starts here in the CSS file.

/**
* Your code starts here
*/

Underneath there, we'll add some CSS to style up our elements.

.timeline {
  position: relative;
  margin: 0;
  padding: 0;
  list-style: none;
}

.timeline__entry {
  margin-bottom: 2em;
  color: #fff;
}

.timeline__heading {
  margin-bottom: .25em;
  font-size: 1.2rem;
}

.timeline__text {
  color: #CCC;

  &:last-child {
    margin-bottom: 0;
  }
}

If you've never seen the & selector before, this is Sass specific syntax. CSS-Tricks has a great tutorial all about it.

Looking a little better now but still nothing like a timeline. Let's fix that.

Going back to the styling for .timeline, add a :before selector with the following CSS;

.timeline {
  position: relative;
  margin: 0;
  padding: 0;
  list-style: none;

  &:before {
    content: "";
    position: absolute;
    top: -3em;
    left: 2em;
    width: .25em;
    height: calc(100% + 6em);
    background: linear-gradient(to bottom, #151515 0%, #3b3b3b 2%, #3b3b3b 98%, #151515 100%);
  }
}

Save that and you should have a line going straight through your .timeline__entry elements.

Protip: Adding content:"" with a before element will allow you to create an empty element with just CSS properties, and because we've absolutely positioned it relative to our .timeline, it will always be rendered relative to the position of the main component.

Fig 1: Laying the foundations for our timeline component.
Fig 1: Laying the foundations for our timeline component.

Add an extra div to wrap around our heading and text elements and name it .timeline__content. These are the elements we're going to give our 'box' styling to rather than giving that to the main .timeline__entry element.

<article class="container">
  <h1 class="section-title">The Star Wars Saga order</h1>
  <ol class="timeline">
    <li class="timeline__entry">
      <div class="timeline__content">
        <h2 class="timeline__heading">This is where our heading will go</h2>
        <p class="timeline__text">This is where our text will go</p>
      </div>
    </li>
    <li class="timeline__entry">
      <div class="timeline__content">
        <h2 class="timeline__heading">This is where our heading will go</h2>
        <p class="timeline__text">This is where our text will go</p>
      </div>
    </li>
    <li class="timeline__entry">
      <div class="timeline__content">
        <h2 class="timeline__heading">This is where our heading will go</h2>
        <p class="timeline__text">This is where our text will go</p>
      </div>
    </li>
    <li class="timeline__entry">
      <div class="timeline__content">
        <h2 class="timeline__heading">This is where our heading will go</h2>
        <p class="timeline__text">This is where our text will go</p>
      </div>
    </li>
  </ol>
</article>

Next, let's style the component up a little.

.timeline__content {
  position: relative;
  display: block;
  margin-left: 4em;
  padding: 1em;
  background: #282727;
}

Put the new .timeline__content styling between .timeline__entry and .timeline__heading to roughly mimic the order the elements appear in the HTML.

Looking much better, but there's still no real reference to where the entries come in the timeline. What we really need is an arrow to explain this to our users. Back within our .timeline__content CSS, add a :before element just like we did for the main .timeline component.

.timeline__content {
  position: relative;
  display: block;
  margin-left: 4em;
  padding: 1em;
  background: #282727;

  &:before {
    content: "";
    position: absolute;
    display: block;
    top: 1em;
    left: -1em;
    border-top: 1em solid transparent;
    border-bottom: 1em solid transparent;
    border-right: 1em solid #282727;
  }
}

Rather than require a new HTML tag, we can use the power of CSS psuedo elements with some CSS border trickery to mimic the idea of an arrow for each of our entries.

Fig 2: Mobile styling.
Fig 2: Creating the timeline's mobile styling.

Great, it's starting to really look like a timeline now. The only thing missing is a reference to the values of the timeline in relation to each entry. Remember how I said we'd style the new .timeline__content wrapping elements rather than the already present .timeline__entry ones? Well this is why.

In each of your entries create a new span tag with the class of .timeline__id, which will serve as our markers on the timeline. As we don't want these to be wrapped within the styling of .timeline__content make sure they're siblings of them.

<article class="container">
  <h1 class="section-title">The Star Wars Saga order</h1>
  <ol class="timeline">
    <li class="timeline__entry">
      <span class="timeline__id">Ep.1</span>
      <div class="timeline__content">
        <h2 class="timeline__heading">This is where our heading will go</h2>
        <p class="timeline__text">This is where our text will go</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Ep.2</span>
      <div class="timeline__content">
        <h2 class="timeline__heading">This is where our heading will go</h2>
        <p class="timeline__text">This is where our text will go</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Ep.3</span>
      <div class="timeline__content">
        <h2 class="timeline__heading">This is where our heading will go</h2>
        <p class="timeline__text">This is where our text will go</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Ep.4</span>
      <div class="timeline__content">
        <h2 class="timeline__heading">This is where our heading will go</h2>
        <p class="timeline__text">This is where our text will go</p>
      </div>
    </li>
  </ol>
</article>

Great, now for some styling. Above your .timeline__content add the following properties to .timeline__id.

.timeline__id {
  position: absolute;
  top: 1em;
  left: 2em;
  padding: .5em 1em;
  background: #282727;
  transform: translateX(-50%);
}

Uhoh, it's broke. Go to your .timeline__entry and give it the property value of position: relative. Finally, let's add some more margin-left to our .timeline__content. 6em should do nicely.

Fig 3: Creating markers on the timeline.
Fig 3: Creating markers on the timeline.

Media Queries

We now have a mobile friendly timeline built using Sass and BEM, however, we're yet to factor in larger screen sizes. Sure, the website will work to some extent on larger screen sizes, but just as we now optimise our layouts for mobile, we must still do the same for our desktop brethren.

With that in mind, we can go back through our timeline's styling and add modifications at larger screen sizes so that the timeline is centred and an entry is shown either side of it. To do that, we'll need to bring out the big guns – the @media rule.

If you're unfamiliar with the @media rule then Mozilla's Developer Network have a great article on the subject of using media queries. The basic concept to understand is that the @media rule will allow you to add or override properties to a selector when it's conditions are met.

To put that into context, we want to alter the styling of our elements when the screen sizes get to a certain width. We can do that with the min-width condition.

@media screen and (min-width: 45em) {
}

Once the screen size is wider that 45em then any properties added to CSS selectors within the @media's brackets will be applied to the site.

For example, at smaller screens we could have a small sized heading, which gets larger at wider screen sizes.

h1 {
  font-size: 2rem;
}

@media screen and (min-width: 45em) {
  h1 {
    font-size: 3rem;
  }
}

Now to try it out on our timeline component. Back in our project, go to the .timeline selector and add in the @media rule as followed within the :before psuedo selector.

.timeline {
  position: relative;
  margin: 0;
  padding: 0;
  list-style: none;

  &:before {
    content: "";
    position: absolute;
    top: -3em;
    left: 2em;
    width: .25em;
    height: calc(100% + 6em);
    background: linear-gradient(to bottom, #151515 0%, #3b3b3b 2%, #3b3b3b 98%, #151515 100%);

    @media screen and (min-width: 45em) {
      left: 50%;
      transform: translateX(-50%);
    }
  }
}

When using Sass, you're able to nest media queries within an element. It's worth noting that when this is compiled out into CSS it'll look exactly the same as if you were to write the media query in regular old CSS. The benefit of this way is more of readability by keeping all styling to do with a selector together. Here's how our styling will look when we compile it out:

.timeline {
  position: relative;
  margin: 0;
  padding: 0;
  list-style: none;
}

.timeline:before {
  content: "";
  position: absolute;
  top: -3em;
  left: 2em;
  width: 0.25em;
  height: calc(100% + 6em);
  background: linear-gradient(
    to bottom,
    #151515 0%,
    #3b3b3b 2%,
    #3b3b3b 98%,
    #151515 100%
  );
}

@media screen and (min-width: 45em) {
  .timeline:before {
    left: 50%;
    transform: translateX(-50%);
  }
}

OK, if you resize your browser window you should see the line become centred after 45em, or about 720px (45 divided by our font-size, which is 16 by default in most browsers). Now to tackle the timeline entries themselves.

First we're going to alter the .timeline__content width and zero out it's margin-left property once the screen size gets larger than 45em.

.timeline__content {
  position: relative;
  display: block;
  margin-left: 6em;
  padding: 1em;
  background: #282727;

  @media screen and (min-width: 45em) {
    margin-left: 0;
    width: calc(50% - 4em);
  }

  &:before {
    content: "";
    position: absolute;
    display: block;
    top: 1em;
    left: -1em;
    border-top: 1em solid transparent;
    border-bottom: 1em solid transparent;
    border-right: 1em solid #282727;
  }
}

Still within the component, we also want to alter the little arrows pointing to the timeline.

.timeline__content {
  position: relative;
  display: block;
  margin-left: 6em;
  padding: 1em;
  background: #282727;

  @media screen and (min-width: 45em) {
    margin-left: 0;
    width: calc(50% - 4em);
  }

  &:before {
    content: "";
    position: absolute;
    display: block;
    top: 1em;
    left: -1em;
    border-top: 1em solid transparent;
    border-bottom: 1em solid transparent;
    border-right: 1em solid #282727;

    @media screen and (min-width: 45em) {
      left: auto;
      right: -1em;
      border-left: 1em solid #282727;
      border-right: none;
    }
  }
}

Next we want to tackle the identifiers for our timeline entries. Find .timeline__id and add a media query that centres the element to the timeline.

.timeline__id {
  position: absolute;
  top: 1em;
  left: 2em;
  padding: .5em 1em;
  background: #282727;
  transform: translateX(-50%);

  @media screen and (min-width: 45em) {
    left: 50%;
    transform: translateX(-50%);
  }
}

Here's how that now looks on desktop.

Fig 4: Styling for devices of larger screen sizes.
Fig 4: Styling for devices of larger screen sizes.

Notice there's no entries to the right hand side? Let's add those. Underneath the .timeline__content selector, add a new selector called .timeline__content--flipped. This is going to be a modifier for our .timeline__content element which changes the direction of the entry to the right, rather than the default (left).

.timeline__content--flipped {
  @media screen and (min-width: 45em) {
    float: right;

    &:before {
      left: -1em;
      right: auto;
      border-left: none;
      border-right: 1em solid #282727;
    }
  }
}

To break that down, at desktop:

  • We make sure the entry is floated to the right;
  • and we also override the arrows positioning to bring it to the left of the entry, just as they are on mobile.

Also to note, as we're now using floats we need to make sure to clear them for each entry. Add the clearfix technique to all .timeline__entry elements.

.timeline__entry {
  position: relative;
  margin-bottom: 2em;
  color: #fff;

  &:after {
    content: "";
    display: table;
    clear: both;
  }
}

Now to use our new modifier. Add the .timeline__content--flipped modifier class to every other entry in our HTML. (That'll be every even entry).

Done that? Great, if you've followed along you should now have a timeline that responds to your screen size using the powers of BEM and Sass.

Fig 5: Creating the BEM modifier for larger screen sizes.
Fig 5: Creating the BEM modifier for larger screen sizes.

Cleaning things up

To finish, there's a few things we can clean up to make our component extra awesome.

You may have already noticed but that there are a lot of references to the 45em value in our media queries. As we're using Sass we can create a variable to house them. Above the .timeline selector add a variable, go through your Sass code and change any reference to 45em to your new variable.

$bp-large: 45em;

I went for $bp-large, standing for "breakpoint large".

Oh, and it's not a Star Wars timeline without any content. Here's the final HTML and content for you to copy.

<article class="container">
  <h1 class="section-title">The Star Wars Saga order</h1>
  <ol class="timeline">
    <li class="timeline__entry">
      <span class="timeline__id">Ep.1</span>
      <div class="timeline__content">
        <h2 class="timeline__heading">The Phantom Menance</h2>
        <p class="timeline__text">Obi-Wan Kenobi (Ewan McGregor) is a young apprentice Jedi knight under the tutelage of Qui-Gon Jinn (Liam Neeson) ; Anakin Skywalker (Jake Lloyd), who will later father Luke Skywalker and become known as Darth Vader, is just a 9-year-old boy. When the Trade Federation cuts off all routes to the planet Naboo, Qui-Gon and Obi-Wan are assigned to settle the matter.</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Ep.2</span>
      <div class="timeline__content timeline__content--flipped">
        <h2 class="timeline__heading">Attack of the Clones</h2>
        <p class="timeline__text">Set ten years after the events of "The Phantom Menace," the Republic continues to be mired in strife and chaos. A separatist movement encompassing hundreds of planets and powerful corporate alliances poses new threats to the galaxy that even the Jedi cannot stem. These moves, long planned by an as yet unrevealed and powerful force, lead to the beginning of the Clone Wars -- and the beginning of the end of the Republic.</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Ep.3</span>
      <div class="timeline__content">
        <h2 class="timeline__heading">Revenge of the Sith</h2>
        <p class="timeline__text">It has been three years since the Clone Wars began. Jedi Master Obi-Wan Kenobi (Ewan McGregor) and Jedi Knight Anakin Skywalker (Hayden Christensen) rescue Chancellor Palpatine (Ian McDiarmid) from General Grievous, the commander of the droid armies, but Grievous escapes. Suspicions are raised within the Jedi Council concerning Chancellor Palpatine, with whom Anakin has formed a bond. Asked to spy on the chancellor, and full of bitterness toward the Jedi Council, Anakin embraces the Dark Side.</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Bonus</span>
      <div class="timeline__content timeline__content--flipped">
        <h2 class="timeline__heading">Rogue One</h2>
        <p class="timeline__text">Former scientist Galen Erso lives on a farm with his wife and young daughter, Jyn. His peaceful existence comes crashing down when the evil Orson Krennic takes him away from his beloved family. Many years later, Galen becomes the Empire's lead engineer for the most powerful weapon in the galaxy, the Death Star. Knowing that her father holds the key to its destruction, Jyn joins forces with a spy and other resistance fighters to steal the space station's plans for the Rebel Alliance.</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Ep.4</span>
      <div class="timeline__content">
        <h2 class="timeline__heading">Star Wars (A New Hope)</h2>
        <p class="timeline__text">The Imperial Forces -- under orders from cruel Darth Vader (David Prowse) -- hold Princess Leia (Carrie Fisher) hostage, in their efforts to quell the rebellion against the Galactic Empire. Luke Skywalker (Mark Hamill) and Han Solo (Harrison Ford), captain of the Millennium Falcon, work together with the companionable droid duo R2-D2 (Kenny Baker) and C-3PO (Anthony Daniels) to rescue the beautiful princess, help the Rebel Alliance, and restore freedom and justice to the Galaxy.</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Ep.5</span>
      <div class="timeline__content timeline__content--flipped">
        <h2 class="timeline__heading">The Empire Strikes Back</h2>
        <p class="timeline__text">The adventure continues in this "Star Wars" sequel. Luke Skywalker (Mark Hamill), Han Solo (Harrison Ford), Princess Leia (Carrie Fisher) and Chewbacca (Peter Mayhew) face attack by the Imperial forces and its AT-AT walkers on the ice planet Hoth. While Han and Leia escape in the Millennium Falcon, Luke travels to Dagobah in search of Yoda. Only with the Jedi master's help will Luke survive when the dark side of the Force beckons him into the ultimate duel with Darth Vader (David Prowse).</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Ep.6</span>
      <div class="timeline__content">
        <h2 class="timeline__heading">Return of the Jedi</h2>
        <p class="timeline__text">Luke Skywalker (Mark Hamill) battles horrible Jabba the Hut and cruel Darth Vader to save his comrades in the Rebel Alliance and triumph over the Galactic Empire. Han Solo (Harrison Ford) and Princess Leia (Carrie Fisher) reaffirm their love and team with Chewbacca, Lando Calrissian (Billy Dee Williams), the Ewoks and the androids C-3PO and R2-D2 to aid in the disruption of the Dark Side and the defeat of the evil emperor.</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Ep.7</span>
      <div class="timeline__content timeline__content--flipped">
        <h2 class="timeline__heading">The Force Awakens</h2>
        <p class="timeline__text">Thirty years after the defeat of the Galactic Empire, the galaxy faces a new threat from the evil Kylo Ren (Adam Driver) and the First Order. When a defector named Finn (John Boyega) crash-lands on a desert planet, he meets Rey (Daisy Ridley), a tough scavenger whose droid contains a top-secret map. Together, the young duo joins forces with Han Solo (Harrison Ford) to make sure the Resistance receives the intelligence concerning the whereabouts of Luke Skywalker (Mark Hamill), the last of the Jedi Knights.</p>
      </div>
    </li>
    <li class="timeline__entry">
      <span class="timeline__id">Ep.8</span>
      <div class="timeline__content">
        <h2 class="timeline__heading">The Last Jedi</h2>
        <p class="timeline__text">Yet to be confirmed. The further adventures of Luke Skywalker (Mark Hamill), Leia (Carrie Fisher) and Rey (Daisy Ridley).</p>
      </div>
    </li>
  </ol>
</article>

I hope you found this tutorial useful. If you feel there's anything I could have explained better or something you're a little unsure about then I'd love to hear from you, please leave a comment below.

Until next time 

Follow us on Twitter, Facebook or Github.
© Copyright 2021 Assortment.
Created and maintained by Luke Whitehouse.