Technology
14 minute read

Build a Custom Full Page Slider with CSS and JavaScript

Stefan is a front-end engineer inspired by modern, interactive layouts. He has worked on hundreds of projects, focusing on high-end UI and UX.

I work with custom full-screen layouts a lot, practically on a daily basis. Usually, these layouts imply a substantial amount of interaction and animation. Whether it be a time-triggered complex timeline of transitions or a scroll-based user-driven set of events, in most cases, the UI requires more than just using an out-of-the-box plugin solution with a few tweaks and changes. On the other hand, I see many JavaScript developers tend to reach for their favorite JS plugin to make their job easier, even though the task may not need all the bells and whistles a certain plugin provides.

Disclaimer: Using one of the many plugins available out there has its perks, of course. You’ll get a variety of options you can use to tweak to your needs without having to do much coding. Also, most plugin authors optimize their code, make it cross-browser and cross-platform compatible, and so on. But still, you get a full-size library included in your project for maybe only one or two different things it provides. I’m not saying using a third-party plugin of any kind is naturally a bad thing, I do it on a daily basis in my projects, just that it is generally a good idea to weigh pros and cons of each approach as it is a good practice in coding. When it comes to doing your own thing this way, it requires a bit more coding knowledge and experience to know what you are looking for, but in the end, you should get a piece of code that does one thing and one thing only the way you want it to.

This article is aimed to show a pure CSS/JS approach in developing a fullscreen scroll-triggered slider layout with custom content animation. In this scaled-down approach, I’ll cover the basic HTML structure you would expect to be delivered from a CMS back-end, modern CSS (SCSS) layout techniques, and vanilla JavaScript coding for full interactivity. Being bare-bones, this concept can be easily extended to a larger-scale plugin and/or used in a variety of applications having no dependencies at its core.

The design we are going to create is a minimalistic architect portfolio showcase with featured images and titles of each project. The complete slider with animations will look like this:

Sample slider of an architect portfolio.

You can check out the demo here, and you can access my Github repo for further details.

HTML Overview

So here’s the basic HTML we will be working with:

<div id="hero-slider">
	<div id="logo" class="mask">
		<!-- Textual logo will go here -->
	</div>
	<div id="slideshow">
	<div id="slides-main" class="slides">
           <!-- Featured image slides will go here -->
	</div>
	<div id="slides-aux" class="slides mask">
           <!-- Slide titles will go here -->
       </div>
	</div>
	<div id="info">
       <!-- Static info on the right -->
	</div>
	<nav id="slider-nav">
       <!-- Current slide indicator -->
	</nav>
</div>

A div with the id of hero-slider is our main holder. Inside, the layout is divided into sections:

  • Logo (a static section)
  • Slideshow which we’ll work on mostly
  • Info (a static section)
  • Slider nav which will indicate the currently active slide as well as the total number of slides

Let’s focus on the slideshow section since that is our point of interest in this article. Here we have two parts— main and aux. Main is the div which contains featured images while aux holds image titles. The structure of each slide inside of these two holders is pretty basic. Here we have an image slide inside of the main holder:

<div class="slide" data-index="0">
	<div class="abs-mask">
		<div class="slide-image" style="background-image: url(./assets/img/slide-1.jpg)">		</div>
       </div>
</div>

The index data attribute is what we’ll use to keep track of where we are at in the slideshow. The abs-mask div we’ll use to create an interesting transition effect and the slide-image div contains the specific featured image. Images are rendered inline as if they were coming directly from a CMS and are set by the end user.

Similarly, the title slides inside of the aux holder:

<h2 class="slide-title slide" data-index="0"><a href="#">#64 Paradigm</a></h2>

Each slide title is an H2 tag with the corresponding data attribute and a link to be able to lead to that project’s single page.

The rest of our HTML is pretty straightforward as well. We have a logo at the top, static info which tells the user which page they are on, some description, and slider current/total indicator.

CSS Overview

