Implement a CSS-only slideshow / carousel with next and previous buttons?

simple screenshot

a working example

http://jsfiddle.net/q4d9m/2/

UPDATE: There seems to be a bug with Firefox 32 (Mac) that means ellipses will not render in SVG masks, this leads to the reflection failing… and don’t get me started on what Chrome 37.0.2062.120 (Mac) is doing if you hover off any of the images in the implemented example at the foot of this answer.

18 Sept 2014.

explaining the basic principal

The slides are built up out of two main parts, a visual part and an interactive part. The interactive part pretty much stays static, and the visual part is animated. After much playing around I extended the initial HTML structure (see above) to incorporate a few extra wrappers around the slide’s content. This allows for the separate interactive and visual parts, and also extra flexibility for other useful abilities i.e. like vertical centering and reflections.

<ul class="css-slider">
  <li class="slide" tabindex="1" id="l1">
    <span class="slide-outer">
      <span class="slide-inner">
        <span class="slide-gfx" id="s1">
          <img src="https://stackoverflow.com/questions/21647389/photos/a.jpg" />
        </span>
      </span>
    </span>
  </li>
  <li class="slide" tabindex="1" id="l2">
    <span class="slide-outer">
      <span class="slide-inner">
        <span class="slide-gfx" id="s2">
          <img src="photos/b.jpg" />
        </span>
      </span>
    </span>
  </li>
  ...
</ul>

Now in order to actually have any system behave as a slideshow, you need to have some way to identify the current or focused slide. In this case i’m relying on :focus to handle this distinction. You might have noticed the addition of tabindex=“1” above, this is so that the pseudo-class :focus is applied to unexpected elements like <li> — this was required mainly for webkit-based browsers, but may help out other agents too.

So put simply, all the slides are stacked one on top of the other, the current focused slide is centred to the viewport and given the highest z-index, the slide before the focused slide is translated off screen to the left, and the slide after the focused slide is translated off screen to the right. It should be noted that the xy translations only effect the visual content of the slides, the interactive parts are still layered atop one another filling the entire viewport area, well almost…

illustration of previous, next and current slides

I say almost because in order for the focus to be triggered on the next and previous slides — via mouse or touch — part of their interactive layers must be accessible and clickable by the user. Using a mixture of left, right and padding the correct areas can be revealed without displacing the visual elements.

snippets of key css

So, as the current focused slide changes, so do the areas of the interactive layers that are accessible. Each time a user clicks the next or previous arrows, they are actually focusing on the adjacent <li> element, rather than clicking a link that performs any kind of action.

illustration of interactive layer positioning

useful to know

  1. In order to get this to work on Webkit browsers the tabindex attribute needs to be used so that the :focus Pseudo-class will function on the whatever base element you use to represent a slide.

  2. Because of the tricks employed, the slides will play in a reversed-dom-order.

  3. You can navigate the slideshow using tab, however, it will navigate backwards, due to point 2. If you have a Mac, you may need to tweak your os settings before tab will function.

  4. Due to the left, right trick to expose the navigation arrows, there is a slight visual glitch when navigating forwards — in that you can see the subsequent previous arrow quickly animate into place.

  5. Because this system works based on :focus whenever focus is lost the slideshow reverts back to the initial slide, for this reason, sub links will not work within your slides — unless you enhance interactions with JavaScript.

  6. My demo makes use of SVG background images, these are obviously optional and do not work on older browsers.

supported by

  1. Firefox v26.0 (Mac/PC ~ most likely much earlier versions)
  2. Chrome v32 (Mac/PC ~ most likely much earlier versions)
  3. Safari v7 (Mac/PC ~ most likely much earlier versions)
  4. Opera v18 (Mac/PC ~ most likely much earlier versions)
  5. Internet Explorer 9+ (PC)

IE7 & IE8 can’t even understand :last-child or :nth-child so no, it does not work for them.

added extras

In the demo below you will see that there are a few JavaScript flourishes, these either help show off what the construct can do, or they progressively enhance. The checkboxes, which are only there for the demo, should allow you to enable or disable certain features. These features are applied by simple classes:

  1. .with-responsive-images ~ little hack to force images to autosize.
  2. .with-selection-disabled ~ prevents user dragging and highlighting.
  3. .width-fade-in ~ fades the slideshow in, initially.
  4. .with-reflection ~ enables a reflection for firefox and webkit.
  5. .with-slide-zoom ~ on hover the slides will zoom to a max-width.
  6. .with-slide-float ~ on focus the slides will levitate.
  7. .with-slide-float-hover ~ on hover the slides will levitate.
  8. .with-shadow ~ a poorman’s reflection.

