I am a huge fan of reveal.js and have used it for almost every talk I’ve ever given. It is a full-featured presentation framework with loads of nifty features, like speaker notes, full-screen mode, code syntax highlighting etc.

But sometimes, I just need to toss up a handful of information on a screen at a local meetup or office presentation. And as a result of being a web developer, I’m way more comfortable controlling and adjusting my layouts with CSS than with a pointy-clicky interface.

I came across an implementation by Ondřej Žára for pure CSS slides and dug into his codebase to see what I could use for my own watered-down HTML slides. For a pure CSS implementation, it’s pretty full-featured, and you should check it out regardless.

The :target pseudo-class

What I wanted was a good way to transition from slide to slide, and I noticed Ondřej’s use of the :target pseudo-class. It is a selector that matches an element with an id matching the current URL’s fragment, which is denoted by the # sign.

For example, if your site has an element with the id of dinosaur, you can navigate to that element from the address bar using the URL, https://www.example.com/index.html#dinosaur. And if that is the current URL, the following CSS rule will apply to the #dinosaur element:

#dinosaur:target {
  background-color: green;
End result should look like this

And with that, we can let the shenanigans commence.

Markup structure

The HTML structure can be fairly simple.

  <section id="slide1"></section>
  <section id="slide2"></section>
  <section id="slide3"></section>
  <section id="slide4"></section>
  <section id="slide5"></section>

Each slide can be a section which takes up the full height of the viewport. And we can achieve that with relative ease using viewport units. Viewport units are as their name suggests, CSS units that are relative to the size of the viewport.

We have vw, vh, vmax and vmin. For the purpose of making a HTML slidedeck, the relavant unit here is vh or viewport height. By setting a height of 100vh to each section, they will always be the height of the viewport, no matter how you resize the browser.

Full height all day every day

Each slide has a unique id that can be targeted, which will be the mechanism for slide controls.

Browsing through slides like a boss

So where’s the :target pseudo-class in all this? Hang on, we’re coming to that. What we did earlier was make the browser jump to the top of each section, you can still scroll up and down, making your presentation behave like a web page instead of a slide deck.

If that’s what you want it to do, great. You’re done for the day, to be honest. But let’s say we don’t want that scroll bar activated. We want the active slide in the viewport and that’s it. Then that’s when the :target pseudo-class comes into play.

Stacking your slides

But before getting into that, let’s visualise how the slide deck will behave.

Each slide is the same size and positioned on top of each other
Stacking the sections

We will style the sections such that only one slide is visible at any one time by stacking them over each other, then utilise the URL fragments to indicate which slide should be active.

So. Stacking along the z-index eh? We now have more than 1 option for achieving something like that. Let’s cover the tried-and-tested technique of position and z-index. The z-index property only applies to positioned boxes, which are boxes that have a position value of anything other than the default value of static.

There are now 4 other values for position, namely relative, absolute, fixed and sticky, sticky being a new addition. Elad Shechter wrote an excellent in-depth write-up of how it works, so do give it a read.

According to the specification:

The root element forms the root stacking context. Other stacking contexts are generated by any positioned element (including relatively positioned elements) having a computed value of ‘z-index’ other than ‘auto’.

So once you apply a position property to a box, you can use the z-index property to adjust its stack level. Setting position: absolute on a box removes it from normal flow and causes it to be explicitly offset with respect to its containing block.

Contents of an absolutely positioned element don’t flow around other boxes, if they have a higher stack level, they’re just going to cover whatever is below them.

Stack 'em up

To stack them all up, apply a position: absolute and width: 100% on each slide section. Some of you might be thinking, what’s the difference between having a width: 100% on an absolutely positioned element versus setting all the offset values to 0? (e.g. top: 0; right: 0; bottom: 0; left: 0) Good thing someone else had the exact same question.

Keith J. Grant wrote an analysis of the difference between these 2 approaches and found that if you use width: 100% with additional margins around the element, it will get shifted out of its positioned ancestor.

But since I want my slides to fill up the viewport, I’m not using margins at all, so I’d rather do a single line than 4. 🤷

The next thing is to adjust the z-index for the active slide to be higher than all the other inactive slides with our trusty :target selector.

section {
  height: 100vh;
  width: 100%;
  position: absolute;
  z-index: 0;

section:target {
  z-index: 1;

Some people handle z-indexes differently, using denominations of 10 or 100, but honestly, between 2 elements, whoever has the higher integer value wins, regardless whether it’s by 1 or 1000.

Look, ma… No scrollbar!

Sprinkle on some animation

To add some semblance of professionalism to this endeavour (who am I kidding?), I think some slide transitions are in order. CSS also offers a lovely suite of animation options for this. You can rotate, zoom, slide in and out, all with CSS transforms.

If you looked at Ondřej’s slides, he used a rotate effect, so let’s try that out. There are 3 position states for this, before entering in viewport, active in the viewport, and left the viewport.

In 'n out
Stacking the sections

The default transform-origin is 50% 50%, which means the element will rotate around its centre. To have a corner rotate like the one in the diagram, set the value to 0 0. All slides start out rotated 90 degrees clockwise out of the viewport, while slides leaving will be rotated 90 degrees anti-clockwise up and out.

We can make use of the sibling selector to target slides that come after the active slide, and add in some transition values to make the animation appear smoother:

section {
  height: 100vh;
  width: 100%;
  position: absolute;
  z-index: 0;
  transform: rotate(90deg);
  transform-origin: 0 0;
  transition: transform 1s, opacity 0.8s;

section:target {
  transform: rotate(0deg);
  z-index: 1;

section:target ~ section {
  opacity: 0;
  transform: rotate(-90deg);

When the slide is rotated out of the viewport, it somehow triggers the scrollbar. I have not figured out the exact reason for this though I do know the solution is to add an overflow: hidden to the body element. I should research this some more, because this doesn’t happen when I use translate.

Wrapping up

One more thing to take note of is that when your slides first load, there will not be any URL fragment triggered, meaning none of your slides will be active. To workaround this issue, add a link to the first slide which will show up on first load, making sure it is also a positioned element.

Press start to begin

Feel free to try out other CSS transforms for more slide transitions if rotate doesn’t suit your style. But if your slide requirements are reasonably simple, why not try something like this? You can even toss it onto some free static site hosts and have it accessible wherever you go. 😎

For a live implementation of this, check out the slides used for introducing Talk.CSS