The source CSS code is written in SCSS, a CSS pre-processor which is then compiled into regular CSS which the browser can interpret. SCSS gives you the advantage of using variables, nested selection, mixins, and other cool stuff, but it needs to be compiled into CSS to have the browser read the code as it should. For the purpose of this tutorial, I’ve used Scout-App to handle the compiling as I’ve wanted to have the tooling at the bare minimum.

I used flexbox to handle the basic side-by-side layout. The idea is to have the slideshow on one side and the info section on the other.

#hero-slider {
	position: relative;
	height: 100vh;
	display: flex;
	background: $dark-color;
}

#slideshow {
	position: relative;
	flex: 1 1 $main-width;
	display: flex;
	align-items: flex-end;
	padding: $offset;
}

#info {
	position: relative;
	flex: 1 1 $side-width;
	padding: $offset;
	background-color: #fff;
}

Let’s dive into the positioning and again, focus on the slideshow section:

#slideshow {
	position: relative;
	flex: 1 1 $main-width;
	display: flex;
	align-items: flex-end;
	padding: $offset;
}

#slides-main {
	@extend %abs;

	&:after {
		content: '';
		@extend %abs;
		background-color: rgba(0, 0, 0, .25);
		z-index: 100;
   }

	.slide-image {
		@extend %abs;
		background-position: center;
		background-size: cover;
		z-index: -1;
	}
}

#slides-aux {
	position: relative;
	top: 1.25rem;
	width: 100%;

	.slide-title {
		position: absolute;
		z-index: 300;
		font-size: 4vw;
		font-weight: 700;
		line-height: 1.3;
		@include outlined(#fff);
	}
}

I’ve set the main slider to be absolutely positioned and have the background images stretch the whole area by using the background-size: cover property. To provide more contrast against the slide titles, I’ve set an absolute pseudo-element which acts as an overlay. The aux slider containing slide titles is positioned at the bottom of the screen and on top of the images.

Since only one slide will be visible at a time, I set each title to be absolute as well, and have the holder size calculated via JS to make sure there are no cut-offs, but more about that in one of our upcoming sections. Here you can see the use of an SCSS feature called extending:

%abs {
	position: absolute;
	top: 0;
	left: 0;
	height: 100%;
	width: 100%;
}

Since I used absolute positioning a lot, I pulled this CSS into an extendable to have it easily available in various selectors. Also, I created a mixin called “outlined” to provide a DRY approach when styling the titles and main slider title.

@mixin outlined($color: $dark-color, $size: 1px) {
	color: transparent;
	-webkit-text-stroke: $size $color;
}

As for the static part of this layout, it has nothing complex about it but here you can see an interesting method when positioning text which has to be on the Y axis instead of its normal flow:

.slider-title-wrapper {
	position: absolute;
	top: $offset;    
	left: calc(100% - #{$offset});
    transform-origin: 0% 0%;
    transform: rotate(90deg);
    @include outlined;
}

I’d like to draw your attention to the transform-origin property as I found it really underused for this type of layout. The way this element is positioned is that its anchor stays in the upper left corner of the element, setting the rotation point and having the text continuously flow from that point downwards with no issues when it comes to different screen sizes.

Let’s have a look at a more interesting CSS part - the initial loading animation:

Load animation for slider.

Usually, this kind of synced animation behavior is achieved using a library - GSAP, for example, is one of the best out there, providing excellent rendering capabilities, is easy to use, and has the timeline functionality which enables the developer to programmatically chain element transitions into each other.

However, as this is a pure CSS/JS example I’ve decided to go really basic here. So each element is set to its starting position by default–either hidden by transform or opacity and shown upon slider load which is triggered by our JS. All transition properties are manually tweaked to ensure a natural and interesting flow with each transition continuing into another providing a pleasant visual experience.

#logo:after {
	transform: scaleY(0);
	transform-origin: 50% 0;
	transition: transform .35s $easing;
}

.logo-text {
	display: block;
	transform: translate3d(120%, 0, 0);
    opacity: 0;
    transition: transform .8s .2s, opacity .5s .2s;
}
.current,
.sep:before {
	opacity: 0;
    transition: opacity .4s 1.3s;
}