please note: the reflection add-on relies on arbitrary markup attributes. You will have to add unique ids to each .slide, and then extend the CSS to take them into account.

CSS breakdown

set-up

Ok, so to start with here is the basic set-up. First off, because my slideshow is with images I’ve set some basic image styling, this is all optional.

.slide-gfx img {
  max-width: 600px;
  max-height: auto;
  border-radius: 20px;
  box-shadow: 0 0 80px rgba(255,255,255,1);
}

The slider mask was added as a wrapper to the entire slideshow, to prevent the window scrollbars from displaying when operating the slideshow at full screen size. This again is optional.

.css-slider-mask {
  display: block;
  overflow: hidden;
  width: 100%;
  height: 100%;
}

Now we get the actual set-up that is required for the slider. This first part is quite straight-forward, save for the display: none; part. This initially hides the slideshow from everyone, but is then later overridden for browsers that support :nth-child. It is most likely that your <body> element will be the 2nd child, but you should check before using this.

.css-slider {
  list-style: none;
  margin: 0;
  padding: 0;
  width: 96%;
  height: 100%;
  margin-left: 2%;
  z-index: 1;
}

.css-slider {
  position: relative;
  display: none;
}

body:nth-child(2) .css-slider {
  display: block;
}

slides

Next we get down to slide specifics. Because of the non-existence of a reversed General Sibling Selector (~), all default styles for slides represent the future (or next) state. This is because there isn’t an actual way to select the future slides.

.css-slider .slide {
  display: block;
  position: absolute;
  left: 40px;
  top: 0;
  right: 0;
  bottom: 0;
  padding-left: 0;
  padding-right: 40px;
  z-index: 100;
  outline: 0; /* kill the focus rect! */
}

Again, by default we style the forward arrow, and then override later for the current and past slides.

.css-slider .slide {
  background: url('arrow-right.svg') no-repeat right center;
  background-size: 25px auto;
}

.css-slider .slide:hover {
  background-image: url('arrow-right-hover.svg');
  cursor: pointer;
}

Now the focused slide, the key items here are :focus (as I’ve already explained) and :last-child which I haven’t. Last Child is used rather than First Child because, again, we have to work backwards (all due to the lack of a reverse General Sibling Selector ~). Why either child is needed at all is so that we can “focus” an initial slide when there is no current focus i.e. on page load.

.css-slider .slide:target,
.css-slider .slide:target:hover,
.css-slider .slide:focus,
.css-slider .slide:focus:hover,
.css-slider .slide:last-child,
.css-slider .slide:last-child:hover {
  left: 40px;
  right: 40px;
  padding-left: 0;
  padding-right: 0;
  background: transparent;
  z-index: 101;
  cursor: default;
}

Now we need to affect all the slides that slip into the past. I have a avoided mentioning the :target pseudo-class prior to now, basically this has been implemented to support the “jump nav”. There are two reasons why I wont sing the praises of the the “jump nav”:

  1. It is partially powered by JavaScript.
  2. It relies on #hash or #fragment values, which, due to creation of history states can mess around with your site’s usability.

Anyway, the trick to selecting slides that are in the past, hinges on the General Sibling Selector. The following construct basically means select .slide(s) that you find after the .slide that has :focus.

.css-slider .slide:target ~ .slide,
.css-slider .slide:focus ~ .slide {
  padding-left: 40px;
  padding-right: 0;
  left: 0;
  right: 40px;
}

.css-slider .slide:target ~ .slide,
.css-slider .slide:focus ~ .slide {
  background: url('arrow-left.svg') no-repeat left center;
  background-size: 25px auto;
}

.css-slider .slide:target ~ .slide:hover,
.css-slider .slide:focus ~ .slide:hover {
  background-image: url('arrow-left-hover.svg');
}

slide contents

Next up we need to control what exactly happens with our slide contents. I have designed this system so that if you want to, you can leave out the animation section. This will mean that the slides will switch instantaneously. This next part takes care of that.

.css-slider .slide .slide-outer {
  display: none;
  width: 100%;
  height: 100%;
}

Slide-inner is purely used to handle the centering of the slide contents. It relies on display table and display table-cell.

.css-slider .slide .slide-outer .slide-inner {
  display: table-cell;
  vertical-align: middle;
  text-align: center;
  width: 100%;
  height: 100%;
}

