Responsive CSS Timeline with 3D Effect

A tutorial about how to create an experimental CSS-only timeline with a 3D effect. The idea is to expand a content area when the associated radio input is selected.

Responsive CSS Timeline with 3D Effect

In this tutorial we are going to experiment with perspective and use a radio input trick to create a fun css-only timeline-like structure. The idea is to show a teaser of an item and when clicking on the associated radio input, the content will expand and rotate in 3D, giving some depth to the whole thing. We’ll be using CSS 3D transform, transitions and sibling selectors.

Please note: this only works as intended in browsers that support the respective CSS properties.

Also note that the 3D effect looks best in WebKit browsers. Unfortunately, Firefox does not play along very nicely.

Let’s get started!

The Markup

Let’s create an unordered list which will have the class “timeline”. We’ll add several list items with the class “event”. Each event will have a radio input, an empty label, a thumbnail and a container for the content. This container will have perspective, so we’ll give it the class “content-perspective”. Note that the radio inputs all have the same name. That’s how we indicate that they all belong to the same group and we can only select one at a time.

<ul class="timeline">
	
	<li class="event">
		<input type="radio" name="tl-group" checked/>
		<label></label>
		<div class="thumb user-4"><span>19 Nov</span></div>
		<div class="content-perspective">
			<div class="content">
				<div class="content-inner">
					<h3>I find your lack of faith disturbing</h3>
					<p>Some text</p>
				</div>
			</div>
		</div>
	</li>

	<li class="event">
		<input type="radio" name="tl-group"/>
		<!-- ... -->
	</li>

	<!-- ... -->

</ul>

The thumbnail will have the class “thumb” and and additional class for the user which will be user-1 to user-8 in our case. The span will be used to add the date.

Tiny break: 📬 Want to stay up to date with frontend and trends in web design? Subscribe and get our Collective newsletter twice a tweek.

The CSS

Note that the CSS will not contain any vendor prefixes, but you will find them in the files.
We’ll be using the border-box box-sizing and reset all the paddings and margins with the following snippet:

*,
*:after,
*:before {
	box-sizing: border-box;
	padding: 0;
	margin: 0;
}

You can of course omit the resetting of the paddings and margins, just keep in mind to adjust them when we define the style of the following elements.

Let’s first add a font that we’ve created with fontello.com. This font will only have four characters, two arrows and two states that we’ll associate with being “checked” and “unchecked”. It’s part of the Font Awesome font:

@font-face {
	font-family: 'fontawesome-selected';
	src: url("font/fontawesome-selected.eot");
	src: 
	url("font/fontawesome-selected.eot?#iefix") format('embedded-opentype'), 
	url("font/fontawesome-selected.woff") format('woff'), 
	url("font/fontawesome-selected.ttf") format('truetype'), 
	url("font/fontawesome-selected.svg#fontawesome-selected") format('svg');
	font-weight: normal;
	font-style: normal;
}

Let’s set the timeline to be relative and add some padding to it:

.timeline {
	position: relative;
	padding: 30px 0 50px 0;
	font-family: 'Gorditas', Arial, sans-serif;
}

The white striped line will be created by using a pseudo element. It’s position will be absolute and we’ll give it a striped background (made with Patternify):

.timeline:before {
	content: '';
	position: absolute;
	width: 5px;
	height: 100%;
	top: 0;
	left: 165px;
	background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAJUlEQVQIW2NkQAP/gYARWQwkAOLDBeECjEAAkkEWAKtEFwAJAgAZEBP+oM1IkwAAAABJRU5ErkJggg==);
}

Each event will be of position relative and we’ll add some margin to the bottom to have some space between the items and some padding to the right. This will make sure that the list item does not overflow too much to the right when we apply the 3D rotation:

.event {
	position: relative;
	margin-bottom: 80px;
	padding-right: 40px;
}

Let’s take care of the left side for now. Here we want the thumbnail to show up, so we’ll set it to absolute, make it round by setting the border-radius to 50%:

.thumb {
	position: absolute;
	width: 100px;
	height: 100px;
	box-shadow: 
		0 0 0 8px rgba(65,131,142,1), 
		0 1px 1px rgba(255,255,255,0.5);
	background-repeat: no-repeat;
	border-radius: 50%;
	transform: scale(0.8) translateX(24px);
}

We’ll also add a transformation which will scale it down a bit. Our aim is to scale it up, once the corresponding radio button is selected. Since the scaling will move the element a bit, we could either set the transform origin or translate it on the X-axis. We’ll go for the latter one and translate it 24 pixels. Why do we move it at all? We’ll have a little pseudo-element with a zig zag background that will point from the thumbnail and we want it to “touch” the radio input:

.thumb:before {
	content: '';
	position: absolute;
	height: 8px;
	z-index: -1;
	background: transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAICAYAAADA+m62AAAAPElEQVQYV2NkQAP/gYARCNDFUQRgirAphitEl0TngxXisg5ZnBGXIpgbYfIYjkb3BNxGbBLYxIgyEaQRAA8KKAWYsZtjAAAAAElFTkSuQmCC);
	width: 51px;
	top: 42px;
	left: 100%;
	margin-left: 8px;
}

Let’s style the span for the date. We’ll set it to position absolute and put it under the thumbnail:

.thumb span {
	color: #41838e; 
	width: 100%;
	text-align: center;
	font-weight: 700;
	font-size: 15px;
	text-transform: uppercase;
	position: absolute;
	bottom: -30px;
}

Now, let’s define the different background images for the users (we could also use images instead of background-images):

.user-1 {
	background-image: url(../images/chewbacca.jpg);
}

.user-2 {
	background-image: url(../images/barf.jpg);
}

.user-3 {
	background-image: url(../images/darkhelmet.jpg);
}

.user-4 {
	background-image: url(../images/darthvader.jpg);
}

.user-5 {
	background-image: url(../images/leia.jpg);
}

.user-6 {
	background-image: url(../images/vespa.jpg);
}

.user-7 {
	background-image: url(../images/c3po.jpg);
}

.user-8 {
	background-image: url(../images/dotmatrix.jpg);
}

For the radio input we’ll use a little trick. We want our transitions and changes to trigger when we click on an input since we can make use of the “checked” state and the sibling selectors. There are many different ways to do it, one of them involving clicking on the label which selects the input in most of the browsers automatically. But we don’t want to use that hack, instead we’ll just use the input itself by stacking it on top of the label and making it transparent. What appears to be a click on the label is actually a click on the input. The label is just used for fanciness in this case, and if we could add pseudo-elements to inputs, we wouldn’t need it at all.

So, what we do is to give both, the label and the input the same width and height and put them in the same place:

.event label,
.event input[type="radio"] {
	width: 24px;
	height: 24px;
	left: 158px;
	top: 36px;
	position: absolute;
	display: block;
}

Since the input needs to precede all the other elements (remember that we want to reach them via the sibling selector), we’ll give it a higher z-index, to be on top of the label. If we wouldn’t do that, the label would naturally be on top of it:

.event input[type="radio"] {
	opacity: 0;
	z-index: 10;
	cursor: pointer;
}

Now, let’s create a little pseudo-element for the label, one that will contain a little icon from the icon font that we’ve included before. We’ll pull it into place and position it absolutely:

.event label:after {
	font-family: 'fontawesome-selected';
	content: 'e702';
 	background: #fff;
    border-radius: 50%;
    color: #41838E;
    font-size: 26px;
    height: 100%;
    width: 100%;
    left: -2px;
    top: -3px;
    line-height: 24px;
    position: absolute;
    text-align: center;
}

Let’s move on to the right side of our timeline, the content. We need a wrapper with perspective which will have a left margin that will move it to the side:

.content-perspective {
	margin-left: 230px;
	position: relative;
	perspective: 600px;
}

To “connect” the content part with the input, we’ll use a pseudo element that will look like a line:

.content-perspective:before {
	content: '';
	width: 37px;
	left: -51px;
	top: 45px;
	position: absolute;
	height: 1px;
	z-index: -1;
	background: #fff;
}

It’s important that we set the pseudo-class to the perspective wrapper since we don’t want it to rotate like anything else inside.

So, the content will be rotated 10 degrees, which, together with a transform origin set to the outer top and left, will make it appear as if its right side is being pushed back:

.content {
	transform: rotateY(10deg);
	transform-origin: 0 0;
	transform-style: preserve-3d;
}

The inner content will actually have the background color and the box shadow. We need this extra element in order to avoid funny things happening:

.content-inner {
	position: relative;
	padding: 20px;
	color: #333;
	border-left: 5px solid #41838e;
	box-shadow: 0 4px 6px rgba(0,0,0,0.1);
	background: #fff;
}

Let’s make the h3 look pretty:

.content-inner h3 {
	font-size: 26px;
	padding: 5px 0 5px 0;
	color: #41838e;
}