#info {
	transform: translate3d(100%, 0, 0);
    transition: transform 1s $easing .6s;
}

.line {
	transform-origin: 0% 0;
    transform: scaleX(0);
    transition: transform .7s $easing 1s;
}

.slider-title {
	overflow: hidden;

	>span {
	display: block;
    transform: translate3d(0, -100%, 0);
    transition: transform .5s 1.5s;
    }
}

If there’s one thing I would like you to see here, it is the use of the transform property. When moving an HTML element, whether it be a transition or animation, it is advised to use the transform property. I see a lot of people who tend to ruse either margin or padding or even the offsets–top, left, etc. which doesn’t produce adequate results when it comes to rendering.

To gain a more in-depth grasp of how to use CSS when adding interactive behavior, I couldn’t recommend the following article enough.

It’s by Paul Lewis, a Chrome engineer, and covers pretty much everything one should know about pixel rendering in web whether it be CSS or JS.

JavaScript Overview and Slider Logic

The JavaScript file is divided into two distinct functions.

The heroSlider function which takes care of all the functionality we need here, and the utils function where I’ve added several reusable utility functions. I’ve commented each of these utility functions to provide context if you are looking to reuse them in your project.

The main function is coded in a way that it has two branches: init and resize. These branches are available via return of the main function and are invoked when necessary. init is the initialization of the main function and it’s triggered on window load event. Similarly, the resize branch is triggered on window resize. The sole purpose of the resize function is to recalculate the title’s slider size on window resize, as title font size may vary.

In the heroSlider function, I’ve provided a slider object which contains all the data and selectors we are going to need:

const slider = {
       hero: document.querySelector('#hero-slider'),
       main: document.querySelector('#slides-main'),
       aux: document.querySelector('#slides-aux'),
       current: document.querySelector('#slider-nav .current'),
       handle: null,
       idle: true,
       activeIndex: -1,
       interval: 3500
   };

As a side-note, this approach could be easily adapted if you’re for example using React, as you can store the data in state or use the newly added hooks. To stay on point, let’s just go through what each of the key-value pairs here represents:

  • The first four properties are an HTML reference to the DOM element we’ll manipulate.
  • The handle property will be used to start and stop autoplay functionality.
  • The idle property is a flag which will prevent the user to force scrolling while the slide is in transition.
  • activeIndex will allow us to keep track of the currently active slide
  • interval denotes the autoplay interval of the slider

Upon slider initialization, we invoke two functions:

setHeight(slider.aux, slider.aux.querySelectorAll('.slide-title'));
loadingAnimation();

The setHeight function reaches out to a utility function to set the height of our aux slider based on the maximum title size. This way we ensure that adequate sizing is provided and no slide title will be cut off even when its content drops into two lines.

loadingAnimation function adds a CSS class to the element providing the intro CSS transitions:

const loadingAnimation = function () {
    slider.hero.classList.add('ready');
    slider.current.addEventListener('transitionend', start, {
        once: true
    });
}

As our slider indicator is the last element in the CSS transition timeline, we wait for its transition to end and invoke the start function. By providing additional parameter as an object we ensure that this is triggered only once.

Let’s have a look at the start function:

const start = function () {
	autoplay(true);
	wheelControl();
	window.innerWidth <= 1024 && touchControl();
	slider.aux.addEventListener('transitionend', loaded, {
		once: true
   });
}

So when the layout has finished, its initial transition is triggered by loadingAnimation function and the start function takes over. It then triggers autoplay functionality, enables wheel control, determines if we are on a touch or desktop device, and waits for the titles slide first transition to add the appropriate CSS class.

Autoplay

One of the core features in this layout is the autoplay feature. Let’s go over the corresponding function:

const autoplay = function (initial) {
	slider.autoplay = true;
	slider.items = slider.hero.querySelectorAll('[data-index]');
	slider.total = slider.items.length / 2;

	const loop = () => changeSlide('next');

	initial && requestAnimationFrame(loop);
	slider.handle = utils().requestInterval(loop, slider.interval);
}

First, we set the autoplay flag to true, indicating the slider is in autoplay mode. This flag is useful when determining whether to re-trigger autoplay after the user interacts with the slider. We then reference all slider items (slides), as we’ll change their active class and calculate the total iterations the slider is going to have by adding up all items and dividing by two as we have two synced slider layouts (main and aux) but only one “slider” per se which changes both of them simultaneously.

The most interesting part of the code here is the loop function. It invokes slideChange, providing the slide direction which we’ll go over in a minute, however, the loop function is called a couple of times. Let’s see why.

If the initial argument is evaluated as true, we’ll invoke the loop function as a requestAnimationFrame callback. This is only happening upon the first slider load which triggers immediate slide change. Using requestAnimationFrame we execute the provided callback just before the next frame repaint.

Diagram of the steps used to create the slider.

However, as we want to keep going through slides in autoplay mode we’ll use a repeated call of this same function. This is usually achieved with setInterval. But in this case, we’ll use one of the utility functions–requestInterval. While setInterval would work just well, requestInterval is an advanced concept which relies on requestAnimationFrame and provides a more performant approach. It ensures that function is re-triggered only if the browser tab is active.

More about this concept in this awesome article can be found on CSS tricks. Please note that we assign the return value from this function to our slider.handle property. This unique ID the function returns is available to us and we’ll use it to cancel autoplay later on using cancelAnimationFrame.

Slide Change

The slideChange function is the principal function in the whole concept. It changes slides whether it’d by autoplay or by user trigger. It is aware of slider direction, provides looping so when you come to the last slide you’ll be able to continue to the first slide. Here’s how I’ve coded it:

const changeSlide = function (direction) {
	slider.idle = false;
	slider.hero.classList.remove('prev', 'next');
	if (direction == 'next') {
		slider.activeIndex = (slider.activeIndex + 1) % slider.total;
		slider.hero.classList.add('next');
	} else {
		slider.activeIndex = (slider.activeIndex - 1 + slider.total) % slider.total;
		slider.hero.classList.add('prev');
   }

	//reset classes
	utils().removeClasses(slider.items, ['prev', 'active']);

	//set prev 
	const prevItems = [...slider.items]
		.filter(item => {
			let prevIndex;
			if (slider.hero.classList.contains('prev')) {
				prevIndex = slider.activeIndex == slider.total - 1 ? 0 : slider.activeIndex + 1;
           } else {
               prevIndex = slider.activeIndex == 0 ? slider.total - 1 : slider.activeIndex - 1;
           }

           return item.dataset.index == prevIndex;
       });

   //set active
	const activeItems = [...slider.items]
       .filter(item => {
           return item.dataset.index == slider.activeIndex;
       });

	utils().addClasses(prevItems, ['prev']);
	utils().addClasses(activeItems, ['active']);
	setCurrent();

	const activeImageItem = slider.main.querySelector('.active');
	activeImageItem.addEventListener('transitionend', waitForIdle, {
		once: true
	});
}

The idea is to determine the active slide based on its data-index we got from HTML. Let’s address each step:

  1. Set slider idle flag to false. This indicates that slide change is in progress and wheel and touch gestures are disabled.
  2. The previous slider direction CSS class gets reset and we check for the new one. The direction parameter is provided either by default as ‘next’ if we are coming from the autoplay function or by a user invoked function–wheelControl or touchControl.
  3. Based on the direction, we calculate the active slide index and provide the current direction CSS class to the slider. This CSS class is used to determine which transition effect will be used (e.g. right to left or left to right)
  4. Slides get their “state” CSS classes (prev, active) reset using another utility function which removes CSS classes but can be invoked on a NodeList, rather than just a single DOM element. Afterward, only previous and currently active slides get those CSS classes added to them. This allows for the CSS to target only those slides and provide adequate transitioning.
  5. setCurrent is a callback which updates slider indicator based on the activeIndex.
  6. Finally, we wait for the transition of the active image slide to end in order to trigger the waitForIdle callback which restarts autoplay if it was previously interrupted by the user.

User Controls

Based on the screen size, I’ve added two types of user controls–wheel and touch. Wheel control:

const wheelControl = function () {
	slider.hero.addEventListener('wheel', e => {
       if (slider.idle) {
           const direction = e.deltaY > 0 ? 'next' : 'prev';
           stopAutoplay();
           changeSlide(direction);
       }
   });
}

Here, we listen to wheel even and if the slider is currently in idle mode (not currently animating a slide change) we determine wheel direction, invoke stopAutoplay to stop the autoplaying function if it’s in progress, and change the slide based on the direction. The stopAutoplay function is nothing but a simple function that sets our autoplay flag to the false value and cancels our interval by invoking cancelRequestInterval utility function passing it the appropriate handle:

const stopAutoplay = function () {
	slider.autoplay = false;
	utils().clearRequestInterval(slider.handle);
}

Similar to wheelControl, we have touchControl that takes care of touch gestures:

const touchControl = function () {
	const touchStart = function (e) {
       slider.ts = parseInt(e.changedTouches[0].clientX);
       window.scrollTop = 0;
   }

   const touchMove = function (e) {
       slider.tm = parseInt(e.changedTouches[0].clientX);
       const delta = slider.tm - slider.ts;
       window.scrollTop = 0;

       if (slider.idle) {
           const direction = delta < 0 ? 'next' : 'prev';
           stopAutoplay();
           changeSlide(direction);
       }
   }

	slider.hero.addEventListener('touchstart', touchStart);
	slider.hero.addEventListener('touchmove', touchMove);
}

We listen for two events: touchstart and touchmove. Then, we calculate the difference. If it returns a negative value, we change to the next slide as the user has swiped from right to left. On the other hand, if the value is positive, meaning that the user has swiped from left to right, we trigger slideChange with direction passed as “previous.” In both cases, autoplay functionality gets stopped.

This is a pretty simple user gesture implementation. To build on this, we could add previous/next buttons to trigger slideChange on click or add a bulleted list to go directly to a slide based on its index.

Wrap-up and Final Thoughts on CSS

So there you go, a pure CSS/JS way of coding a non-standard slider layout with modern transition effects.

I hope you find this approach useful as a way of thinking and could use something similar in your front-end projects when coding for a project that was not necessarily conventionally designed.

For those of you interested in the image transition effect, I’ll go over this in the next few lines.

If we revisit the slides HTML structure I’ve provided in the intro section we’ll see that each image slide has a div around it with the CSS class of abs-mask. What this div does is that it hides a portion of the visible image by a certain amount by using overflow:hidden and offsetting it in a different direction than the image. For example, if we look at the way the previous slide is coded:

&.prev {
	z-index: 5;
	transform: translate3d(-100%, 0, 0);
	transition: 1s $easing;

	.abs-mask {
       transform: translateX(80%);
       transition: 1s $easing;
	}
}

The previous slide has a -100% offset in its X axis, moving it to the left of the current slide, however, the inner abs-mask div is translated 80% to the right, providing a narrower viewport. This, in combination with having a larger z-index for the active slide results in a sort of cover effect—the active image covers the previous while in the same time extending its visible area by moving the mask which provides the full view.

Understanding the basics

What are CSS properties that can be animated?

The most standard CSS properties we can animate are transform, opacity, color, background-color, height, width, etc. The complete list can be found in Mozilla technical documentation.

What is a CSS keyframe animation?

A CSS keyframe animation is 0-100% time representation of all the transitions that should occur on the selected element for a specified period of time. This way multiple transitions can be combined into a seamless visual representation.

What is a transition property?

A transition is a property that allows for CSS properties to be transitioned between two values. For example, hovering over an element and let it transition its opacity from 0 to 1.

What is CSS specificity?

By interpreting specificity the browser decides which CSS rule to interpret. CSS specificity is dependant on selector types: Type selectors, Class selectors, ID selectors. Combining multiple selectors, adding sibling and child combinators manipulates the specificity as well.

CSS in the browser: How does it work?

To get your HTML content styled by CSS the browser uses the following method. Upon loading HTML, it combines its content with the style information provided, creates a DOM tree, and finally displays its contents.