Slide-gfx is literally just a containing wrapper for whatever you decide to put in your slideshow. The reflection is generated from this container, and a few other visual extras are attached to it also.

.css-slider .slide .slide-outer .slide-inner .slide-gfx {
  position: relative;
  display: inline-block;
  z-index: 102;
  text-align: left; /* override the centering back to defaults */
}

This part is responsible for the instant switch when no animation is included.

.css-slider .slide:target .slide-outer,
.css-slider .slide:focus .slide-outer,
.css-slider .slide:last-child .slide-outer {
  display: block; /* if they don't support display table */
  display: table;
}

last-child overrides

Due to the :last-child declarations that step in when nothing is focused, if you were to make no further changes, you would find certain things break. This is because :last-child always applies, unlike :focus. To rectify this we need to negate the :last-child changes, but only when something has been focused. That’s what the following CSS does.

.css-slider .slide:target ~ .slide:last-child,
.css-slider .slide:focus ~ .slide:last-child {
  cursor: pointer;
}

.css-slider .slide:target ~ .slide:last-child .slide-outer,
.css-slider .slide:focus ~ .slide:last-child .slide-outer {
  display: none;
}

final annoying z-index hack

So far the CSS has been quite generalised, but there is always something…

This part is only required to fix the click-ability of the ‘previous slide’ arrow, so that the most recent previous frame floats above anything else. If you don’t care about a previous slide action then you could do away with this step, as long as you hide the previous arrow. It’s rather annoying because this whole arbitrary section could be done away with if CSS supported an inverted version of the General Sibling Selector.

Basically the following will support up to 5 frames, if you need more, add more. The good news at least is you can add far more frames than you need without any real adverse effects. Obviously if you go above 100 frames you’ll have to adjust other z-indexes in the rest of the CSS.

.css-slider .slide:target ~ .slide:nth-child(1),
.css-slider .slide:focus  ~ .slide:nth-child(1) { z-index:99; }
.css-slider .slide:target ~ .slide:nth-child(2),
.css-slider .slide:focus  ~ .slide:nth-child(2) { z-index:98; }
.css-slider .slide:target ~ .slide:nth-child(3),
.css-slider .slide:focus  ~ .slide:nth-child(3) { z-index:97; }
.css-slider .slide:target ~ .slide:nth-child(4),
.css-slider .slide:focus  ~ .slide:nth-child(4) { z-index:96; }
.css-slider .slide:target ~ .slide:nth-child(5),
.css-slider .slide:focus  ~ .slide:nth-child(5) { z-index:95; }

Animation and Add-ons

As I have stated, the animation is optional, along with all the rest of the CSS i.e. visual add-ons. I’ll include the rest here with less detail. Most of it is quite straight-forward once you know the tricks above and CSS transitions or animations. The main reason for it’s bulk, is, as usual, vendor-prefixes; which I’ve removed for brevity. To source the full CSS you can obviously do so from the demo below.

please note: Most of these add-ons rely on quite modern CSS i.e. animations or SVGs

/** --------------------------------------------------------------------------
 * HANDLE THE SLIDE ANIMATION (optional)
 * ------------------------------------------------------------------------ */

/* Override the default instant slide behaviour */
.css-slider .slide .slide-outer {
  display: block !important;
  display: table !important;
}

/* set up the transitions */
.css-slider .slide .slide-outer {
  transition-property:                opacity, transform;
  transition-duration:                2s;
  transition-timing-function:         ease;
}

/* After state */
.css-slider .slide:target ~ .slide .slide-outer,
.css-slider .slide:target ~ .slide:last-child .slide-outer,
.css-slider .slide:focus ~ .slide .slide-outer,
.css-slider .slide:focus ~ .slide:last-child .slide-outer {
  transform: translate(-150%,0);
  transform: translate3D(-150%,0,0);
}

/* Before state */
.css-slider .slide .slide-outer {
  transform: translate(200%,0);
  transform: translate3D(200%,0,0);
}

/* Focused state*/
.css-slider .slide:target .slide-outer,
.css-slider .slide:focus .slide-outer,
.css-slider .slide:last-child .slide-outer {
  transform: translate(0,0);
  transform: translate3D(0,0,0);
}

/** --------------------------------------------------------------------------
 * SMALL SCREEN FIX / SLIDE JERK (optional)
 * ---------------------------------------------------------------------------
 * When we shift 'left' and 'right' values -- in order to allow access to a future
 * or past arrow -- this can cause a jump in the responsive scaling of the slide.
 * if we transition the left value quickly, it can make this appear less jarring.
 */

