Updated 8 Nov 2021.

When attending a large music festival there's always the challenge of getting an overview of stages and concurrent performances – in order to plan your own personal schedule.

As I attended the big Danish Roskilde Festival (highly recommended, btw) earlier this year, I used their clever schedule in the festival's native app. In the schedule, all stages and music performances are displayed in a timeline along a single large scroll-and-pan canvas.

I looks and works like this:

Interestingly, on their website, the festival uses a different design – and probably for good reason (hint: limited mobile browser support):

This made me wonder whether it would be possible to recreate the native app design using only HTML and CSS, that is, without relying on JavaScript.

Fast forward, after some experimentation, getting to know CSS native grid a bit better, I reached a working design:

Try it out yourself, preferably in a desktop browser:

Festival schedule demo

Position: sticky to the rescue

For each stage, the HTML looks like this:

  <header class="stage-name-wrapper">
    <h2 class="stage-name">Pavilion</h2>
  <div class="timeline">
    <div class="timeline-event" data-event-id="2132">Artist 2132</div>
    <div class="timeline-event" data-event-id="1646">Artist 1646</div>
    <div class="timeline-event" data-event-id="2684">Artist 2684</div>
    <div class="timeline-event" data-event-id="703">Artist 703</div>

To make the timeline slots and stage names stay put when scrolling and panning, I use position: sticky, where elements works as position: fixed when the user scrolls/pans beyond a specified treshold:

.stage-name {
  position: sticky;
/* The tresholds used */
.timeline-header {
  top: 0;
.stage-name {
  left: 0; 

Furthermore, regarding the time slots for music events, CSS grid layout is very well suited. If naming grid lines by time (24 hour clock), placing events on the timeline becomes very straightforward in a human-readable format:

.timeline {
  display: grid;
  grid-template-columns: [t-1400] 7rem [t-1430] 7rem [t-1500] 7rem [t-1530] 7rem [t-1600] 7rem [t-1630] 7rem [t-1700] 7rem [t-1730] 7rem [t-1800] 7rem [t-1830] 7rem [t-1900] 7rem [t-1930] 7rem [t-2000] 7rem [t-2030] 7rem [t-2100] 7rem [t-2130] 7rem [t-2200] 7rem [t-2230] 7rem [t-2300] 7rem [t-2330] 7rem [t-2400];
  column-gap: var(--timeline-slot-gap);


[data-event-id="1646"] {
  grid-row: 1;
  grid-column: t-1400 / t-1530; /* event duration from 14:00 to 15:30 o'clock */

[data-event-id="2684"] {
  grid-row: 1;
  grid-column: t-1600 / t-1800; /* event duration from 16:00 to 18:00 o'clock */


On a sidenote, I had some problems with CSS native grid and horizontal sizing. Contrary to my expectation, a wide grid didn't make the parent elements – all up to the <body> element – grow accordingly. So, the parent elements were narrower than the grid, making position sticky stop working beyond the borders of the parents. Thus I had to explicitly make parents as wide as the grid:

body {
  /* The full width of all slots and spaces between slots (slot-gaps) */
  width: calc((var(--slot-count) + 1) * var(--slot-width) + (var(--slot-count) - 1) * var(--slot-gap));
  /* Updated 8 Nov 2021 - Instead of manually calculating width as in the line above, we can let the browser do the math with this much simpler rule: */
  width: fit-content;

Perhaps I just don't understand CSS grid well enough...

Position sticky should be quite well supported by modern browsers caniuse.com/#feat=css-sticky, however, I have experienced some issues in mobile browsers such as Chrome, Firefox and Edge (tested on Android), where sticky doesn't work as expected leading to the interaction design "failing". Some of the issues may also be related to using CSS custom properties (="variables") in calc(...) expressions. Which is probably why Roskilde Festival settled for a web design less cool (and useful) compared to that of their native app :)


html-css navigation widget-design

Related posts

Please note: By using this site, you accept cookies from Google Analytics.