Building a Horizontal Timeline With CSS and JavaScript

In a previous post, I showed you how to build a responsive vertical timeline from scratch. Today, I’ll cover the process of creating the associated horizontal timeline.

Building a Horizontal Timeline With CSS and JavaScript

As usual, to get an initial idea of what we’ll be building, take a look at the related CodePen demo (check out the larger version for a better experience):

We have a lot to cover, so let’s get started!

1. HTML Markup

The markup is identical to the markup we defined for the vertical timeline, apart from three small things:

  • We use an ordered list instead of an unordered list as that’s more semantically correct.
  • There’s an extra list item (the last one) which is empty. In an upcoming section, we’ll discuss the reason.
  • There’s an extra element (i.e. .arrows) which is responsible for the timeline navigation.

Here’s the required markup:

<section class="timeline">
        Some content here
    <!-- more list items here -->
  <div class="arrows">
    <button class="arrow arrow__prev disabled" disabled>
      <img src="arrow_prev.svg" alt="prev timeline arrow">
    <button class="arrow arrow__next">
      <img src="arrow_next.svg" alt="next timeline arrow">

The initial state of the timeline looks like this:

2. Adding Initial CSS Styles

After some basic font styles, color styles, etc. which I’ve omitted here for the sake of simplicity, we specify some structural CSS rules:

.timeline {
  white-space: nowrap;
  overflow-x: hidden;

.timeline ol {
  font-size: 0;
  width: 100vw;
  padding: 250px 0;
  transition: all 1s;

.timeline ol li {
  position: relative;
  display: inline-block;
  list-style-type: none;
  width: 160px;
  height: 3px;
  background: #fff;

.timeline ol li:last-child {
  width: 280px;

.timeline ol li:not(:first-child) {
  margin-left: 14px;

.timeline ol li:not(:last-child)::after {
  content: '';
  position: absolute;
  top: 50%;
  left: calc(100% + 1px);
  bottom: 0;
  width: 12px;
  height: 12px;
  transform: translateY(-50%);
  border-radius: 50%;
  background: #F45B69;

Most importantly here, you’ll notice two things:

  • We assign large top and bottom paddings to the list. Again, we’ll explain why that happens in the next section.
  • As you’ll notice in the following demo, at this point we cannot see all the list items because the list has width: 100vw and its parent has overflow-x: hidden. This effectively “masks” the list items. Thanks to the timeline navigation, however, we’ll be able to navigate through the items later.

With these rules in place, here’s the current state of the timeline (without any actual content, to keep things clear):

3. Timeline Element Styles

At this point we’ll style the div elements (we’ll call them “timeline elements” from now on) which are part of the list items as well as their ::before pseudo-elements.

Additionally, we’ll use the :nth-child(odd) and :nth-child(even) CSS pseudo-classes to differentiate the styles for the odd and even divs.

Here are the common styles for the timeline elements:

.timeline ol li div {
  position: absolute;
  left: calc(100% + 7px);
  width: 280px;
  padding: 15px;
  font-size: 1rem;
  white-space: normal;
  color: black;
  background: white;

.timeline ol li div::before {
  content: '';
  position: absolute;
  top: 100%;
  left: 0;
  width: 0;
  height: 0;
  border-style: solid;

Then some styles for the odd ones:

.timeline ol li:nth-child(odd) div {
  top: -16px;
  transform: translateY(-100%);

.timeline ol li:nth-child(odd) div::before {
  top: 100%;
  border-width: 8px 8px 0 0;
  border-color: white transparent transparent transparent;

And finally some styles for the even ones:

.timeline ol li:nth-child(even) div {
  top: calc(100% + 16px);

.timeline ol li:nth-child(even) div::before {
  top: -8px;
  border-width: 8px 0 0 8px;
  border-color: transparent transparent transparent white;

Here’s the new state of the timeline, with content added again:

As you’ve probably noticed, the timeline elements are absolutely positioned. That means they are removed from the normal document flow. With that in mind, in order to ensure that the whole timeline appears, we have to set large top and bottom padding values for the list. If we don’t apply any paddings, the timeline will be cropped:

Building a Horizontal Timeline With CSS and JavaScript

4. Timeline Navigation Styles

It’s now time to style the navigation buttons. Remember that by default we disable the previous arrow and give it the class of disabled.

Here are the associated CSS styles:

.timeline .arrows {
  display: flex;
  justify-content: center;
  margin-bottom: 20px;

.timeline .arrows .arrow__prev {
  margin-right: 20px;

.timeline .disabled {
  opacity: .5;

.timeline .arrows img {
  width: 45px;
  height: 45px;

The rules above give us this timeline:

5. Adding Interactivity

The basic structure of the timeline is ready. Let’s add some interactivity to it!


First things first, we set up a bunch of variables which we’ll use later.

const timeline = document.querySelector(".timeline ol"),
  elH = document.querySelectorAll(".timeline li > div"),
  arrows = document.querySelectorAll(".timeline .arrows .arrow"),
  arrowPrev = document.querySelector(".timeline .arrows .arrow__prev"),
  arrowNext = document.querySelector(".timeline .arrows .arrow__next"),
  firstItem = document.querySelector(".timeline li:first-child"),
  lastItem = document.querySelector(".timeline li:last-child"),
  xScrolling = 280,
  disabledClass = "disabled";

Initializing Things

When all page assets are ready, the init function is called.

window.addEventListener("load", init);

This function triggers four sub-functions:

function init() {
  animateTl(xScrolling, arrows, timeline);
  setSwipeFn(timeline, arrowPrev, arrowNext);
  setKeyboardFn(arrowPrev, arrowNext);

As we’ll see in a moment, each of these functions accomplishes a certain task.

Equal-Height Timeline Elements

If you jump back to the last demo, you’ll notice that the timeline elements don’t have equal heights. This doesn’t affect the main functionality of our timeline, but you might prefer it if all elements had the same height. To achieve this, we can give them either a fixed height via CSS (easy solution) or a dynamic height which corresponds to the height of the tallest element via JavaScript.

The second option is more flexible and stable, so here’s a function that implements this behavior:

function setEqualHeights(el) {
  let counter = 0;
  for (let i = 0; i < el.length; i++) {
    const singleHeight = el[i].offsetHeight;
    if (counter < singleHeight) {
      counter = singleHeight;
  for (let i = 0; i < el.length; i++) {
    el[i].style.height = `${counter}px`;

This function retrieves the height of the tallest timeline element and sets it as the default height for all the elements.

Here’s how the demo’s looking:

6. Animating the Timeline

Now let’s focus on the timeline animation. We’ll build the function that implements this behavior step-by-step.

First, we register a click event listener for the timeline buttons:

function animateTl(scrolling, el, tl) {
  for (let i = 0; i < el.length; i++) {
    el[i].addEventListener("click", function() {
      // code here

Each time a button is clicked, we check the disabled state of the timeline buttons and if they aren’t disabled, we disable them. This ensures that both buttons will be clicked only once until the animation finishes.

So, in terms of code, the click handler initially contains these lines:

if (!arrowPrev.disabled) {
  arrowPrev.disabled = true;

if (!arrowNext.disabled) {
  arrowNext.disabled = true;

The next steps are as follows:

  • We check to see if it’s the first time we’ve clicked on a button. Again, keep in mind that the previous button is disabled by default, so the only button that can be clicked initially is the next one.
  • If indeed it’s the first time, we use the transform property to move the timeline 280px to the right. The value of the xScrolling variable determines the amount of movement.
  • On the contrary, if we’ve already clicked on a button, we retrieve the current transform value of the timeline and add or remove to that value, the desired amount of movement (i.e. 280px). So, as long as we click on the previous button, the value of the transform property decreases and the timeline is moved from left to right. However, when the next button is clicked, the value of the transform property increases and the timeline is moved from right to left.

The code that implements this functionality is as follows:

let counter = 0; 
for (let i = 0; i < el.length; i++) {
  el[i].addEventListener("click", function() {
    // other code here
    const sign = (this.classList.contains("arrow__prev")) ? "" : "-";
    if (counter === 0) { = `translateX(-${scrolling}px)`;
    } else {
      const tlStyle = getComputedStyle(tl);
      // add more browser prefixes if needed here
      const tlTransform = tlStyle.getPropertyValue("-webkit-transform") || tlStyle.getPropertyValue("transform");
      const values = parseInt(tlTransform.split(",")[4]) + parseInt(`${sign}${scrolling}`); = `translateX(${values}px)`;

Great job! We’ve just defined a way of animating the timeline. The next challenge is to figure out when this animation should stop. Here’s our approach:

  • When the first timeline element becomes fully visible, it means that we’ve already reached the beginning of the timeline, and thus we disable the previous button. We also ensure that the next button is enabled.
  • When the last element becomes fully visible, it means that we’ve already reached the end of the timeline, and thus we disable the next button. We also, therefore, ensure that the previous button is enabled.

Remember that the last element is an empty one with width equal to the width of the timeline elements (i.e. 280px). We give it this value (or a higher one) because we want to make sure that the last timeline element will be visible before disabling the next button.

To detect whether the target elements are fully visible in the current viewport or not, we’ll take advantage of the same code we used for the vertical timeline. The required code which comes from this Stack Overflow thread is as follows:

function isElementInViewport(el) {
  const rect = el.getBoundingClientRect();
  return ( >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)

Beyond the function above, we define another helper:

function setBtnState(el, flag = true) {
  if (flag) {
  } else {
    if (el.classList.contains(disabledClass)) {
    el.disabled = false;

This function adds or removes the disabled class from an element based on the value of the flag parameter. In addition, it can change the disabled state for this element.

Given what we’ve described above, here’s the code we define for checking whether the animation should stop or not:

for (let i = 0; i < el.length; i++) {
  el[i].addEventListener("click", function() {
    // other code here
    // code for stopping the animation
    setTimeout(() => {
      isElementInViewport(firstItem) ? setBtnState(arrowPrev) : setBtnState(arrowPrev, false);
      isElementInViewport(lastItem) ? setBtnState(arrowNext) : setBtnState(arrowNext, false);
    }, 1100);
    // other code here

Notice that there’s a 1.1 second delay before executing this code. Why does this happen?

If we go back to our CSS, we’ll see this rule:

.timeline ol {
  transition: all 1s;

So, the timeline animation needs 1 second to complete. As long as it completes, we wait for 100 milliseconds and then, we perform our checks.

Here’s the timeline with animations:

7. Adding Swipe Support

So far, the timeline doesn’t respond to touch events. It would be nice if we could add this functionality though. To accomplish it, we can write our own JavaScript implementation or use one of the related libraries (e.g. Hammer.js, TouchSwipe.js) that exist out there.

For our demo, we’ll keep this simple and use Hammer.js, so first, we include this library in our pen:

Building a Horizontal Timeline With CSS and JavaScript

Then we declare the associated function:

function setSwipeFn(tl, prev, next) {
  const hammer = new Hammer(tl);
  hammer.on("swipeleft", () =>;
  hammer.on("swiperight", () =>;

Inside the function above, we do the following:

  • Create an instance of Hammer.
  • Register handlers for the swipeleft and swiperight events.
  • When we swipe over the timeline in the left direction, we trigger a click to the next button, and thus the timeline is animated from right to left.
  • When we swipe over the timeline in the right direction, we trigger a click to the previous button, and thus the timeline is animated from left to right.

The timeline with swipe support:

Adding Keyboard Navigation

Let’s further enhance the user experience by providing support for keyboard navigation. Our goals:

  • When the left or right arrow key is pressed, the document should be scrolled to the top position of the timeline (if another page section is currently visible). This ensures that the whole timeline will be visible.
  • Specifically, when the left arrow key is pressed, the timeline should be animated from left to right.
  • In the same way, when the right arrow key is pressed, the timeline should be animated from right to left.

The associated function is the following:

function setKeyboardFn(prev, next) {
  document.addEventListener("keydown", (e) => {
    if ((e.which === 37) || (e.which === 39)) {
      const timelineOfTop = timeline.offsetTop;
      const y = window.pageYOffset;
      if (timelineOfTop !== y) {
        window.scrollTo(0, timelineOfTop);
      if (e.which === 37) {;
      } else if (e.which === 39) {;

The timeline with keyboard support:

8. Going Responsive

We’re almost done! Last but not least, let’s make the timeline responsive. When the viewport is less than 600px, it should have the following stacked layout:

Building a Horizontal Timeline With CSS and JavaScript

As we’re using a desktop-first approach, here are the CSS rules that we have to overwrite:

@media screen and (max-width: 599px) {
  .timeline ol,
  .timeline ol li {
    width: auto; 
  .timeline ol {
    padding: 0;
    transform: none !important;
  .timeline ol li {
    display: block;
    height: auto;
    background: transparent;
  .timeline ol li:first-child {
    margin-top: 25px;
  .timeline ol li:not(:first-child) {
    margin-left: auto;
  .timeline ol li div {
    width: 94%;
    height: auto !important;
    margin: 0 auto 25px;
  .timeline ol li:nth-child div {
    position: static;
  .timeline ol li:nth-child(odd) div {
    transform: none;
  .timeline ol li:nth-child(odd) div::before,
  .timeline ol li:nth-child(even) div::before {
    left: 50%;
    top: 100%;
    transform: translateX(-50%);
    border: none;
    border-left: 1px solid white;
    height: 25px;
  .timeline ol li:last-child,
  .timeline ol li:nth-last-child(2) div::before,
  .timeline ol li:not(:last-child)::after,
  .timeline .arrows {
    display: none;

Note: For two of the rules above, we had to use the !important rule to override the related inline styles applied through JavaScript.

The final state of our timeline:

Browser Support

The demo works well in all recent browsers and devices. Also, as you’ve possibly noticed, we use Babel to compile our ES6 code down to ES5.

The only small issue I encountered while testing it is the text rendering change that happens when the timeline is being animated. Although I tried various approaches proposed in different Stack Overflow threads, I didn’t find a straightforward solution for all operating systems and browsers. So, keep in your mind that you might see small font rendering issues as the timeline is being animated.


In this fairly substantial tutorial, we started with a simple ordered list and created a responsive horizontal timeline. Without doubt, we covered a lot of interesting things, but I hope you enjoyed working towards the final result and that it’s helped you gain some new knowledge.

If you have any questions or if there’s anything you didn’t understand, let me know in the comments below!

Next Steps

If you want to further improve or extend this timeline, here are a few things you can do:

  • Add support for dragging. Instead of clicking the timeline buttons for navigating, we could just drag the timeline area. For this behavior, you could use either the native Drag and Drop Api (which unfortunately doesn’t support mobile devices at the time of writing) or an external library like Draggable.js.
  • Improve the timeline behavior as we resize the browser window. For instance, as we resize the window, the buttons should be enabled and disabled accordingly.
  • Organize the code in a more manageable way. Perhaps, use a common JavaScript Design Pattern.