.css-slider .slide {
  transition-property:        left, padding-left;
  transition-duration:        1s;
  transition-timing-function: ease;
}

/** --------------------------------------------------------------------------
 * Add-on module : responsive images
 * ------------------------------------------------------------------------ */
.with-responsive-images .slide-gfx img {
  width: 100%;
  height: auto;
}

/** --------------------------------------------------------------------------
 * Add-on module : stop user selection
 * ------------------------------------------------------------------------ */

/* if your slides don't need to be selectable, I recommend using this */
.with-selection-disabled {
  user-select: none;
}

/** --------------------------------------------------------------------------
 * Add-on module : initial fade in 
 * ------------------------------------------------------------------------ */
.with-fade-in .slide-gfx {
  opacity: 0;
}

/* animate into visibility */
.with-fade-in .slide-gfx {
  animation:           css-slideshow-fade-in 2s;
  animation-delay:     1s;
  animation-fill-mode: forwards;
}

/* Vebdor animations */
@keyframes css-slideshow-fade-in { from { opacity: 0; } to { opacity: 1; } }

/** --------------------------------------------------------------------------
 * Add-on module : slide reflection
 * ------------------------------------------------------------------------ */

/* force our slide-gfx to be inline-block and relative positioned */
.with-reflection .slide-gfx {
  display: inline-block !important;
}

/* reflection for webkit agents */
.with-reflection .slide-gfx > *:first-child {
  -webkit-box-reflect: below 2px 
        -webkit-gradient(linear, left top, left bottom, 
           from(transparent), color-stop(0.9, transparent), to(white));
}

/* make sure internal images don't keep inline spacing/margin */
.with-reflection .slide-gfx img {
  display: block;
}

/* generate the reflection */
.with-reflection .slide-gfx:after {
  content: '';
  position: absolute;
  display: block;
  mask: url("reflection-mask.svg#mask"); /* gradient fade the reflection */
  transform: scaleY(-1); /* flip clone to appear as reflection */
  opacity: 0.5; /* fade out reflection */
  top: 100%;
  width: 100%;
  height: 60px;
  z-index: 200;
  margin-top: 2px;
}

/* again, due to element() requiring IDs we need arbitrary code 
   per each slide and each slide-gfx needs an id */
.with-reflection #s1:after { background: -moz-element(#s1) no-repeat left bottom; }
.with-reflection #s2:after { background: -moz-element(#s2) no-repeat left bottom; }
.with-reflection #s3:after { background: -moz-element(#s3) no-repeat left bottom; }
.with-reflection #s4:after { background: -moz-element(#s4) no-repeat left bottom; }
.with-reflection #s5:after { background: -moz-element(#s5) no-repeat left bottom; }
.with-reflection #s6:after { background: -moz-element(#s6) no-repeat left bottom; }
.with-reflection #s7:after { background: -moz-element(#s7) no-repeat left bottom; }
.with-reflection #s8:after { background: -moz-element(#s8) no-repeat left bottom; }
.with-reflection #s9:after { background: -moz-element(#s9) no-repeat left bottom; }
.with-reflection #s10:after { background: -moz-element(#s10) no-repeat left bottom; }
.with-reflection #s11:after { background: -moz-element(#s11) no-repeat left bottom; }
.with-reflection #s12:after { background: -moz-element(#s12) no-repeat left bottom; }
.with-reflection #s13:after { background: -moz-element(#s13) no-repeat left bottom; }
.with-reflection #s14:after { background: -moz-element(#s14) no-repeat left bottom; }
.with-reflection #s15:after { background: -moz-element(#s15) no-repeat left bottom; }
.with-reflection #s16:after { background: -moz-element(#s16) no-repeat left bottom; }
.with-reflection #s17:after { background: -moz-element(#s17) no-repeat left bottom; }
.with-reflection #s18:after { background: -moz-element(#s18) no-repeat left bottom; }
.with-reflection #s19:after { background: -moz-element(#s19) no-repeat left bottom; }
.with-reflection #s20:after { background: -moz-element(#s20) no-repeat left bottom; }

/** --------------------------------------------------------------------------
 * Add-on module : slide zoom (optional, not compatible with slide float)
 * ------------------------------------------------------------------------ */
.with-slide-zoom .slide .slide-gfx > *:first-child {
  transition-property:                max-width;
  transition-duration:                2s;
  transition-timing-function:         ease-in-out;
  transition-delay:                   0.25s;
}

