How to Build a Lightweight Responsive Content Slider from Scratch


What you’ll get

  • A fully responsive slider that adapts to viewport size
  • Touch and mouse dragging support
  • Keyboard navigation and ARIA attributes for accessibility
  • Lazy-loading images for performance
  • Clean, dependency-free code you can extend

Folder structure

Keep it simple:

  • index.html
  • styles.css
  • slider.js
  • images/ (optional)

HTML: semantic and minimal

Use a list for slides so content remains accessible if JavaScript is disabled.

<!doctype html> <html lang="en"> <head>   <meta charset="utf-8" />   <meta name="viewport" content="width=device-width,initial-scale=1" />   <title>Lightweight Responsive Slider</title>   <link rel="stylesheet" href="styles.css" /> </head> <body>   <section class="slider" aria-label="Featured content">     <div class="slider__viewport">       <ul class="slider__list">         <li class="slider__slide" role="group" aria-roledescription="slide" aria-label="Slide 1 of 4">           <img data-src="images/slide1.jpg" alt="Description 1" class="lazy" />           <div class="slide__caption">Slide 1 caption</div>         </li>         <li class="slider__slide" role="group" aria-roledescription="slide" aria-label="Slide 2 of 4">           <img data-src="images/slide2.jpg" alt="Description 2" class="lazy" />           <div class="slide__caption">Slide 2 caption</div>         </li>         <li class="slider__slide" role="group" aria-roledescription="slide" aria-label="Slide 3 of 4">           <img data-src="images/slide3.jpg" alt="Description 3" class="lazy" />           <div class="slide__caption">Slide 3 caption</div>         </li>         <li class="slider__slide" role="group" aria-roledescription="slide" aria-label="Slide 4 of 4">           <img data-src="images/slide4.jpg" alt="Description 4" class="lazy" />           <div class="slide__caption">Slide 4 caption</div>         </li>       </ul>     </div>     <button class="slider__prev" aria-label="Previous slide">←</button>     <button class="slider__next" aria-label="Next slide">→</button>     <div class="slider__dots" role="tablist" aria-label="Slide dots"></div>   </section>   <script src="slider.js" defer></script> </body> </html> 

Notes:

  • Using data-src with class=“lazy” allows lazy-loading via IntersectionObserver.
  • Buttons and dot container will be wired up via JavaScript.

CSS: responsive, performant, minimal

Use flexbox for layout, transform for animation (GPU-accelerated), and prevent layout shifts.

