Building sliding cards with position: sticky

How I got the tabs to scroll on sarajoy.dev

Dec 11, 2024ยท

8 min read

Building sliding cards with position: sticky

I've moved this draft to my site!

See sarajoy.dev/blog/scrolling-tabs for further progress :)

CSS subversion?

Late-2021: I ditched my newly-built portfolio, already in need of much modernising (I was on a bit of a journey), as it was made with what I knew about HTML and CSS from 15-20 years ago, plus scraps picked up since.

I wracked my brain for ideas. Played with CSS concepts that were new to me like position: sticky; and display: grid; / flex; and started messing with them.

Could I subvert them somehow, make something different? Is there anything new in the world, that hasn't already been done?

If you haven't seen it already, here's my website: sarajoy.dev. It may not be groundbreaking, but I'm satisfied that it's a bit novel. So how did I do it?

Overlapping cards

I saw people on Stack Overflow battling against sticky elements pushing each other out of the way, or overlapping, and trying to get the opposite behaviour - and I got the idea that maybe, the overlapping of elements is good, actually.

What if the overlapping elements were big enough to have real content in them? What if they took up the whole page? And what if they could look like they were stacking on top of each other as you scrolled?

Code to set up cards

This is a tidied-up version of my first pass with this idea:

Have a click around and you might find some of the limitations. This truly was just the first pass, it doesn't work quite right just yet...

I tried to keep the HTML structure quite simple for this proof of concept. A very simple nav bar across the top within a <header> element, and three currently empty <section> elements with <h2> headings within:

<header>
  <h1 id="name"><a href="#">Name / Logo</a></h1>
  <nav>
    <a href="#intro">Intro</a>
    &centerdot;
    <a href="#stuff">Stuff</a>
    &centerdot;
    <a href="#contact">Contact</a>
  </nav>
</header>

<main>
  <section id="intro">
    <h2>Introduction</h2>
  </section>
  <section id="stuff">
    <h2>Stuff</h2>
  </section>
  <section id="contact">
    <h2>Contact</h2>
  </section>
</main>

Scroll down within the CSS on Codepen and you'll find the comments /* header/nav layout */ and /* sliding card styles */, under which you'll find the following code - I've added some extra comments here to describe what I'm up to:

/* header/nav layout */
header {
  /* line up the nav bar */
  display: flex;
  justify-content: space-between;
  align-items: baseline;

  /* leave some space around the top and sides */
  top: 5px;
  height: calc(100vh - 5px);
  width: calc(100% - 16px);

  /* give it the base colour and put
  it at the bottom of the stack */
  background-color: hsl(200, 15%, 20%);
  z-index: 0;
}

/* sliding card styles */
section,
header {
  /* each card will:
  - not scroll past the top of the viewport 
  - be centred horizontally
  - have rounded top corners
  - have a shadow so it looks like it's stacked on top
  */
  position: sticky;
  margin-inline: auto;
  border-top-left-radius: 5px;
  border-top-right-radius: 5px;
  box-shadow: 0px 0px 8px -3px black;
}

These two declaration blocks (a.k.a. rulesets or style rules) set up the general shape of all the cards - but without content or a height set for the sections under the header, they're stuck at the bottom with only the height of their headings, as there is nothing and nowhere to scroll to.

So, now we need to give the sections more properties, so we can scroll through them. They all have subtle differences - I have added comments on the first ruleset:

#intro {

  /* Intro element will not scroll up over the header/nav */
  top: calc(5px + 3rem);

  /* when clicking Intro link, the anchor will not try to scroll
  higher than given top:, or it will pull up the next card too */
  scroll-margin-top: calc(5px + 3rem);

  /* if the card height + top: is taller than the viewport,
  it will keep scrolling beyond where we want it to stop */
  height: calc(100vh - 5px - 3rem);

  /* width is ever so slightly wider than element below,
  to give the effect of being on top/closer to the viewer */
  width: calc(100% - 12px);

  /* colour is slightly lighter than the element underneath */
  background-color: hsl(200, 15%, 25%);

  /* element is one level higher than element underneath */
  z-index: 1;
}
#stuff {
  top: calc(5px + 6rem);
  scroll-margin-top: calc(5px + 6rem);
  height: calc(100vh - 5px - 6rem);
  width: calc(100% - 8px);
  background-color: hsl(200, 15%, 30%);
  z-index: 2;
}
#contact {
  top: calc(5px + 9rem);
  scroll-margin-top: calc(5px + 9rem);
  height: calc(100vh - 5px - 9rem);
  width: calc(100% - 4px);
  background-color: hsl(200, 15%, 35%);
  z-index: 3;
}

If you look through the rulesets, you see each successive card is:

  • shorter (in vertical a.k.a. block height)

  • scrolling up to a lower top position

  • wider than the previous card

  • lighter (in colour)

  • one layer closer to the viewer

I've made the spacing between the card tops 3rem, and added 2px extra width on each side of each card as they stack up.

It looks nice and scrolls pretty well with your scroll wheel or flicking on a touch screen, and the links work - or, do they?

