{% for i in (1..include.N) %}
{% endfor %}
The liquids
capture caption_var and
include[caption_var] are necessary here to insert the index no. on each line. The format
{{ include.cap{{ i }} }} will not work.
An alternative caption insertion method (good for 100s of images) is to use a plugin that extracts the text from an exif tag on each image file, such as the following...
Exiftag plugin
You can extract the captions from the exif coment tag using a Jekyll plugin for the native exifr... (exiftag, by Beni Buess). It works well in the syntax because it uses the include file brace {%, not double brace {{ (as some other exifr plugins do). I did have to add the lines in the plugin with UTF8, to allow for extracting special characters.
You will need jpg files with the exif data (webp files have no exif). So in the images dir use one more mogrify command, after the comments are written to exif or every time they are updated..
View code
The folder jpg~ carries a tilde so that it is not included in the jekyll build, as only the exif data is needed at build time.
Copy the code below (from Github) to a file jekyll-exiftag.rb and save it to the _plugins dir. The plugins saved here are loaded automatically.
Also make sure to have the correct line in the file:
require 'exifr/jpeg' .
View code
{% include exiftag.rb %}
Add the dependency to your Gemfile and run `gem install` to install it.
gem 'exifr'
Or you can install it as a gem, with this line under the jekyll_plugins group in your Gemfile and running `gem install`.
gem 'jekyll-exiftag'
The include tag for the html looks like this (and keep those commas, which are vital)
{% exiftag comment,{{include.folder}}/jpg~,{{i}}.jpg %}
So the html with includes for the plugin looks like this..
View code
{% for i in (1..include.N) %}
{% exiftag comment,{{include.folder}}/jpg~,{{i}}.jpg %}
You will need to write your captions, of course, to the exif comment tags of all the images. Actually, XnviewMP has a tool to do this on the right-click menu, but there's a problem with the comments after the exiftag plugin extracts them into the html, they are written with a | mark on the end.
It works fine with my own script however (I made an Xnview button on the toolbar, but you can easily map it in ranger).
see my
exif comment script
Keeping the captions in the exif data should be very safe (unless you wipe the exif data) and you can easily re-organirse or replace images without the captios getting mis-matched.
My style sheets
Note that initial values are for mobile-first awareness, to enable mobile devices to display nicely with small screens.
The values set after
@media only screen and (min-device-width: 1200px) override any previous value of same name, for large screens.
I improved the css just now to enable better image size responsiveness (Jan 2026).
CSS for the modal
Using 'z-index: 100' ensures that this element is drawn last..
View code
/* The Modal (background) */
.modal {
display: none;
background-color: rgb(0,0,0,0.8);
cursor: pointer;
position: fixed;
padding-top: 40px;
left: 0;
top: 0;
width: 100vw;
height: 95vh; // make sure thumbs strip stays on the screen
z-index: 100;
-webkit-tap-highlight-color: transparent;
}
/* Modal Content */
.modal-content {
background-color: rgb(0,0,0,0.8);
position: relative;
width: 95%;
height: 100%;
padding: 0;
margin: auto;
text-align: center;
}
@media only screen and (min-device-width: 1200px){
.modal {
padding-top: 50px;
}
.modal-content {
width: 90%;
height: 100%;
max-width: 1200px;
}}
/* The Close Button */
.close {
color: #AFAFAF;
cursor:pointer;
position: absolute;
top: 10px;
right: 25px;
font-size: 35px;
font-weight: bold;
}
@media only screen and (min-device-width: 1200px){
.close {
font-size: 35px;
}}
.close:hover,
.close:focus {
color: #AFAFAF;
text-decoration: none;
cursor: pointer;
}
CSS for the slides
Here I needed to use flexbox attributes to allow centering of smaller slides - although now that I'm using 'height: 100%' on images, smaller images will be stretched.
So the mySlides div takes up 100% of slide-wrapper div which takes up 50% (mobile screen) or 80% of the modal area and is positioned at the top.
Fading in/out is achieved using opacity 0 on all slides, which changes to 1 when the class 'fade-in' is added by js, and then back to 0 when the class is removed )so simple!!).
View code
.slide-wrapper {
position: relative;
height: 50%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center; /* vertical center for images under 800px high -apply diplay:flex to parent div */
}
.mySlides {
display: block;
position: absolute;
top: 0;
height: 100%;
width: auto;
opacity: 0;
transition: 0.3s ease-out;
}
.fade-in {
opacity: 1;
transition: 0.3s ease-out;
}
img.slide {
position: relative;
border-radius: 5px;
outline: 3px ridge #AFAFAF;
height: 100%;
width: auto; /* keep portraits same height as landscapes */
margin: auto;
}
@media only screen and (min-device-width: 1200px){
.slide-wrapper {
height: 80%;
}
.mySlides {
height: 100%;
}
img.slide {
height: 100%;
object-fit: cover;
}}
@media only screen and (min-device-width: 1400px){
img.slide{
}}
.slidecaption {
background-color: none;
height: 50px;
padding: 25px 0 15px 0;
color: #AFAFAF;
font-size: 1.2em;
z-index: 50;
}
@media only screen and (min-device-width: 1200px){
.slidecaption {
height: 40px;
padding: 10px 0;
font-size: 1.1rem;
}}
CSS for the previous/next areas
The prev and next divs cover the slide div from top to bottom, taking up 50% on left and 50% on the right, and they are drawn on top with z-index: 10. The buttons are contained in these two areas for visual effect, even though clicking anywhere in the area calls the plusSlides fn.
View code
/* Next & previous area */
.prev1,
.next1 {
cursor: pointer;
position: absolute;
top: 0;
bottom:0;
width: 50%;
user-select: none;
-webkit-user-select: none;
z-index: 10;
}
.prev1 { /* Position the "prev area" to the left */
left:0px;
}
.next1 { /* Position the "next area" to the right */
right: 0;
}
/* Next & previous buttons */
.prev-button1, .next-button1 {
cursor: pointer;
position: absolute;
bottom: -350px;
margin-top: -50px;
width: 60px;
height: 50px;
padding: 9px;
color: #AFAFAF;
font-weight: bold;
font-size: 35px;
transition: 0.6s ease;
border-radius: 0 6px 6px 0;
user-select: none;
-webkit-user-select: none;
}
.prev-button1 {
left:0px;
}
/* Position the "next button" to the right */
.next-button1 {
right: 0;
border-radius: 6px 0 0 6px;
}
@media only screen and (min-device-width: 1200px){
.prev-button1, .next-button1 {
top: 50%;
font-size: 20px;
}
/* On hover, add a dark background */
.prev-button1:hover,
.next-button1:hover {
background-color: rgba(0, 0, 0, 0.8);
}}
CSS for the play/pause buttons
Positioning the buttons with the slide image size takes some work. The buttons are hidden when their class is switched to play0 or pause0 via js. The html starts up with play1 and pause0.
View code
.pause1, .play1 {
cursor:pointer;
position: absolute;
width: 150px;
height: 50px;
bottom: -340px;
left: 48%;
margin: -50px 0 0 -60px;
border-radius: 10%;
color: #AFAFAF;
text-align:center;
line-height: 5px;
padding: 20px 0 0 0;
z-index: 100;
}
.pause1 {font-size: 1.8rem; }
.play1 {font-size: 2.8rem; }
.pause0, .play0 {
display: none;
}
@media only screen and (min-device-width: 1200px){
.pause1, .play1 {
bottom: 65px;
margin: auto;
padding: 20px 0 10px 0;
line-height: 15px;
height: 30px;
}
.pause1 {font-size:1.3rem; }
.play1 {font-size:1.9rem; }
.pause1:hover, .play1:hover {
background-color: rgba(0, 0, 0, 0.6);
box-shadow: 1px 1px 3px #999999;
transition: 0.4s ease;
}}
CSS for the thumbnail strip
Flexbox is used again, to allow centering of thumbs, both landscape and portrait, which I chop a little to make them nearly squarish.
View code
.strip {
display: flex;
background-color: rgb(0,0,0,0.8);
flex-wrap: nowrap; /* how to allow overflow y ??? */
max-height: 150px;
max-width: 400px;
border-radius: 5px;
width: fit-content; /* collapse width with only few thumbs */
margin: auto; /* center strip */
margin-top: 60px;
border: 3px ridge #AFAFAF;
overflow-x: auto; /* allow scrolling */
overflow-y: hidden;
white-space: nowrap;
text-align: center;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.strip::-webkit-scrollbar {
display: none; /* Chrome */
}
.thumb {
display: flex;
justify-content: center;
align-items: center;
min-width: 150px;
max-width: 215px;
max-height: 210px;
border-radius: 5px;
overflow: hidden; /* allow thumbs round corners */
margin-right: 20px;
}
@media only screen and (min-device-width: 1200px){
.strip {
max-width: 1200px;
max-height: 135px;
margin-top: 10px;
position: absolute;
bottom: 4px;
}
.thumb {
display: flex;
min-width: 120px;
max-width: 135px;
max-height: 101px;
margin-right: 5px;
opacity: 0.7;
}
.active,
.thumb:hover {
opacity: 1;
transition: 0.3s;
outline: 0px ridge #AFAFAF;
}}
CSS for mobile portrait orientation
A whole different experience is allowed when the mobile is rotated to landscape, with larger slides and the thumbnails strip to the side.
Note that the last '@media only... 1200px' entry was needed below the landscape values to keep large screen display working.
It takes some work to make the appearance look good. Without 'overflow: hidden' on 'slide-wrapper' div the last slides went off the top of screen, for some reason.
View code
@media only screen and (max-device-width: 1200px) and (orientation: landscape){
.modal {
height: 100vh;
padding-top: 2px;
}
.close {
position: absolute;
top: 50px;
left: 2px;
width: 35px;
height: 40px;
font-size: 35px;
z-index: 40;
}
.slide-wrapper {
position: relative;
height: 99%;
width: auto;
right: 0%;
margin-left: 170px;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center; /* vertical center for images under 800px high -apply diplay:flex to parent div */
}
img.slide{
margin: 6px;
}
.prev-button1, .next-button1 {
top: 55%;
}
.strip {
flex-wrap: wrap;
position: absolute;
top: -50px;
left: 20px;
height: 400px;
max-height: 400px;
width: 150px;
overflow-x: hidden;
overflow-y: auto;
gap: 5px;
}
.thumb {
width: 150px;
min-width: 150px;
max-width: 150px;
max-height: 150px;
}
.pause1, .play1 {
width: 50px;
height: 50px;
bottom: -5px;
left: 56px;
color: #797373;
}}
.thumb img {
object-fit: contain;
height: 280px;
max-width: 280px;
}
CSS for the gallery thumbnails
Flexbox again, to allow centering of thumbs, which I chop a little to make them nearly squarish.
View code
img.launcher {
opacity: 0.9;
border-radius: 5px;
}
.launcher:hover {
outline: 3px ridge #AFAFAF;
cursor:pointer;
border-radius: 5px;
opacity: 1;
}
.gallerywrapper { /* Gallery page */
position: relative;
width: 90%;
margin-left: auto;
margin-right: auto;
padding: 180px 60px 60px 60px;
min-height: 900px;
}
@media only screen and (min-device-width: 768px){
.gallerywrapper {
width: 80%;
height: auto;
padding: 100px 60px 60px 60px;
}}
.gallery-row { /* Gallery content page tiles area */
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
}
.gallery-col {
padding: 0px;
}
.gallery-col p {
text-align: center;
}
.gallery-col img {
width: 380px;
margin-bottom: 20px;
}
@media only screen and (min-device-width: 1200px){
.gallery-row {
width: 95%;
margin: auto;
}
.gallery-col img {
height: 133px;
width: auto;
margin-bottom: 15px;
}}
@media only screen and (max-device-width: 1200px) and (orientation: landscape){
.gallery-col img {
height: 133px;
width: auto;
margin-bottom: 15px;
}}
.thumbs-list { /* thumbs sub-page */
display: flex;
flex-wrap: wrap;
justify-content: center;
gap:20px;
}
.thumbs-item {
display: flex;
justify-content: center;
align-items: center;
max-width: 285px;
max-height: 253px;
opacity: 0.9;
border-radius: 5px;
margin-bottom: 15px;
overflow: hidden; /* allow thumbs round corners */
}
.thumbs-item:hover {
outline: 3px ridge #AFAFAF;
cursor: pointer;
border-radius: 5px;
opacity: 1;
}
@media only screen and (max-device-width: 1200px) and (orientation: portrait){ /* resize thumbs for mobile only */
.thumbs-item img {
object-fit: contain;
width: auto;
height: 285px;
}}
@media only screen and (min-device-width: 1200px){
.thumbs-list {
width: 100%;
margin: auto;
gap:10px;
}
.thumbs-item {
max-width: 150px;
max-height: 133px;
}}
@media only screen and (max-device-width: 1200px) and (orientation: landscape){
.thumbs-item {
max-width: 150px;
max-height: 133px;
}}
My javascript
And the javascript code I developed to add all these user-friendly features became quite long. I will break it up in pieces below.
The Modal engine
First of all, the vital part needed to run the lightbox. I use a true/false argument to tell the script if the Modal is open or not, to prevent page scroll when open, and allow it again when closed (see fns below), and to pause when closed (see below).
The fn 'scrollIntoView' is used to create smooth motion of the thumbnail bar, keeping the active thumb at center, except for the ends, which wrap to the other end.
I also switched to a 0-based index (slideIndex = 0) which negates the need for 'slideIndex-1' for the current slide (used when the slide index is 1-n and the div array is 0-n), but the 'currentSlide' fn call must use slide no. -1, in order to find the correct index value (because we number the images 1-n).
Also, to wrap the index from the end back to the start, or from start to end, the line
slideIndex = (slideIndex + slides.length) % slides.length; is needed (instead of if (n > slides.length) ... if ( n < 1) ... ), when the index is 0-based.
modalClosed = true;
var prevIndex = null;
slideIndex = 0;
const modal = document.getElementById("myModal");
// Open the Modal
function openModal() {
modal.style.display = "block";
stopscroll();
modalClosed = false;
}
// Close the Modal
function closeModal() {
Pause();
modal.style.display = "none";
resumescroll();
modalClosed = true;
}
// Next/previous controls
function plusSlides(n) {
prevIndex = slideIndex; // update prevIndex
showSlides(slideIndex += n);
}
// Thumbnail image controls
function currentSlide(n) {
prevIndex = slideIndex; // update prevIndex
showSlides(slideIndex = n);
}
function showSlides(n) {
var i;
var slides = document.getElementsByClassName("mySlides");
var thumbs = document.getElementsByClassName("thumb");
slideIndex = (slideIndex + slides.length) % slides.length;
for (i = 0; i < thumbs.length; i++) {
thumbs[i].className = thumbs[i].className.replace(" active", "");
thumbs[slideIndex].className += " active";
}
// Auto scroll active thumb to center of slider
thumbs[slideIndex].scrollIntoView({
behavior: "smooth", inline: "center"})
slides[slideIndex].classList.add("fade-in");
slides[slideIndex].style.zIndex = "10";
slides[prevIndex].classList.remove("fade-in");
setTimeout((index) => {
slides[index].style.zIndex = "0";
}, 200, slideIndex);
}
To create the cross-fade of new slide with previous, so that the previous fades out at the same time, I use the 'fade-in' class - add to new slide, remove from previous.
The previous slide must fade out because portrait slides will not cover the whole slide area and clearing the previous slide instantly would look bad.
Notes:
'prevIndex = null' this variable is first set to 'null' so that it can be updated
'prevIndex = slideIndex' the variable is updated before the slide index changes (eg. next slide, or rnadom slide). It therefore holds the value of the index (slide no.) before a new slide, i.e. the prevous slide
'setTimeout((index)' tells the script to pause for 200ms, enough for the previous slide to fade out, and then the current slide drops to 'z-index: 0' after starting at '10' to make sure it is displayed on top of the previous (in case of lower slides, i.e. going backwards)
The 'index' returns the value that is held in the closure, after the delay value, i.e. 200, slideIndex) to catch the correct slide.
The slideshow timer
The Play and Pause fns are called from the html onClick, attached to the two buttons, which carry the id 'play' and 'pause'. The timer starts using the fn called 'setInterval' and stops with 'clearInterval'. The value 3000 stands for 3seconds, before plusSlides fn calls a new slide. The fn also has to hide the play button when pressed and show the pause button, and then hide the pause button when pressed and show the play button again!
// SLIDESHOW
let timerId
var Timer = false
function startTimer() { timerId = setInterval("plusSlides(1)", 3000); }
function hideplay() { document.querySelector("#play").className = "play0"; }
function showpause() { document.querySelector("#pause").className = "pause1"; }
function Play(event) {
startTimer();
hideplay();
showpause();
Timer = true;
}
function stopTimer() { clearInterval(timerId);}
function hidepause() { document.querySelector("#pause").className = "pause0"; }
function showplay() { document.querySelector("#play").className = "play1"; }
function Pause(event) {
if (Timer == true) {
stopTimer();
hidepause();
showplay();
Timer = false;
} else ;}
Mouse scrolling
Progress through slides using the mouse scroll button. Acomplished using 'event.deltaY'.
// PREV, NEXT WITH SCROLL WHEEL
function wheel() {
if (event.deltaY > 0) { plusSlides(1)}
// Scroll wheel down
else if (event.deltaY < 0) {plusSlides(-1)}
// Scroll wheel up
}
Keyboard navigation
Keyboard integration, accomplished with 'keyCode' numbers of keys and attaching them to elements in the html that carry onClick events, or else calling functions directly, as with Esc and Spacebar.
// JAVASCRIPT FOR KEYBOARD NAVIGATION (PREV, NEXT, AND ESCAPE BUTTONS)
document.onkeydown = function (e) {
e = e || window.event;
if (e.keyCode == 37) {document.querySelector('.prev').click();}
if (e.keyCode == 39) {document.querySelector('.next').click();}
if (e.keyCode == 27) {
if (!modalClosed) {closeModal();}
else {document.querySelector('#back-button').click();}
}
else if (e.keyCode == 32) {Spacebar();}
}
function Spacebar(event) {
if (Timer == false) { Play(); }
else { Pause(); };
}
// JAVASCRIPT FOR TAB KEY & ENTER SELECT THUMB)
document.addEventListener('keydown', function(event) {
// Check if the Enter key is pressed
if (event.keyCode === 13) {
// Get the currently focused element
var focusedElement = document.activeElement;
// Trigger a click event on the focused element
if (focusedElement.click) {
focusedElement.click();
}
}
});
Prevent unwanted actions
The close event attached to the 'modal' div, that surrounds the slides, is prevented from affecting the child div 'modal-content' with the onClick call to the 'stopClose' fn.
Page body scroll prevented when Modal is opened and mouse is scrolling to progress slides.
// JAVASCRIPT FOR CLICK ON SLIDE DOES NOT CLOSE LB)
function stopClose(event) {
event.stopPropagation();
}
// PREVENTING BODY SCROLL
function stopscroll() { document.querySelector("body").classList.add("modal-open"); }
function resumescroll() { document.querySelector("body").classList.remove("modal-open"); }
Touch gestures on mobile screens
Swiping left or right needs some test variables set to decide if the touch was a swipe or was accidental.
// SWIPE (PREV, NEXT)
var minHorizontalMove = 30; // adjust to your liking
var maxVerticalMove = 30; // adjust to your liking
var withinMs = 1000; // adjust to your liking
// Initialize variables
var startXPos;
var startYPos;
var startTime;
// Touch start event handler
document.addEventListener('touchstart', (e) => {
startXPos = event.touches[0].pageX;
startYPos = event.touches[0].pageY;
startTime = new Date();
})
// Touch end event handler
document.addEventListener('touchend', (e) => {
var endXPos = event.changedTouches[0].pageX;
var endYPos = event.changedTouches[0].pageY;
var endTime = new Date();
var moveX = endXPos - startXPos;
var moveY = endYPos - startYPos;
var elapsedTime = endTime - startTime;
if (Math.abs(moveX) > minHorizontalMove && Math.abs(moveY) < maxVerticalMove && elapsedTime < withinMs) {
if (moveX < 0) {
// Swipe left detected
console.log("Swipe left");
// Call your left-swipe function here
plusSlides(1);
} else {
// Swipe right detected
console.log("Swipe right");
// Call your right-swipe function here
plusSlides(-1);
}
}
})
Multiple lightboxes
The case might arise when you need more than one lightbox on the same page. It won't work however with the html/js as already described, because when the javascript function is called, it will set the class name of the first 'myModal' div it finds on the page or it will progress through all the slides it finds on the page..
To work as expected, each lightbox would need to call the slides within its own div.
You could rename every function in the script and the html, appending a suffix that you can easily replace, like 'L1', 'L2' etc.. But this is a clumsy method, having multiple scripts for the same functions! And errors are bound to spring up when adding more functions later.
Looking at it logically, we should be able to discover the active lightbox modal from the element that is clicked on at the start (if not by finding the one having display: block).
So let's build it so that a click on a gallery thumbnail sets the closest modal div element as a variable, which is then used everywhere in the js while the modal is open.
We can do this using
nextElementSibling from the thumbnail container div, which we get using
ele.closest('.modal') from the clicked thumbnail.
The onClick call to open the modal needs to pass an argument to the fn to catch the element that is making the call (clicked on), like this..
View code
.
The script for multiple lightboxes...
View code
modalClosed = true;
var modal = null;
var prevIndex = null;
slideIndex = 0;
// Open the Modal closest to the thumb clicked on
function openModal(n, thumb) {
modal = thumb.closest('.thumbs-list').nextElementSibling;
modal.style.display = 'block';
showSlides(slideIndex = n);
stopscroll();
modalClosed = false;
}
// Close the Modal
function closeModal() {
Pause();
modal.style.display = "none";
resumescroll();
modalClosed = true;
}
// Next/previous controls
function plusSlides(n) {
prevIndex = slideIndex; // update prevIndex
slideIndex = slideIndex + n;
showSlides(slideIndex);
}
// Thumbnail image controls
function currentSlide(n) {
prevIndex = slideIndex; // update prevIndex
showSlides(slideIndex = n);
}
function showSlides(n) {
var i;
for (i = 0; i < thumbs.length; i++) {
thumbs[i].className = thumbs[i].className.replace(" active", "");
thumbs[slideIndex].className += " active";
}
// Auto scroll active thumb to center of slider
thumbs[slideIndex].scrollIntoView({
behavior: "smooth", inline: "center"})
slideIndex = (slideIndex + slides.length) % slides.length;
for (i = 0; i < thumbs.length; i++) {
thumbs[i].className = thumbs[i].className.replace(" active", "");
thumbs[slideIndex].className += " active";
}
// Auto scroll active thumb to center of slider
thumbs[slideIndex].scrollIntoView({
behavior: "smooth", inline: "center"})
slides[slideIndex].classList.add("fade-in");
slides[slideIndex].style.zIndex = "10";
slides[prevIndex].classList.remove("fade-in");
setTimeout((index) => {
slides[index].style.zIndex = "0";
}, 200, slideIndex);
}
'modal = null' initializes the variable, so it can be updated each time a new modal opens.
'openModal(n, thumb)' catches the thumbnail slide no. and the element that clicked, as 'thumb', which is used to locate the 'thumbs-list' div, which in turn is used to locate the 'modal' div.
'slides = modal.querySelectorAll('.mySlides')' - the 'showSlides' fn creates the index of slides using this variable 'modal', to gather them only from the open modal.
And that's it really!
The method I used to build a gallery of multiple image sets, all in one place on the same page, is to use the javascript function 'fetchHtml' which calls html from a file and loads it into a div on the page, replacing the html content of that div.
This way, a single click on a thumbnail or a 'next' button will load a different chunk of html and the javascript on the page then works with the new html, after clicking on a thumbnail in the new html.
I describe the implementation of this idea on my
fetch html page.