:root{   --gap: 16px;   --nav-size: 40px;   --accent: #0b76ef; } *{box-sizing:border-box} html,body{height:100%;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,'Helvetica Neue',Arial} .slider{   position:relative;   max-width:1100px;   margin:40px auto;   padding:20px; } .slider__viewport{   overflow:hidden;   border-radius:8px;   background:#f7f7f7; } .slider__list{   display:flex;   gap:var(--gap);   transition:transform 320ms cubic-bezier(.22,.9,.32,1);   will-change:transform;   padding:var(--gap);   margin:0;   list-style:none; } .slider__slide{   min-width:100%;   flex:0 0 100%;   background:#fff;   border-radius:6px;   overflow:hidden;   box-shadow:0 2px 8px rgba(0,0,0,.08);   display:flex;   flex-direction:column;   align-items:stretch; } .slider__slide img{   width:100%;   height:220px;   object-fit:cover;   display:block;   background:#eaeaea; } .slide__caption{   padding:12px 16px;   font-size:15px; } /* Navigation */ .slider__prev, .slider__next{   position:absolute;   top:50%;   transform:translateY(-50%);   width:var(--nav-size);   height:var(--nav-size);   border-radius:50%;   border:0;   background:#fff;   box-shadow:0 4px 12px rgba(0,0,0,.08);   cursor:pointer; } .slider__prev{left:8px} .slider__next{right:8px} .slider__dots{   display:flex;   gap:8px;   justify-content:center;   margin-top:12px; } .slider__dots button{   width:10px;height:10px;border-radius:50%;border:0;background:#d0d0d0;cursor:pointer; } .slider__dots button[aria-selected="true"]{   background:var(--accent); } /* Responsive: show 2 slides on medium, 3 on wide screens */ @media(min-width:700px){   .slider__slide{min-width:50%;flex:0 0 50%} } @media(min-width:1000px){   .slider__slide{min-width:33.3333%;flex:0 0 33.3333%} } 

Key points:

  • Transition on transform keeps the animation smooth.
  • Use min-width on slides to control how many fit per viewport.
  • Images have fixed height to avoid layout shift; you can use aspect-ratio instead if preferred.

JavaScript: lightweight, accessible, and touch-ready

This script handles:

  • Next/previous navigation
  • Dot generation and focus management
  • Keyboard controls (Left/Right, Home/End)
  • Touch and mouse dragging
  • Lazy-loading with IntersectionObserver
// slider.js class Slider {   constructor(root, opts = {}) {     this.root = root;     this.list = root.querySelector('.slider__list');     this.slides = Array.from(this.list.children);     this.prevBtn = root.querySelector('.slider__prev');     this.nextBtn = root.querySelector('.slider__next');     this.dotsWrap = root.querySelector('.slider__dots');     this.index = 0;     this.slideWidth = 0;     this.visibleCount = 1;     this.opts = Object.assign({ loop: false }, opts);     this.setup();     this.bind();     this.onResize();     this.lazyInit();   }   setup(){     this.makeDots();     this.update();   }   bind(){     this.nextBtn.addEventListener('click', ()=>this.go(this.index + 1));     this.prevBtn.addEventListener('click', ()=>this.go(this.index - 1));     window.addEventListener('resize', ()=>this.onResize());     this.dotsWrap.addEventListener('click', (e)=>{       if(e.target.matches('button[data-index]')){         this.go(Number(e.target.dataset.index));       }     });     // keyboard     this.root.addEventListener('keydown', (e)=>{       if(e.key === 'ArrowRight') { e.preventDefault(); this.go(this.index + 1) }       if(e.key === 'ArrowLeft')  { e.preventDefault(); this.go(this.index - 1) }       if(e.key === 'Home')       { e.preventDefault(); this.go(0) }       if(e.key === 'End')        { e.preventDefault(); this.go(this.slides.length - this.visibleCount) }     });     // drag / touch     this.initDrag();   }   makeDots(){     this.dotsWrap.innerHTML = '';     const count = Math.max(1, this.slides.length - (this.visibleCount - 1));     for(let i=0;i<count;i++){       const btn = document.createElement('button');       btn.type = 'button';       btn.dataset.index = i;       btn.setAttribute('aria-selected', i === this.index ? 'true' : 'false');       btn.setAttribute('aria-label', `Go to slide ${i+1}`);       this.dotsWrap.appendChild(btn);     }     this.dots = Array.from(this.dotsWrap.children);   }   onResize(){     const viewWidth = this.root.querySelector('.slider__viewport').clientWidth;     const slideStyle = getComputedStyle(this.slides[0]);     const gap = parseFloat(getComputedStyle(this.list).gap) || 0;     // derive visible count by checking min-width vs container     const firstMin = parseFloat(slideStyle.minWidth) || viewWidth;     this.visibleCount = Math.round(viewWidth / (firstMin + gap)) || 1;     this.slideWidth = this.slides[0].getBoundingClientRect().width + gap;     this.makeDots();     this.go(this.index, true);   }   update(){     const x = -(this.index * this.slideWidth);     this.list.style.transform = `translateX(${x}px)`;     this.dots.forEach((d,i)=>d.setAttribute('aria-selected', i === this.index ? 'true' : 'false'));   }   go(idx, immediate = false){     const maxIndex = Math.max(0, this.slides.length - this.visibleCount);     if(this.opts.loop){       if(idx < 0) idx = maxIndex;       if(idx > maxIndex) idx = 0;     } else {       idx = Math.max(0, Math.min(idx, maxIndex));     }     this.index = idx;     if(immediate) {       const ttl = this.list.style.transition;       this.list.style.transition = 'none';       this.update();       requestAnimationFrame(()=>this.list.style.transition = ttl || '');     } else {       this.update();     }     // update aria-hidden on slides for screen readers     this.slides.forEach((s,i)=>{       const hidden = i < this.index || i >= this.index + this.visibleCount;       s.setAttribute('aria-hidden', hidden ? 'true' : 'false');     });   }   initDrag(){     let startX = 0, currentX = 0, dragging = false, startTranslate = 0, lastTime = 0;     const vp = this.root.querySelector('.slider__viewport');     const onDown = (clientX) => {       dragging = true;       startX = clientX;       startTranslate = -this.index * this.slideWidth;       lastTime = Date.now();       this.list.style.transition = 'none';       vp.style.cursor = 'grabbing';     };     const onMove = (clientX) => {       if(!dragging) return;       currentX = clientX;       const dx = currentX - startX;       this.list.style.transform = `translateX(${startTranslate + dx}px)`;     };     const onUp = (clientX) => {       if(!dragging) return;       dragging = false;       vp.style.cursor = '';       const dx = clientX - startX;       const threshold = this.slideWidth * 0.25;       if(dx < -threshold) this.go(this.index + 1);       else if(dx > threshold) this.go(this.index - 1);       else this.go(this.index);     };     // mouse     this.list.addEventListener('mousedown', (e)=>{       e.preventDefault();       onDown(e.clientX);       const move = (ev)=>onMove(ev.clientX);       const up = (ev)=>{ onUp(ev.clientX); window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };       window.addEventListener('mousemove', move);       window.addEventListener('mouseup', up);     });     // touch     this.list.addEventListener('touchstart', (e)=>onDown(e.touches[0].clientX), {passive:true});     this.list.addEventListener('touchmove', (e)=>onMove(e.touches[0].clientX), {passive:true});     this.list.addEventListener('touchend', (e)=>onUp(e.changedTouches[0].clientX));   }   lazyInit(){     const imgs = Array.from(this.root.querySelectorAll('img.lazy'));     if('IntersectionObserver' in window){       const io = new IntersectionObserver((entries, obs)=>{         entries.forEach(entry=>{           if(entry.isIntersecting){             const img = entry.target;             img.src = img.dataset.src;             img.classList.remove('lazy');             obs.unobserve(img);           }         });       }, {root:this.root.querySelector('.slider__viewport'), rootMargin:'200px'});       imgs.forEach(i=>io.observe(i));     } else {       imgs.forEach(i=>i.src = i.dataset.src);     }   } } document.addEventListener('DOMContentLoaded', ()=>{   const sliderEl = document.querySelector('.slider');   sliderEl.setAttribute('tabindex','0'); // allow keyboard focus   new Slider(sliderEl, {loop:false}); }); 

Notes on JavaScript choices:

  • Use transform: translateX for smooth GPU-accelerated movement.
  • Dragging adjusts transform directly, then settles via go().
  • Visible count is recalculated on resize so dots and indexing remain correct.
  • ARIA attributes and tabindex make the slider keyboard-accessible.

Accessibility checklist

  • Keyboard focusable container (tabindex=0) and keyboard handlers.
  • ARIA roles/labels: slides have role=“group” and aria-roledescription. Dots use aria-selected.
  • Hide off-screen slides from assistive tech via aria-hidden.
  • Ensure color contrast for controls and focus outlines visible.

Performance tips

  • Lazy-load images (IntersectionObserver).
  • Use responsive images (srcset) if you serve multiple sizes.
  • Avoid heavy libraries — this implementation is ~2–3 KB minified.
  • Reduce repaints: animate transform rather than left/top.

Extensions and ideas

  • Add autoplay with pause on hover/focus.
  • Support infinite looping by cloning slides (careful with accessibility).
  • Add variable-width slides for mixed-size content.
  • Add slide indicators with thumbnails.

This implementation provides a small, responsive, accessible foundation you can adapt. If you want, I can minify the JS/CSS, add autoplay, show how to implement infinite looping, or convert this into a reusable ES module.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *