HTML and CSS responsive carousel in Jekyll

I built a photogallery carousel in pure HTML and CSS featuring responsive images and touch controls for mobile devices.


Jekyll Liquid Responsive images HTML CSS SASS

In a quest to continuous performance optimization, I created an image gallery carousel in pure HTML and CSS for the photography section in Silvia’s website. A full-fledged Jekyll module, it features responsive images and intuitive touch controls for mobile devices.

Optimising the stack

When we released Silvia’s new website, the photo galleries relied on two Javascript-based components from Bootstrap 5: one for modal windows and one for carousels. When I worked for UI Farm in London, I used to write pure HTML and CSS alterneatives to accordions, interactive overlays, image galleries and more.

Having always considered web performance as a design feature instead of an afterthought, I deemed the use of Javascript for modal windows and carousels a waste of resources. In Silvia’s case, it also littered her local environment with a preposterous amount of node.js modules that I wanted to rid of.

The module

First, the end result of this, meaning: what do we have to do in order to add a photo gallery in a page or post:

{% include pattern-imagegallery.html folder="/assets/images/gallery-press/" id="1" %}

Pretty simple. We include an HTML file containing the logic and the markup, while passing two simple parameters: the path to the folder containing the images and a numeric ID. The only requirement is to follow the same naming convention for all the images, where the small low resolution version, used for preview in the page and navigation in the carousel, would have the prefix thumb-.

The first step is to render the list of thumbnails serving as preview. Taking advantage of Jekyll’s static files feature, I set a default local path for all the images in the config.yml file. It’s instructing the system to treat each file contained in that path as an image:

  - scope:
      path: "assets/images"
      image: true

The section tag wrapping the whole module has its specific dynamic ID, necessary for when multiple galleries are present in the same page. Inside, a div tag serves as a flexbox container to show the thumbnails in a centered row.

<section id="gallery-{{ }}">
  <div class="d-flex flex-wrap justify-content-center">

The following logic fetches the correct images by filtering the path we passed earlier, working out their filenames to dynamically print a caption:

{%- for image in site.static_files | where: "image", true -%}
  {%- capture galleryPath -%}{{ include.folder }}{%- endcapture -%}
  {%- if image.path contains galleryPath -%}
    {%- assign filenameparts = image.path | split: "/" -%}
    {%- assign imgCaption = filenameparts | last | replace: image.extname,'' | replace: 'thumb-', '' | replace: 'a_','' | replace: 'b_','' | replace: 'c_','' | replace: 'd_','' | replace: 'e_','' | replace: 'f_','' | replace: 'g_','' | replace: 'h_','' | replace: 'i_','' | replace: 'l_','' | replace: 'm_','' | replace: 'n_','' | replace: 'o_','' | replace: 'p_','' | replace: 'q_','' | replace: 'r_','' | replace: 's_','' | replace: 't_','' | replace: 'u_','' | replace: 'v_','' | replace: 'z_','' | replace: '-',' ' | replace: '_',' ' -%}
    {%- if image.path contains 'thumb-' -%}
    <div class="ps-1 pe-2 text-center">
      <img class="rounded mx-auto d-block" src="{{ image.path }}" alt="{{ imgCaption | capitalize }}" width="150" height="150">
      <span class="d-block initialism mt-2 mb-4">{{ imgCaption }}</span>
    {%- endif -%}
  {%- endif -%}
{%- endfor -%}

To break it down:

This is the result:

Column view thumbnail photo gallery

All of the above is compiled by simply including this folder in the page:

List of included images in the filesystem

Creating the modal window

Before the closing div and section, we include a second pattern containing the modal window and the carousel itself:

    {%- include pattern-modal-carousel.html -%}

The modal is wrapped in another flexbox div, where the first element is the button responsible for opening the modal itself:

<div class="m2m-modal-container d-flex flex-wrap justify-content-center mt-4">
  <input class="m2m-modal-btn" type="checkbox" id="m2m-modal-{{ }}" name="m2m-modal-{{ }}">
  <label class="btn btn-lg btn-m2m btn-m2m-cta py-3 px-4 fw-bold" for="m2m-modal-{{ }}">📷 <span class="initialism fs-5 ps-1"><strong>Open the gallery</strong></span></label>

By passing the same ID, we make sure each gallery has its own instruction, otherwise the system wouldn’t know which one to open and close. Since the mechanism responsible for opening and closing the modal window is based on the well-established checkbox hack,1 a label HTML element is the actual button. The modal takes 95 percent of the available browser window width and height and is hidden by default, via SCSS:

.m2m-modal-container {
  [type="checkbox"]:not(:checked) {
    @extend .visually-hidden;

.m2m-modal-wrap {
  width: 95vw;
  height: 95vh;
  margin: auto;
  border-radius: 4px;
  overflow: hidden;
  background-color: #000;
  align-self: center;
  opacity: 0;
  transform: scale(0.6);
  transition: opacity 250ms 250ms ease, transform 300ms 250ms ease;

We make the window appear when the checkbox is selected:

.m2m-modal-btn:checked ~ .m2m-modal {
  pointer-events: auto;
  opacity: 1;
  transition: all 300ms ease-in-out;

The close button is added in an :after pseudo-class, including a bit of further media query code (not shown here) to change its size on small devices.

.m2m-modal-btn:checked + label:after,
.m2m-modal-btn:not(:checked) + label:after {
  position: fixed;
  content: '❌';
  top: 4px;
  right: 4px;
  width: 40px;
  height: 40px;
  line-height: 40px;
  font-size: 1rem;
  text-align: center;
  background-color: $white;
  border-radius: 10em;
  transition: all 200ms linear;
  opacity: 0;
  pointer-events: none;
  transform: translateY(20px);

The carousel is contained in a couple of divs, an unordered list and a navigation pattern. The list element contains the hi-res image, filtered by exclusion with the unless condition.

Hi-res images

<div class="m2m-carousel-container d-flex justify-content-center">
  <div class="m2m-carousel">
    <ul class="m2m-carousel-scroll list-unstyled mx-0 my-0" scroll-behavior="smooth">
      {%- unless image.path contains 'thumb-' -%}
      <li class="m2m-carousel-scroll-item-outer">
        <div id="{{ imgCaption | slugify }}-{%- increment slideId -%}" class="m2m-carousel-scroll-item">
          [=== slide image here ===]
      {%- endunless -%}
    [=== navigation here ===]

To break in down:

A navigation row sits at the bottom of the modal window, featuring the thumbnails linking to the related hi-res image above.

<nav id="m2m-slider-nav">
  {% comment %}
    Here's the same logic I used earlier
    to fetch the images from Jekyll's
    static files functionality.
  {% endcomment %}
  {%- assign imageNavPath = image.path | split: "/" | last | prepend: 'thumb-' -%}
  {%- assign slideId = 0 -%}
  {%- assign slideNavId = 0 -%}
  {%- assign slideNav = 0 -%}
  <a href="#{{ imgCaption | slugify }}-{%- increment slideNavId -%}">
    <img class="mx-auto d-block" src="{{ galleryPath }}{{ imageNavPath }}" alt="{{ imgCaption | capitalize }}" title="Click to view {{ imgCaption | capitalize }}" width="120" height="120">

Besides the same liquid logic I’ve already employed to fetch the correct images and generate the captions, I introduce three new variables. They’re all adopted to generate numerically incrementing IDs that keep the navigation in sync with the hi-res images.

Mobile functionality

Through SCSS code, I made sure users could change slide by swiping left or right on the image, respecting any preference set in either the browser or the operating system for reduced-motion.

.m2m-carousel-scroll {
  @media (prefers-reduced-motion: no-preference) {
    scroll-behavior: smooth;
    @-moz-document url-prefix() {
      scroll-behavior: auto;
  display: flex;
  overflow-y: hidden;
  width: 100%;
  margin: 0;
  padding: 0;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
.m2m-carousel-scroll-item > img:active {
  cursor: grabbing;
  cursor: -webkit-grabbing;
@supports (scroll-snap-align: start) {
  .m2m-carousel-scroll {
    scroll-snap-type: x mandatory;
  .m2m-carousel-scroll-item-outer {
    scroll-snap-align: center;
@supports not (scroll-snap-align: start) {
  .m2m-carousel-scroll {
    scroll-snap-type: mandatory;
    scroll-snap-destination: 0 50%;
    scroll-snap-points-x: repeat(100%);
  .m2m-carousel-scroll-item-outer {
    scroll-snap-coordinate: 0 0;

Further optimization: responsive images

As stated earlier, once the module was completed we realized to have forgotten about the issue of large images on small devices. Since we had already implemented responsive images in the website, I just decided to grab the code to render the hi-res pictures which generates different sizes for different media viewports:

{%- assign respFileNamePart = filenameparts | last -%}
{%- assign respImgPath = respFileNamePart | prepend: galleryPath | remove_first: "/" -%}
{% responsive_image_block %}
  path: {{ respImgPath }}
  alt: {{ imgCaption | capitalize }}
  margin-nil: 0
{% endresponsive_image_block %}

The source code above generates the following HTML:

<figure class="my-0 text-center">
  <img class="mx-auto" src="(" alt="In cambridge" srcset="( 576w,( 768w,( 1200w, ( 1600w">

It renders responsive images inside a figure tag, using srcset with the smallest resized image used as a fallback. Every time a new gallery is added to a page, Jekyll generates all the resized versions on its own.


Testing on all devices

Tests were successful on all devices, desktop, tablets and smartphones with the browsers:

Swiping on touch devices worked as expected while most browsers on desktop gracefully accepted the scroll snapping.

Performance improvements

All tests after the new module was released gave stunning results: despite a total image weight of almost 2 megabytes, Pagespeed on mobile went from 27 to 100.

Source code

Check out my public gists for the three files:


  1. Please note that the checkbox hack is not fully accessible. I need to figure out an alternative.