The paragraph will have a special role since it will transition it’s height. Because we can’t animate to height: auto (which would be optimal since we don’t know the height of the element), we’ll need to use a smart hack suggested by Lea Verou. We’ll animate the max-height value instead. Note that this does only create the desired transition if the max-height value is very close to the real height itself. So, we’ll need to adjust the max-height to not be too big. This is not a good solution, especially in a responsive setting. You’ll of course not want to worry about that since it almost breaks down to the same problem as setting the height itself. For our experiment we don’t want to use any JavaScript, so this is the best option and we’ll also define some media queries, so that things don’t break.

So, we set the max-height to 0 pixel and give the paragraph a transparent color (playing with opacities here was causing some flickering, so we’ll use this trick instead):

.content-inner p {
	font-size: 1.8px;
	max-height: 0px;
	overflow: hidden;
	color: rgba(0,0,0,0);
	text-align: left;
}

The aim is to expand the paragraph and animate the alpha opacity value of the RGBA to 1, simulating a fading in.

Let’s add the little arrow to the left side, using the icon font:

.content-inner:before {
	font-family: 'fontawesome-selected';
	content: '25c2';
	font-weight: normal;
	font-size: 54px;
	line-height: 54px;
	position: absolute;
	width: 30px;
	height: 30px;
	color: #41838e;
	left: -22px;
	top: 19px;
	z-index: -1;
}

Next, we’ll define all the different transitions for the respective elements. We first want the content to expand and after that we want all the other transitions to happen. So we’ll need to add some delay for the resting transitions.

Let’s give the thumbnail, its span and the headline of the content a transition with 0.2s delay:

.thumb,
.thumb span,
.content-inner h3 {
	transition: all 0.6s ease-in-out 0.2s;
}

The inner content needs a transition for the box shadow:

.content-inner {
	transition: box-shadow 0.8s linear 0.2s;
}

The content will have a transition for the transformation and we’ll add a nice smooth bounce as the timing function:

.content {
	transition: transform 0.8s cubic-bezier(.59,1.45,.69,.98) 0.2s;
}

Now, the paragraph needs a transition for the max-height and for the color.

.content-inner p {
	transition: max-height 0.5s linear, color 0.3s linear;
}

Next, we’ll define what happens when we select a radio input. Once a radio is “checked”, we’ll give a different icon to the label’s pseudo class and change its color and box shadow:

.event input[type="radio"]:checked + label:after {
	content: '2714';
	color: #F26328;
	box-shadow: 0 0 0 5px rgba(255, 255, 255, 0.8);
}

Let’s also change the color of the line and the heading of the content:

.event input[type="radio"]:checked ~ .content-perspective:before {
	background: #F26328;
}

.event input[type="radio"]:checked ~ .content-perspective .content-inner h3 {
	color: #F26328;
}

The content will rotate to the front:

.event input[type="radio"]:checked ~ .content-perspective .content {
	transform: rotateY(-5deg);
}

And the inner content will get a different border color and box shadow:

.event input[type="radio"]:checked ~ .content-perspective .content-inner {
	border-color: #F26328;
	box-shadow: 10px 0px 10px -6px rgba(0, 0, 0, 0.1);
}

The paragraph will animate to a max-height of 260 pixels and the color will become more opaque:

.event input[type="radio"]:checked ~ .content-perspective .content-inner p {
	max-height: 260px; /* Add media queries */
	color: rgba(0,0,0,0.6);
	transition-delay: 0s, 0.6s;
}

The transition delay set here will ensure that the color transition will happen with a delay. We first want the max-height to animate and then then the transparency of the RGBA color.

Let’s change the color of the arrow:

.event input[type="radio"]:checked ~ .content-perspective .content-inner:before {
	color: #F26328;
}

The thumbnail will scale up (and translate back to 0px) and we’ll give it a border-like box shadow:

.event input[type="radio"]:checked ~ .thumb {
	transform: scale(1);
	box-shadow: 
		0 0 0 8px rgba(242,99,40,1), 
		0 1px 1px rgba(255,255,255,0.5);
}

The date will change color:

.event input[type="radio"]:checked ~ .thumb span {
	color: #F26328;
}

And we’ll replace the blue zig zag line with an orange one:

.event input[type="radio"]:checked ~ .thumb:before {
	background: transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAICAYAAADA+m62AAAAPUlEQVQYV2NkQAOfUjX+882+wYgujiIAU4RNMVwhuiQ6H6wQl3XI4oy4FMHcCJPHcDS6J2A2EqUQpJhohQBbNyaHFmzEqgAAAABJRU5ErkJggg==);
}

Now we need to make sure that everything looks fine when we view this with smaller screens. At 850 pixel we’ll set the font sizes to be smaller and also reset the max-height of the paragraph:


@media screen and (max-width: 850px) { 
	.content-inner h3 {
		font-size: 20px;
	}

	.content-inner p {
		font-size: 14px;
		text-align: justify;
	}

	.event input[type="radio"]:checked ~ .content-perspective .content-inner p {
		max-height: 500px; 
	}
}

From 540 pixel we need to do some more stuff. Since everything is getting a bit crammed, we will change out layout a bit. The thumbnail will stay where it is, but the content will be moved from he right side to under the thumbnail. The input will be placed on top of the thumbnail, so that we need to click the there in order to open the content. We’ll get rid of some elements that we won’t need here anymore, like the label and the lines. We’ll also change the rotation of the content making it stick out at the bottom and not the right side:

@media screen and (max-width: 540px) {
	.timeline:before {
		left: 50px;
	}

	.event { 
		padding-right: 0px;
		margin-bottom: 100px;
	}

	.thumb {
		transform: scale(0.8);
	}

	.event input[type="radio"] {
		width: 100px;
		height: 100px;
		left: 0px;
		top: 0px;
	}

	.thumb:before,
	.event input[type="radio"]:checked ~ .thumb:before {
		background: none;
		width: 0;
	}

	.event label {
		display: none;
	}

	.content-perspective {
		margin-left: 0px; 
		top: 80px;
	}

	.content-perspective:before {
		height: 0px; 
	}

	.content {
		transform: rotateX(-10deg);
	}

	.event input[type="radio"]:checked ~ .content-perspective .content {
		transform: rotateX(10deg);
	}

	.content-inner {
		border-left: none;
		border-top: 5px solid #41838e;
	}

	.event input[type="radio"]:checked ~ .content-perspective .content-inner {
		border-color: #F26328;
		box-shadow: 0 10px 10px -6px rgba(0, 0, 0, 0.1);
	}

	.content-inner:before {
		content: '25b4';
		left: 33px;
		top: -32px;
	}

	.event input[type="radio"]:checked ~ .content-perspective .content-inner p {
		max-height: 300px; 
	}
}

The max-height value needs to be adjusted again because now we’ll have a bit more horizontal space for the paragraph.

If you’d like it to be possible to open more than one content item, just try out using checkboxes instead of radio inputs.

And that’s it! I hope you enjoyed this tutorial and find it inspiring!

Manoela Ilic

Manoela is the main tinkerer at Codrops. With a background in coding and passion for all things design, she creates web experiments and keeps frontend professionals informed about the latest trends.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!

Feedback 44

Comments are closed.
  1. In the example I love the Chewbacca item “Rrrrrrr rrrraaaahhh rrrrrrggghhhhnn! ” thanks for the tutorial!

  2. Simply beautiful. This will make a wonderful commenting system. Do you think it will be difficult to add nested/layered comments?

    • Thanks! That shouldn’t be too difficult, I guess… and I can imagine it with some nice delayed transition effect! 🙂 Cheers, ML

  3. WOW, awesome, from where did you get this idea Mary ?,
    making Facebook timeline 3D effected and responsive ?
    great idea.

  4. Fonts renders horrible in webkit (PC)! A bit better in Firefox but always bad quality

  5. Hi,
    interesting tutorial to read but as long as some major browsers do not support the needed features I won’t use it for projects. Still I don’t loose faith that this will change in the nearer future…

  6. I would like to incorporate this – with some obvious tuning to my likes – into a website I am building, but I have a problem. I am wondering if there is a fix, or something you can create to make it degrade/work in ie8 and ie7 as well. Some of the customers that would be looking at the site will still be running out dated browsers due to restrictions from their work ITdepartments. Just curious if you know of anyway to make this work in an older browser degraded, or just have the answers show up? Any help would be appreciated, thanks so much!

    -Tusk

  7. I like this script..but want to know that is it possible to handle this event clicking from any query string or anchor tag or something else. because i want to handle it from the address bar.. just like “index.html#event5″…

    Thanx in advance

  8. Awesome tutorial, THUMBS UP!

    Question? Is there a way to start from the top of the article when it’s clicked (checked)?

  9. Hy…congratulations for your job! amazing!!!! but I have a question….I try to do this in a wordpress web site but after i upload all the code and the css I can’t see the text…..I don’t understand why? Can you help me??

  10. Hi Mary,

    Well done for your works! Quick question….. how do I make the actual round image clickable? I think most of the users would rather click on the image instead of the small circle in between. Is there any way to do that?

    Thanks,

    Peter