.with-slide-zoom .slide .slide-gfx > *:first-child:hover {
  max-width: 1000px;
}

/** --------------------------------------------------------------------------
 * Add-on module : slide float (optional, not compatible with slide zoom)
 * ------------------------------------------------------------------------ */

/* inital transition set-up */
.with-slide-float:not(.with-slide-zoom) .slide .slide-gfx > *:first-child,
.with-slide-float-hover:not(.with-slide-zoom) .slide .slide-gfx > *:first-child {
  transition-property:        transform;
  transition-duration:        2s;
  transition-timing-function: ease-in-out;
}

/* we need a delay for the non-hover version */
.with-slide-float:not(.with-slide-zoom) .slide .slide-gfx > *:first-child {
  transition-delay: 2s;
}

/* initial levitation on focus */
.with-slide-float:not(.with-slide-zoom) .slide:target .slide-gfx > *:first-child,
.with-slide-float:not(.with-slide-zoom) .slide:focus .slide-gfx > *:first-child,
.with-slide-float-hover:not(.with-slide-zoom) .slide .slide-gfx > *:first-child:hover {
  transform: translate(0,-40px);
  transform: translate3D(0,-40px,0);
}

/* trigger the float animation after 4s */
.with-slide-float:not(.with-slide-zoom) .slide:target .slide-gfx > *:first-child,
.with-slide-float:not(.with-slide-zoom) .slide:focus .slide-gfx > *:first-child,
.with-slide-float-hover:not(.with-slide-zoom) .slide .slide-gfx > *:first-child:hover {
  animation:                         css-slideshow-levitate 4s;
  animation-direction:               alternate;
  animation-fill-iteration-count:    infinite;
  animation-timing-function:         ease-in-out;
  animation-delay:                   2s;
}

/* longer delay for automatic version i.e. non-hover */
.with-slide-float:not(.with-slide-zoom) .slide:target .slide-gfx > *:first-child,
.with-slide-float:not(.with-slide-zoom) .slide:focus .slide-gfx > *:first-child {
  animation-delay:                   4s;
}

/* Vebdor animations for the float */
@keyframes css-slideshow-levitate {
  from { transform: translate(0,-40px); transform: translate3D(0,-40px,0); }
  to   { transform: translate(0,-20px); transform: translate3D(0,-20px,0); }
}

/** --------------------------------------------------------------------------
 * Add-on module : ground shadow (optional)
 * ------------------------------------------------------------------------ */

.with-shadow .slide .slide-gfx:before {
  content: '';
  background: url('slide-shadow.svg') no-repeat center center;
  position: absolute;
  bottom: -10px;
  left: -20px;
  right: -20px;
  height: 20px;
  z-index: -1;
  opacity: 0.7;
}

.with-shadow.with-slide-float .slide .slide-gfx:before,
.with-shadow.with-slide-float-hover .slide .slide-gfx:before {
  transition-property:        opacity;
  transition-duration:        2s;
  transition-timing-function: ease-in-out;
}

.with-shadow.with-slide-float .slide .slide-gfx:before {
  transition-delay: 2s;
}

.with-shadow.with-slide-float .slide:target .slide-gfx:before,
.with-shadow.with-slide-float .slide:focus .slide-gfx:before,
.with-shadow.with-slide-float .slide:last-child .slide-gfx:before,
.with-shadow.with-slide-float-hover .slide .slide-gfx:hover:before {
  opacity: 0.1;
  animation:                      css-slideshow-shadow 4s;
  animation-delay:                4s;
  animation-direction:            alternate;
  animation-fill-iteration-count: infinite;
  animation-timing-function:      ease-in-out;
}

.with-shadow.with-slide-float-hover .slide .slide-gfx:hover:before {
  animation-delay: 2s;
}

/* Vebdor animations for the float */
@keyframes css-slideshow-shadow { from { opacity: 0.1; } to { opacity: 0.7; } }

simple screenshot of implemented example

implemented example (v0.2)

http://codelamp.co.uk/css-slideshow/v0.2/

please note: the jump nav i.e. circular dots do rely on a small bit of JavaScript. The rest is pure CSS.

previous version

As I said, it has taken quite a bit of time to refine this system. For those that might be interested, here is my (last) version 0.1 — there have been many before 😉 This took a slightly different approach and involved both visual and interactive moving parts. In the end I scrapped it because it involved more browser processing and so was much more clunky; something that this solid color demo doesn’t reveal.

http://jsfiddle.net/3cyP8/7/

Leave a Comment