All of the anchors!

Each card has an id="tabname" set within each tab <section> - and the links across the top of the header point to them. And they work. But only on the way down (I mean, the content looks like it is moving up rather than down, but you get what I mean)!

Once a card has been pulled up and is in the viewport, it stays there because it's sticky. It doesn't disappear away above the viewport - so if you hit a link to one of the #tabnames but the tab is already up even if hidden beneath a subsequent tab, nothing changes. "It's in the viewport already, dummy," says the anchor, "can't you see it?" ๐Ÿ˜ฌ

To get past this issue, we need to have some invisible elements with anchors that stay in place where the tops of the cards would have been, had they not become stuck at the top of the viewport. This is where the mental gymnastics begin! They will invisibly slide up past the viewport, and when we click a link which references one of them, they will get pulled back into the viewport, bringing the rest of the page with them.

(At this point, the rebuilding of my sliding tabs into CodePen is starting to diverge from what is live on my site! Not to worry - I'm seeing flaws as I go and this way should be the best or simplest way ๐Ÿคž. We'll see if I refactor my site to match...)

Adding the invisible elements

The id="tabname" now moves into a div added above the corresponding section. The sections now have classes, instead. The navigation can be left as it is.

<main>
  <!-- new element below, with id that
  previously belonged to the section -->
  <div id="intro"></div>
  <!-- for CSS selection, the section
  now has a class instead of an id -->
  <section class="intro">
    <h2><a href="#intro">Introduction</a></h2>
  </section>
  <div id="stuff"></div>
  <section class="stuff">
    <h2><a href="#stuff">Stuff</a></h2>
  </section>
  <div id="contact"></div>
  <section class="contact">
    <h2><a href="#contact">Contact</a></h2>
  </section>
</main>

The styles need just a little editing. The #tabname selectors now need to be .tabname instead. The new #tabname elements now need the scroll-margin-top property that the sections had.

/* new element with the id now takes
over the scroll-margin-top property */
#intro {
  scroll-margin-top: calc(5px + 3rem);
}
/* section selector now needs . not # */
.intro {
  top: calc(5px + 3rem);
  height: calc(100vh - 5px - 3rem);
  width: calc(100% - 12px);
  background-color: hsl(200, 15%, 25%);
  z-index: 1;
}
#stuff {
  scroll-margin-top: calc(5px + 6rem);
}
.stuff {
  top: calc(5px + 6rem);
  height: calc(100vh - 5px - 6rem);
  width: calc(100% - 8px);
  background-color: hsl(200, 15%, 30%);
  z-index: 2;
}
#contact {
  scroll-margin-top: calc(5px + 9rem);
}
.contact {
  top: calc(5px + 9rem);
  height: calc(100vh - 5px - 9rem);
  width: calc(100% - 4px);
  background-color: hsl(200, 15%, 35%);
  z-index: 3;
}

The eagle-eyed may have spotted that I also made the <h2> headings into links to their own sections. Why not? Now if you have a tab in the viewport but not fully visible, you can click it to surface the rest.

Adding tabs

The next thing I wanted to do was to stop the header just growing ever bigger with every subsequent card - but couldn't just let them land on top of each other, or you lost all ability to use the navigation.

I took inspiration from the tabs that stick out from the side of ring binder dividers and moved the <h2> above the top of the scrolling sections.

Here's the changed CSS for the section headings, with some styling:

/* I've added a couple of custom properties a.k.a.
variables into the :root ruleset for convenience */
:root {
  --tabheight: 3rem;
  --tabwidth: 14ch;
}

/* [some other CSS here...] */

h2 {
  font-size: clamp(1rem, 3vw, 1.25rem);
  padding: 0.5em;

  /* heading has new position */
  position: absolute;
  top: calc(-0.8 * var(--tabheight));
  height: var(--tabheight);
  width: var(--tabwidth);
  text-align: center;
  border-top-left-radius: 5px;
  border-top-right-radius: 5px;
  box-shadow: 0px -3px 5px -3px black;
}

This alone already makes it look cool, but they're all still taking up lots of space and overlapping badly.

The sections now have cute little tabs with headings sticking out the top of the cards. But, they're all on top of each other, vertically.

So there's some more work to do! Let's shuffle the tabs along. First, we can take all the separate top: and height: declarations out of the specific sections and put them into just one ruleset.

section {
  top: calc(5px + var(--tabheight));
  height: calc(100vh - 5px - var(--tabheight));
}

That piles all the tabs up like so:

Now the tabs really are on top of each other. Only the Contact tab is visible, and the name/logo of the website is hidden underneath.

So we need to go back into the individual tab styles and shift them along.

Todo:

Code for moving tabs across

Scroll margin can move out of separate invisible divs into one declaration for them all

Add embedded codepen

Limited to viewport height

Scrolling larger content, v1

within the viewport-height card

Separating the 'shoulders' a.k.a. scrolling larger content, v2

Z-index madness

Fixing the bottom of each scrolling section

for the next card to slide over it