Hugo + js = enhanced figures

Hugo + js = enhanced figures


Updated 2022-01-01: extended the gallery-image shortcode

Multiple shortcodes to enhance Hugo’s default figure capabilities.

Hugo supports figures out of the box by using the figure shortcode or the markdown syntax. They both work well if you quickly want to add an image. The Hugo shortcode supports some customization e.g. adjust dimension and what not but we want to go further. First of all images should auto scale to the available page width, use lazyload1 and have the ability to be shown modally. Images can come from the Page resources or external links. In addition some shortcodes are added to show images in different ways (gallery, grid, …).

Default way of using images in Hugo:

![https://example.com](https://example.com/example.jpg "Example image")

Markdown figure syntax

{{< figure src="example.jpg" title="Example image" >}}

Hugo default shortcode

This article was written for Hugo 0.70.

Override the default shortcode

This shortcode is a modified version of hugo’s default figure shortcode ( source). Our additions include loading images from Hugo’s page resources, use lazyloading and enabling an image overlay/gallery (see section). The figure caption got extended as well.
Overwriting the default shortcode is very simple, create a shortcode with the same name in your own shortcode directory (/layouts/shortcodes/figure.html) and you’re set.

Only the figure shortcode is overwritten, not the markdown syntax “shortcode” because I only the use the former. You can override the markdown “shortcode”, if so desired, and put it in layouts/_default/_markup/render-image.html.

Usage

All the default shortcode’s parameters are still supported and additional options were added

option   effect   optionaldefault
linkTxttext which, if link is set, will be shown in the caption instead of the link itselfyeslink itself
overlaywhether or not the image can be shown modal (boolean)yestrue

A subtile difference compared to the default however, dimensions (width and height) must be provided without unit and are assumed to be in px (support for other units can be added). The attr parameters will used set the source in the figure caption, it can be either a link or text. Images are stretched to fill the available width.

If your image comes from the page’s resources multiple resolutions are generated to have so called responsive images (inspired by henrik sommerfeld).
If you have many images generating responsive images may have a significant performance impact when building the website as stated by Hugo.

Below are a few examples:

{{< figure src="ganttExample.jpg" title="Gantt chart" caption="example with source and link" attr="wikidot.com" attrlink="http://jldiaz.wikidot.com/en-tikz-example:gantt" link="https://en.wikipedia.org/wiki/Gantt_chart" linkTxt="Gantt chart" >}}
example with source and link

Gantt chart

example with source and link

Source: wikidot.com

Link: Gantt chart

{{< figure src="ganttExample.jpg" overlay="false" caption="no overlay (enabled by default)" >}}
no overlay (enabled by default)

no overlay (enabled by default)

{{< figure src="ganttExample.jpg" class="example-class-img" caption="figure with custom css class" >}}
.example-class-img {
  padding-top: 1.5em;
  background-color: darkorange;
  text-align: center !important; // Center the img
}
.example-class-img figcaption {
  color: black;
  justify-content: center !important; // Center the  caption
}

custom css class

figure with custom css class

figure with custom css class

{{< figure src="ganttExample.jpg" caption="resized example" width="400" >}}
resized example

resized example

{{< figure src="ganttExample.jpg" caption="resized example" height="100" >}}
resized example

resized example

{{< figure src="ganttExample.jpg" caption="resized example" height="100" width="100" >}}
resized example

resized example

{{< figure src="https://upload.wikimedia.org/wikipedia/commons/e/e8/Point_de_vue_du_Gras_by_Ni%C3%A9pce%2C_1826.jpg" height="300" attr="wikipedia.com" link="https://en.wikipedia.org/wiki/View_from_the_Window_at_Le_Gras" linkTxt="View from the Window at Le Gras" >}}

Source: wikipedia.com

Link: View from the Window at Le Gras

Code

Although strictly not required the lazyload library is used to load images. At the time of writing 5.2.0.

Override the default shortcode by adding your version in `/layouts/shortcodes/figure.html`, the name of the file is important. The shortcode is quite heavily modified compared to the original, high-level this is the flow: "get" the image (as remote link or from the page resources), resize the image if required (only if it is a Page resource) and generate the responsive images if applicable (a small preview image is directly embedded in HTML as a Base64 encoded string, so that there is no extra request required to fetch the preview image), similar to the original shortcode add a `img` element with all the attributes and finally the `figcaption` if required.

If the image is a Page resource, and resized, a link to the original image in full resolution is added (as hsrc) as well which the user can open modally. The full resolution dimensions are also added because they are required upfront due to the library we use, see below. Whether or not the image can be opened modally is determined by adding the modalImageTrigger class. All the caption information is also added as attributes because said data will be required later on (see below).

The lazyload class is added for the lazyload library which is used to only load the images when required and pick the appropriate responsive image (if applicable). Alteratively one could have used the default HTML responsive image feature instead.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
{{ .Page.Scratch.Add "hasImages" "true" }}
{{/* Scratch to conditionally include js files. */}}
{{/* Get the image */}}
{{- $img := .Get "src" -}}
{{- $width := .Get "width" -}}
{{- $height := .Get "height" -}}
{{- $isRemote := strings.HasPrefix $img "http" -}}
{{- $isResized := false -}}
{{- $isResponsive := false -}}
{{- $originalImg := "" -}}
{{- if not $isRemote -}}
{{- $img = .Page.Resources.GetMatch $img -}}
{{- if and (not $img) .Page.File -}}
{{- $path := path.Join .Page.File.Dir $img -}}
{{- $img = resources.Get $path -}}
{{- end -}}
{{- $originalImg = $img -}}
{{/* Resize the image if desired */}}
{{- if or $width $height }}
{{- $isResized = true -}}
{{- if and $width $height -}}
{{- $img = ($img.Resize (print $width "x" $height)) -}}
{{- else if $width -}}
{{- $img = ($img.Resize (print $width "x")) -}}
{{- else if $height -}}
{{- $img = ($img.Resize (print "x" $height)) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Generate responsive images if applicable */}}
{{- $src_set := "" -}}
{{- $placeholder := "" -}}
{{- if not $isRemote -}}
{{- if (ge $img.Width "500") -}}
{{- $isResponsive = true -}}
{{/*  Very small placeholder, inline in HTML  */}}
{{- $placeholder = ($img.Resize "48x q20") | images.Filter (images.GaussianBlur 6) -}}
{{- $src_set = (print $img.RelPermalink " " $img.Width "w") -}}
{{- if ge $img.Width "500" -}}
{{- $x_small := $img.Resize "500x" -}}
{{- $src_set = (print $src_set ", "  $x_small.RelPermalink " 500w") -}}
{{- end -}}
{{- if ge $img.Width "800" -}}
{{- $small := $img.Resize "800x" -}}
{{- $src_set = (print $src_set ", " $small.RelPermalink " 800w") -}}
{{- end -}}
{{- if ge $img.Width "1200" -}}
{{- $medium := $img.Resize "1200x" -}}
{{- $src_set = (print $src_set ", " $medium.RelPermalink " 1200w") -}}
{{- end -}}
{{- if gt $img.Width "1500" -}}
{{- $large := $img.Resize "1500x" -}}
{{- $src_set = (print $src_set ", " $large.RelPermalink " 1500w") -}}
{{- end -}}
{{- end -}}
{{- end -}}
<figure{{ with (.Get "class") }} class="{{ . }}"{{ end }}>
    <img data-src="{{ $img }}"
         {{- if $isResponsive }}data-sizes="auto" srcset="data:image/jpeg;base64,{{ $placeholder.Content | base64Encode }}" data-src="{{ $img.RelPermalink }}"
         data-srcset="{{ $src_set }}"{{ end -}}
         {{- if or (.Get "alt") (.Get "caption") }}
         alt="{{ with .Get "alt" }}{{ . }}{{ else }}{{ .Get "caption" | markdownify | plainify }}{{ end }}"
         {{- else -}}
         alt=""
         {{- end -}}
         class="lazyload{{- if eq "true" (default "true" (.Get "overlay")) }} modalImageTrigger{{ else }} noHoverImgTrigger{{ end -}}"
         {{- with $width }} width="{{ . }}px"{{ end -}}
         {{- with $height }} height="{{ . }}px"{{ end -}}
         {{- if and $height (not $width) }} style="height: {{ $height }}px;"{{ end -}}
         {{- with .Get "title" }} imgTitle="{{ . | plainify }}"{{ end -}}
         {{- with .Get "caption" }} imgCaption="{{ . | markdownify | plainify }}"{{ end -}}
         {{- with .Get "attr" }} attr="{{ . | markdownify | plainify }}"{{ end -}}
         {{- with .Get "attrlink" }} attrlink="{{ . | plainify }}"{{ end -}}
         {{- if (.Get "link") }}
          {{- $target := (.Get "linkTarget") | default "_blank" -}}
          linkhref="{{ .Get "link" }}"
          {{- with .Get "linkTxt" }} linkTxt="{{ . | markdownify | plainify }}"{{ end -}}
          linktarget="{{ $target }}"
          linkrel="{{- with .Get "linkRel" }}{{ . | plainify }}{{ else }}{{ if eq $target "_blank" }}noreferrer{{ end }}{{ end -}}"         
         {{ end -}}
         {{/* Link the high res version of the image if applicable */}}
         {{- if and (not $isRemote) $isResized }}
          hsrc="{{ $originalImg.RelPermalink }}"
          hwidth="{{ $originalImg.Width }}"
          hheight="{{ $originalImg.Height }}"
         {{ end -}}
    />
    {{- if or (.Get "link") (or (.Get "attrlink") (or (.Get "attr") (or (.Get "title") (.Get "caption")))) -}}
        <figcaption>
            {{- with (.Get "title") -}}
                <h4>{{ . }}</h4>
            {{- end -}}
            {{- with (.Get "caption") -}}
                <p>{{ . }}</p>
            {{- end -}}
            {{- if and (.Get "attr") (not (.Get "attrlink")) -}}
                <p>Source: {{ (.Get "attr") | markdownify }}</p>
            {{- end -}}
            {{- with (.Get "attrlink") -}}
                <p>Source: <a href="{{ . }}" target="_blank" rel="noreferrer">{{ default . (($.Get "attr") | markdownify) }}</a></p>
            {{- end -}}
            {{- if (.Get "link") -}}
                {{- $target :=  (.Get "linkTarget") | default "_blank" -}}
                <p>Link: <a href="{{ .Get "link" }}" target="{{ $target }}" rel="{{- with .Get "linkRel" }}{{ . | plainify }}{{ else }}{{ if eq $target "_blank" }}noreferrer{{ end }}{{ end -}}">{{ default . (($.Get "linkTxt") | markdownify) }}</a></p>
            {{- end -}}
        </figcaption>
    {{- end -}}
</figure>

The accompanying SCSS is fairly minimal, the img rule will scale the image to fit the page or native dimension of the image (whichever is smaller).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
img {
  max-width: 100%;
  height: auto;
}

// {{< figure >}}
figure {
  margin-left: 0;
  margin-right: 0;

  img {
    border: 1px solid var(--md-bg-color-dark);
    padding: 5px;

    &:hover {
      box-shadow: 0 0 2px 1px rgba(var(--line-color), 0.5);
    }
  }
}

// Required for responsive images with lazyload
img[data-sizes="auto"] {
  display: block;
  width: 100%;
}

figcaption {
  display: flex;
  flex-wrap: wrap;
  font-weight: 300;
  font-size: $font-size-small;
  color: var(--font-color-light);

  > :first-child {
    padding-left: 0;
  }

  p {
    margin-top: 0;
    padding-left: 5px;
    display: inline-block;
    line-height: 1em !important;
  }

  h4 {
    margin-top: 0 !important;
    font-weight: bolder !important;
    display: inline-block !important;
    margin-bottom: 1rem !important;
  }
}

// ======================= Triggers

.modalImageTrigger,
.imageGalleryTrigger {
  cursor: zoom-in;
  transition: 0.3s;

  &:hover {
    opacity: 0.7;
  }
}

.imageGalleryTrigger:hover {
  box-shadow: unset !important;
}

.noHoverImgTrigger {
  &:hover {
    opacity: 1.0 !important;
    box-shadow: unset !important;
  }
}

Don’t forget to load lazysizes at the bottom of your page, a scratch variable was added in the figure shortcode to determine if loading the javascript library(s) is required. For example in this theme before closing the </body> tag i have the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{{/*  When the page contains images.
      Note: will NOT work for default markdown images since we cannot add a scratch for them... not allowed in a goldmark context.  */}}
{{/*  Hugo server does not take scratch properly into account.  */}}
{{ if or (.Scratch.Get "hasImages") $hugo.IsServer }}
{{ $thirdPartyLazysizesJS := resources.Get "js/thirdParty/lazysizes.min.js" }}
{{ $thirdPartyPhotoSwipeJS := resources.Get "js/thirdParty/photoswipe.js" }}
{{ $thirdPartyPhotoSwipeUIJS := resources.Get "js/thirdParty/photoswipe-ui-default.js" }}
{{ $gliderJS := resources.Get "js/thirdParty/glider.min.js" }}
{{ $imageSliderJS := resources.Get "js/content/image-slider.js" }}
{{ $imageGalleryJS := resources.Get "js/content/image-gallery.js" }}
{{ $imageModalJS := resources.Get "js/content/image-modal.js" }}
{{ if $hugo.IsServer }}
<script src="{{ $thirdPartyLazysizesJS.Permalink }}"></script>
<script src="{{ $thirdPartyPhotoSwipeJS.Permalink }}"></script>
<script src="{{ $thirdPartyPhotoSwipeUIJS.Permalink }}"></script>
<script src="{{ $gliderJS.Permalink }}"></script>
<script src="{{ $imageSliderJS.Permalink }}"></script>
<script src="{{ $imageGalleryJS.Permalink }}"></script>
<script src="{{ $imageModalJS.Permalink }}"></script>
{{ else }}
{{ with slice $thirdPartyLazysizesJS $thirdPartyPhotoSwipeJS $thirdPartyPhotoSwipeUIJS $gliderJS $imageSliderJS $imageGalleryJS $imageModalJS | resources.Concat "js/images.js" | minify | fingerprint }}
<script src="{{ .Permalink }}" integrity="{{ .Data.Integrity }}"></script>
{{ end }}
{{ end }}
In server builds all files are added individually to easy debugging but in a production builds everything is combined and minified in a single file to avoid multiple requests. The script files are only included if the page contains at least one image, this to improve the page’s performance if there are no images (many of these scripts execute code when the document is loaded).

Sometimes to save space (or avoid distracting the reader) you might want to link to an image but not show the image itself. To make this clear to the user the an image an icon is added ot the link. When the link is clicked the image will be shown modal (see other section).

Usage

Image links support both page resources and external links. Roughly the same parameters as our figure shortcode are supported, irrelevant parameters like resizing or disabling modal image are left out. In addition the following options were added.

option   effect   optionaldefault
texttext of the link in the contentyes

This is an example on how it {{< figure-link text="looks like" src="ganttExample.jpg" caption="Example image" >}}
This is an example on how it looks like .

Code

The shortcode only adds an fake “link” element in the content with all required information stored in its attributes, the actual functionality of opening the image modally is implemented in Javascript (see other section).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{{ .Page.Scratch.Add "hasImages" "true" }}
{{/* Scratch do conditionally include css/js files. */}}
{{- $img := .Get "src" }}
{{- $isRemote := strings.HasPrefix $img "http" -}}
{{- if not $isRemote -}}
{{- $img := .Page.Resources.GetMatch $img -}}
{{- if and (not $img) .Page.File -}}
{{- $path := path.Join .Page.File.Dir $img -}}
{{- $img = resources.Get $path | .Permalink -}}
{{- end -}}
{{- end -}}
<span class="modalImageTxtTrigger imageLink" 
      src="{{ $img }}" 
      alt="{{ with .Get "alt" }}{{ . }}{{ else }}{{ .Get "caption" | markdownify | plainify }}{{ end }}"
      {{- with .Get "title" }} imgTitle="{{ . | plainify }}"{{ end -}}
      {{- with .Get "caption" }} imgCaption="{{ . | markdownify | plainify }}"{{ end -}}
      {{- with .Get "attr" }} attr="{{ . | markdownify | plainify }}"{{ end -}}
      {{- with .Get "attrlink" }} attrlink="{{ . | plainify }}"{{ end -}}
      {{- with .Get "linkTxt" }} linkTxt="{{ . | markdownify | plainify }}"{{ end -}}
      {{- if (.Get "link") }}
      {{- $target := (.Get "linkTarget") | default "_blank" -}}
      linkhref="{{ .Get "link" }}"
      {{- with .Get "linkTxt" }} linkTxt="{{ . | markdownify | plainify }}"{{ end -}}
      linktarget="{{ $target }}"
      linkrel="{{- with .Get "linkRel" }}{{ . | plainify }}{{ else }}{{ if eq $target "_blank" }}noreferrer{{ end }}{{ end -}}"         
      {{ end -}}
      >{{(.Get "text") | markdownify }}{{ partial "inline-svg" .Site.Params.content.imageLinkIcon }}</span>

Accompanying SCSS

1
2
3
4
5
6
7
8
9
// Same look as an ordinary link
.imageLink {
    color: var(--primary-color);
    cursor: pointer;
  
    &:hover {
      text-decoration: underline;
    }
  }

Image gallery

Image gallaries are ideal when you want to show many images that belong together. The concept is also known as carousel, slider, slideshow, … The alternative is to use a image grid, they typically take up more space though especially when using many images (+6).

Usage

The images sources can either be: external, from the Page resources or automatically taken from the a folder in the Page’s resources. The available options:

option   effect   optionaldefault
folderload all images from the specified page resource folderyes
captionsuse the image name as cpation (boolean). Only applicable if folder is setyestrue
titletitle for the galleryyes
captioncaption for the galleryyes
overlaywhether or not the gallery images can be shown modal (boolean)yestrue

If you do not use the folder option images have to be specified manually by using the gallery-image shortcode; supports external links and Page resources.

Examples

{{< gallery caption="An example of many images for which a grid layout would take up too much space." >}}
  {{< gallery-image src="https://mdbootstrap.com/img/Photos/Horizontal/Nature/4-col/img%20(63).jpg" >}}
  {{< gallery-image src="https://mdbootstrap.com/img/Photos/Horizontal/Nature/4-col/img%20(63).jpg" >}}
  {{< gallery-image src="https://mdbootstrap.com/img/Photos/Horizontal/Nature/4-col/img%20(63).jpg" >}}
  {{< gallery-image src="https://mdbootstrap.com/img/Photos/Horizontal/Nature/4-col/img%20(63).jpg" >}}
  ... x23
{{< /gallery >}}

An example of many images for which a grid layout would take up too much space.

{{< gallery folder="exampleGallery" title="Rainbow" caption="example images from a resource folder" />}}
1 red2 orange3 yellow4 green15 green26 blue17 blue28 violet

Rainbow

example images from a resource folder

{{< gallery folder="exampleGallery" captions="false" caption="isabled image captions (open modal)" />}}

Disabled image captions (open modal)

Code

The (fairly lightweight) glider.js library is used to show the image gallery. At the time of writing 1.7.2.

There are 2 shortcodes (`gallery` and `gallery-image`) and a partial. The partial is required because if the gallery is generated from a folder the `gallery` shortcode will add the images (as `gallery-image` shortcode) itself; shortcodes cannot call other shortcodes. Therefore it must be able to refer to `gallery-image` so the actual code for the `gallery-image` shortcode is located in the partial and the `gallery-image` shortcode just calls the partial template with the correct arguments. This might sound more complicated than it actually is.

The gallery shortcode itself is fairly straight forward, add the images, add the controls and a caption. To automatically generate the images from a Page resource folder one can iterate through all the files in the folder and process them, this functionality is support out of the box by Hugo.

Shortcode for the gallery

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{{- .Page.Scratch.Add "hasImages" "true" -}}
<div class="glider-contain">
  <div class="glider {{- if eq "true" (default "true" (.Get "overlay")) }} modalImageGalleryTrigger{{ end -}}">
    {{- if .Get "folder" -}}
    {{- $page := .Page -}}
    {{- $addCaption := eq "true" (default "true" (.Get "captions")) -}}
    {{- $folder := path.Join "content" (path.Join .Page.File.Dir (.Get "folder")) -}}
    {{- range readDir $folder -}}
    {{- if lower .Name | findRE "\\.(apng|ico|cur|jfif|pjpeg|pjp|tif|gif|jpg|jpeg|tiff|png|bmp|webp)" -}}
    {{- $caption := "" -}}
    {{- if $addCaption -}}
    {{ $caption = .Name | replaceRE "\\..*" "" | humanize }}
    {{- end -}}
    {{- partial "gallery-image" (dict "src" (path.Join ($.Get "folder") .Name) "Page" $page "caption" $caption) -}}
    {{- end -}}
    {{- end -}}
    {{- else -}}
    {{- .Inner -}}
    {{- end -}}
  </div>
  {{/*  Glider controls  */}}
  <div class="glider-controls">
    <button role="button" aria-label="Previous" class="glider-prev">{{ partial "inline-svg" "chevron-left" }}</button>
    <div role="tablist" class="dots"></div>
    <button role="button" aria-label="Next" class="glider-next">{{ partial "inline-svg" "chevron-right" }}</button>
  </div>
  {{/*  Gallery caption  */}}
  {{- if or (.Get "title") (.Get "caption") -}}
  <figcaption>
    {{ with (.Get "title") -}}
        <h4>{{ . }}</h4>
    {{- end -}}
    {{ with (.Get "caption") -}}
        <p>{{ . }}</p>
    {{- end -}}
  </figcaption>
  {{- end -}}
</div>

Partial for the gallery image, this is a simplified version of the figure shortcode.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{{- $img := .src -}}
{{- $width := 350 -}}
{{- $isRemote := strings.HasPrefix $img "http" -}}
{{- $originalImg := "" -}}
{{- if not $isRemote -}}
{{- $img = .Page.Resources.GetMatch $img -}}
{{- if and (not $img) .Page.File -}}
{{- $path := path.Join .Page.File.Dir $img -}}
{{- $img = resources.Get $path -}}
{{- end -}}
{{- $originalImg = $img -}}
{{/* Resize the image if applicable */}}
{{- $img = ($img.Resize (print $width "x")).RelPermalink -}}
{{- end -}}
<img data-src="{{ $img }}"
     {{- if or .alt .caption }}
     alt="{{ with .alt }}{{ . }}{{ else }}{{ .caption | markdownify | plainify }}{{ end }}"
     {{- else -}}
     alt=""
     {{- end -}}
     draggable="false" ondragstart="return false;"
     class="lazyload imageGalleryTrigger"
     {{- with .title }} imgTitle="{{ . | plainify }}"{{ end -}}
     {{- with .caption }} imgCaption="{{ . | markdownify | plainify }}"{{ end -}}
     {{- with .attr }} attr="{{ . | markdownify | plainify }}"{{ end -}}
     {{- with .attrlink }} attrlink="{{ . | plainify }}"{{ end -}}
     {{- if .link }}
     {{- $target := .linkTarget | default "_blank" -}}
     linkhref="{{ .Get "link" }}"
     {{- with .linkTxt }} linkTxt="{{ . | markdownify | plainify }}"{{ end -}}
     linktarget="{{ $target }}"
     linkrel="{{- with .linkRel }}{{ . | plainify }}{{ else }}{{ if eq $target "_blank" }}noreferrer{{ end }}{{ end -}}"         
     {{ end -}}
     {{/* Link the high res version of the image if applicable. */}}
     {{- if (not $isRemote) }}
      hsrc="{{ $originalImg.RelPermalink }}"
      hwidth="{{ $originalImg.Width }}"
      hheight="{{ $originalImg.Height }}"
     {{ end -}}
/>

Shortcode for the gallery image, it simply loads the partial template and passes all parameters2.

1
{{- partial "gallery-image" (dict "src" (.Get "src") "title" (.Get "title") "caption" (.Get "caption") "attr" (.Get "attr") "attrlink" (.Get "attrlink") "linkTxt" (.Get "linkTxt") "link" (.Get "link") "linkRel" (.Get "linkRel") "linkTarget" (.Get "linkTarget") "alt" (.Get "alt") "Page" .Page) -}}

SCSS overrides for the default Glider theme.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
.glider-dot:hover,
.glider-dot:focus,
.glider-dot.active {
  color: var(--font-color);
}

.glider-prev:hover,
.glider-next:hover,
.glider-prev:focus,
.glider-next:focus {
  color: var(--font-color);
}

.glider-prev,
.glider-next {
  color: var(--font-color-muted);
  top: unset;
  bottom: 0;
  position: unset;
  left: 0;
  margin-top: 5px;
}

.glider-next.disabled,
.glider-prev.disabled {
  color: inherit;
}

.glider-controls {
  display: flex;
  flex-direction: row;
}

.glider-dot:hover,
.glider-dot:focus,
.glider-dot.active {
  background: var(--font-color);
}

The Javascript required to load the Glider library is straight forward, pass the settings and appropriate HTML elements to Glider and you’re done.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
window.addEventListener("load", function () {
    const gliderSettings = {
        // Mobile first
        slidesToShow: 1,
        slidesToScroll: 1,
        draggable: true,
        duration: 0.25, // Lower - better performance
        dots: '#dots',
        arrows: {
            prev: '#glider-prev',
            next: '#glider-next'
        },
        responsive: [
            {
                // screens greater than >= XXX px
                breakpoint: 800,
                settings: {
                    // Set to `auto` and provide item width to adjust to viewport
                    slidesToShow: 'auto',
                    slidesToScroll: 1,
                    itemWidth: 150
                }
            }, {
                breakpoint: 1200,
                settings: {
                    slidesToShow: 'auto',
                    slidesToScroll: 1,
                    itemWidth: 350 // This value should match the value in the gallery-img shortcode
                }
            }
        ]
    };
    document.querySelectorAll('.glider').forEach(g => {
        // Patch the elements for this glider
        const container = g.parentElement.children[1].children;
        gliderSettings.arrows.prev = container[0];
        gliderSettings.dots = container[1];
        gliderSettings.arrows.next = container[2];

        new Glider(g, gliderSettings);
    });
});

Do not forget to load Glider’s Javascript and CSS.

Further improvements

Support for custom image captions was left out and could should be added. This can be done in the gallery-image shortcode, no need to change/write Javascript code.

Image grid

Image grids can be used when one wants to show a few images that belong together and small thumbnails suffice. However if you want to show many images i would recommend using an image gallery instead (especially for mobile users).

An advantage of image grids, they are very simple to implement and can be done with just CSS alone. Of course there are many Javascript libraries available if you want more features; or you can create your own. We go for a full CSS version however.

Our version is quite simple, more feature rich version of a Hugo image grid can be found here liwenyip/hugo-easy-gallery/ (unmaintained) or here mxmehl/hugo-snap-gallery.

Usage

The usage and capabilities of the gallery and gallery-grid are identical, see section. Although here we do support captions per grid-image.

Example

{{< gallery-grid >}}
  {{< grid-image src="board.jpg" caption="Raspberry Pi" >}}
  {{< grid-image src="board.jpg" >}}
  {{< grid-image src="board.jpg" >}}
  {{< grid-image src="https://mdbootstrap.com/img/Photos/Horizontal/Nature/4-col/img%20(63).jpg" caption="Nature" >}}
  {{< grid-image src="https://mdbootstrap.com/img/Photos/Horizontal/Nature/4-col/img%20(63).jpg" >}}
  {{< grid-image src="https://mdbootstrap.com/img/Photos/Horizontal/Nature/4-col/img%20(63).jpg" >}}
{{< /gallery-grid >}}

Code

The gallery and gallery-grid shortcodes are nearly identical, only the HTML of gallery-image and grid-image differ a little bit.

Shortcode for the gallery-grid

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
{{- .Page.Scratch.Add "hasImages" "true" -}}
<div>
  <div class="gallery-grid{{ if eq "true" (default "true" (.Get "overlay")) }} modalImageGalleryTrigger{{ end }} hover-effect-{{ with .Get "hover-effect" | default "grow" }}{{.}}{{end}}">
      {{- if .Get "folder" -}}
      {{- $page := .Page -}}
      {{- $addCaption := eq "true" (default "true" (.Get "captions")) -}}
      {{- $folder := path.Join "content" (path.Join .Page.File.Dir (.Get "folder")) -}}
      {{- range readDir $folder -}}
      {{- if lower .Name | findRE "\\.(apng|ico|cur|jfif|pjpeg|pjp|tif|gif|jpg|jpeg|tiff|png|bmp|webp)" -}}
      {{- $caption := "" -}}
      {{- if $addCaption -}}
      {{ $caption = .Name | replaceRE "\\..*" "" | humanize }}
      {{- end -}}
      {{- partial "grid-image" (dict "src" (path.Join ($.Get "folder") .Name) "Page" $page "caption" $caption) -}}
      {{- end -}}
      {{- end -}}
      {{- else -}}
      {{- .Inner -}}
      {{- end -}}
  </div>
  {{/*  Gallery caption  */}}
  {{- if or (.Get "link") (or (.Get "attrlink") (or (.Get "attr") (or (.Get "title") (.Get "caption")))) -}}
        <figcaption>
            {{- with (.Get "title") -}}
                <h4>{{ . }}</h4>
            {{- end -}}
            {{- with (.Get "caption") -}}
                <p>{{ . | markdownify }}</p>
            {{- end -}}
            {{- if and (.Get "attr") (not (.Get "attrlink")) -}}
                <p>Source: {{ (.Get "attr") | markdownify }}</p>
            {{- end -}}
            {{- with (.Get "attrlink") -}}
                <p>Source: <a href="{{ . }}" target="_blank" rel="noreferrer">{{ default . (($.Get "attr") | markdownify) }}</a></p>
            {{- end -}}
            {{- if (.Get "link") -}}
                {{- $target :=  (.Get "linkTarget") | default "_blank" -}}
                <p>Link: <a href="{{ .Get "link" }}" target="{{ $target }}" rel="{{- with .Get "linkRel" }}{{ . | plainify }}{{ else }}{{ if eq $target "_blank" }}noreferrer{{ end }}{{ end -}}">{{ default . (($.Get "linkTxt") | markdownify) }}</a></p>
            {{- end -}}
        </figcaption>
    {{- end -}}
</div>

Partial for the grid image.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
{{ .Page.Scratch.Add "hasImages" "true" }}
{{/* Get the image */}}
{{- $img := .src -}}
{{- $width := 350 -}}
{{- $isRemote := strings.HasPrefix $img "http" -}}
{{- $originalImg := "" -}}
{{- if not $isRemote -}}
{{- $img = .Page.Resources.GetMatch $img -}}
{{- if and (not $img) .Page.File -}}
{{- $path := path.Join .Page.File.Dir $img -}}
{{- $img = resources.Get $path -}}
{{- end -}}
{{- $originalImg = $img -}}
{{/* Resize the image if applicable */}}
{{- $img = ($img.Resize (print $width "x")).RelPermalink -}}
{{- end -}}
<div class="box"{{ with $width }} style="max-width:{{ . }}px;"{{ end }}>
  <figure>
    <img data-src="{{ $img }}"
         {{- if or .alt .caption }}
         alt="{{ with .alt }}{{ . }}{{ else }}{{ .caption | markdownify | plainify }}{{ end }}"
         {{- else -}}
         alt=""
         {{- end -}}
         draggable="false" ondragstart="return false;"
         class="lazyload imageGalleryTrigger"
         {{- with .title }} imgTitle="{{ . | plainify }}"{{ end -}}
         {{- with .caption }} imgCaption="{{ . | markdownify | plainify }}"{{ end -}}
         {{- with .attr }} attr="{{ . | markdownify | plainify }}"{{ end -}}
         {{- with .attrlink }} attrlink="{{ . | plainify }}"{{ end -}}
         {{- if .link }}
          {{- $target := .linkTarget | default "_blank" -}}
          linkhref="{{ .Get "link" }}"
          {{- with .linkTxt }} linkTxt="{{ . | markdownify | plainify }}"{{ end -}}
          linktarget="{{ $target }}"
          linkrel="{{- with .linkRel }}{{ . | plainify }}{{ else }}{{ if eq $target "_blank" }}noreferrer{{ end }}{{ end -}}"         
         {{ end -}}
         {{/* Link the high res version of the image if applicable. */}}
         {{- if (not $isRemote) }}
          hsrc="{{ $originalImg.RelPermalink }}"
          hwidth="{{ $originalImg.Width }}"
          hheight="{{ $originalImg.Height }}"
         {{ end -}}
     />
    {{- if or .title .caption -}}
    <figcaption>
        {{- with .title -}}
        <h4>{{ . }}</h4>
        {{- end -}}
        {{- with .caption -}}
        <p>{{ . }}</p>
        {{- end -}}
    </figcaption>
    {{- end -}}
  </figure>
</div>

Shortcode for the grid image.

1
{{- partial "grid-image" (dict "src" (.Get "src") "title" (.Get "title") "caption" (.Get "caption") "attr" (.Get "attr") "attrlink" (.Get "attrlink") "linkTxt" (.Get "linkTxt") "link" (.Get "link") "linkRel" (.Get "linkRel") "linkTarget" (.Get "linkTarget") "alt" (.Get "alt") "Page" .Page) -}}

Accompanying SCSS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// {{< gallery-grid >}}
.gallery-grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;
  align-items: stretch;

  figure {
    position: relative;
    margin: 0;
    overflow: hidden;
  }

  img {
    max-width: 100%;
    height: auto;
    display: block;
    transition: transform .2s ease-in-out;
  }

  .box {
    display: inline-block;
    margin: 10px;

    figure figcaption {
      margin: auto;
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      margin: 5px; // Same as the border of the img
      margin-top: 0px;
      color: white;
      text-align: center;
      font-size: $font-size-small;
      background: rgba(0, 0, 0, 0.5);
      opacity: 0;
      display: block;
      flex-wrap: unset;
    }

    &:hover {
      figure figcaption {
        opacity: 1;
      }
    }
  }
}

@media only screen and (min-width : $breakpoint-sm) {
  .gallery-grid .box {
    width: calc(100% / 2 - 20px); // Important to make them a bit smaller to reserve space for margins ect
  }
}

@media screen and (min-width: $breakpoint-md) {
  .gallery-grid .box {
    width: calc(100% / 3 - 20px);
  }
}

@media screen and (min-width: $breakpoint-lg) {
  .gallery-grid .box {
    width: calc(100% / 4 - 20px);
  }
}

@media only screen and (min-width : $breakpoint-xl) {
  .gallery-grid .box {
    width: calc(100% / 5 - 20px);
  }
}

// Hover effect
.gallery-grid.hover-effect-grow .box:hover img {
  transform: scale(1.05);
}

.gallery-grid.hover-effect-shrink .box:hover img {
  transform: scale(0.95);
}

.gallery-grid.hover-effect-slidedown .box:hover {
  transform: translateY(5px);
}

.gallery-grid.hover-effect-slideup .box:hover {
  transform: translateY(-5px);
}

further improvements

Images in the grid are not stretched to be the same size if they have a different aspect ratio which visually is not ideal. This can be corrected by changing the CSS rules.

Image slider

This section is a (heavily) modified version of the image comparison tutorial, all credit goes to www.w3schools.com.

If you want to compare 2 images an image slider is pretty neat and fortunately easy to make.

Usage

The shortcode is fairly simple, there are not many options available. Works for both external links and Page resources and will auto scale the slider to the page or image (whichever is smaller).

option   effect   optionaldefault
src1source of the first image (background)no
src2source of the second image (overlay)no
titletitle for the slideryes
captioncaption for the galleryyes
heightlimit the height (in px)yes
widthlimit the width (in px)yes
overlaywhether or not the images can be shown modal (boolean)yestrue

Examples

{{< image-slider src1="https://www.w3schools.com/howto/img_snow.jpg" height="200"
                   src2="https://www.w3schools.com/howto/img_forest.jpg" >}}

{{< image-slider src1="exampleSlider/hugo.png" 
                 src2="exampleSlider/hugo_pixelated.png" 
                 caption="Pixelated versus normal logo" >}}

Pixelated versus normal logo

Code

The shortcode is a slimmed down version of the figure shortcode. The figure caption is simplified and support for responsive images is removed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
{{ .Page.Scratch.Add "hasImages" "true" }}
{{- $width := .Get "width" -}}
{{- $height := .Get "height" -}}
{{/* First image */}}
{{- $img := .Get "src1" -}}
{{- $isRemote := strings.HasPrefix $img "http" -}}
{{- $originalImg := "" -}}
{{- $isResized := false -}}
{{- if not $isRemote -}}
{{- $img = .Page.Resources.GetMatch $img -}}
{{- if and (not $img) .Page.File -}}
{{ $path := path.Join .Page.File.Dir $img }}
{{- $img = resources.Get $path -}}
{{- end -}}
{{- $originalImg = $img -}}
{{- if or $width $height }}
{{- $isResized = true -}}
{{- if and $width $height -}}
{{- $img = ($img.Resize (print $width "x" $height)).RelPermalink -}}
{{- else if $width -}}
{{- $img = ($img.Resize (print $width "x")).RelPermalink -}}
{{- else if $height -}}
{{- $img = ($img.Resize (print "x" $height)).RelPermalink -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Second image */}}
{{- $img2 := .Get "src2" -}}
{{- $isRemote2 := strings.HasPrefix $img2 "http" -}}
{{- $originalImg2 := "" -}}
{{- $isResized2 := false -}}
{{- if not $isRemote2 -}}
{{- $img2 = .Page.Resources.GetMatch $img2 -}}
{{- if and (not $img2) .Page.File -}}
{{ $path := path.Join .Page.File.Dir $img2 }}
{{- $img2 = resources.Get $path -}}
{{- end -}}
{{- $originalImg2 = $img2 -}}
{{- if or $width $height }}
{{- $isResized2 = true -}}
{{- if and $width $height -}}
{{- $img2 = ($img2.Resize (print $width "x" $height)).RelPermalink -}}
{{- else if $width -}}
{{- $img2 = ($img2.Resize (print $width "x")).RelPermalink -}}
{{- else if $height -}}
{{- $img2 = ($img2.Resize (print "x" $height)).RelPermalink -}}
{{- end -}}
{{- end -}}
{{- end -}}
<div class="img-comp-container">
    <img class="lazyload img-comp-img img-comp-background"
         alt=""
         data-src="{{ $img }}"
         draggable="false" ondragstart="return false;"
         {{- with $width }} width="{{ . }}px" {{ end -}}
         {{- with $height }} height="{{ . }}px" {{ end -}}
         {{- if and $height (not $width) }} style="height: {{ $height }}px;"{{ end -}}
         {{- if and (not $isRemote) $isResized }}
         hsrc="{{ $originalImg.RelPermalink }}"
         hwidth="{{ $originalImg.Width }}"
         hheight="{{ $originalImg.Height }}"
         {{ end -}} />
    <div class="img-comp-img">
        <img class="lazyload img-comp-overlay"
             alt=""
             data-src="{{ $img2 }}"
             draggable="false" ondragstart="return false;"
             {{- with $width }} width="{{ . }}px" {{ end -}}
             {{- with $height }} height="{{ . }}px" {{ end -}}
             {{- if and $height (not $width) }} style="height: {{ $height }}px;"{{ end -}}
             {{- if and (not $isRemote2) $isResized2 }}
             hsrc="{{ $originalImg2.RelPermalink }}"
             hwidth="{{ $originalImg2.Width }}"
             hheight="{{ $originalImg2.Height }}"
             {{ end -}} />
    </div>
    <div class="img-comp-controls">{{- if eq "true" (default "true" (.Get "overlay")) -}}<div class="img-comp-expand" title="Expand">{{ partial "inline-svg" "expand-alt" }}</div>{{- end -}}</div>
</div>
{{- if or (.Get "title") (.Get "caption") -}}
<figcaption>
    {{ with (.Get "title") -}}
    <h4>{{ . }}</h4>
    {{- end -}}
    {{ with (.Get "caption") -}}
    <p>{{ . }}</p>
    {{- end -}}
</figcaption>
{{- end -}}

The Javascript code in an enhanced version of the tutorial. First add a slider element and initialize the dimensions of the control. The basic mechanism is, a static background image and an overlay image which is clipped depending on the slider’s position. When the slider moves the clipped width of the overlay image is adjusted. If the whole control resizes, e.g. by changing the window size, all sizes are recalculated.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class ImageSlider {
    constructor(sliderContainer) {
        this._parent = sliderContainer;
        this._img_overlay = sliderContainer.querySelector(".img-comp-overlay");
        this._img_background = sliderContainer.querySelector(".img-comp-background");
        this._controls = sliderContainer.querySelector(".img-comp-controls"); // Could have been done in CSS

        // Initial sizes
        this._w = this._img_background.offsetWidth;
        this._controls.style.width = this._w + "px";
        this._parent.style.height = this._img_overlay.offsetHeight + "px";
        this._img_overlay.parentNode.style.width = (this._w / 2) + "px";
        this._img_overlay.style.width = this._w + "px";

        // Add slider element
        this._slider = document.createElement("DIV");
        this._slider.setAttribute("class", "img-comp-slider");
        this._slider.style.left = (this._w / 2) - (this._slider.offsetWidth / 2) + "px";
        this._img_overlay.parentNode.parentNode.insertBefore(this._slider, this._img_overlay.parentNode);

        // Wire events
        this._slider.addEventListener("mousedown", e => this._slideReady(e));
        window.addEventListener("mouseup", _ => this._slideFinish());
        this._slider.addEventListener("touchstart", e => this._slideReady(e));
        window.addEventListener("touchstop", _ => this._slideFinish());

        this._slideMoveProxy = event => this._slideMove(event); // Only bound when moving the slider

        // Adjust the size if the page rescales
        new ResizeObserver(elems => { this._sizeChanged(elems[0]); }).observe(this._img_background);
    }

    _slideReady(e) {
        e.preventDefault(); // Prevent any other actions to occur when moving over the image

        window.addEventListener("mousemove", this._slideMoveProxy);
        window.addEventListener("touchmove", this._slideMoveProxy);
    }

    _slideFinish() {
        window.removeEventListener("mousemove", this._slideMoveProxy);
        window.removeEventListener("touchmove", this._slideMoveProxy);
    }

    _slideMove(e) {
        e = e || window.event;

        // Get cursor position
        const img_x = this._img_overlay.getBoundingClientRect();
        let pos = e.pageX - img_x.left; // Cursor x coordinate relative to the img
        pos = pos - window.pageXOffset; // Take page scrolling into account
        pos = Math.max(0, Math.min(pos, this._w)); // Prevent the slide go outside of the image

        // Slide the image
        this._img_overlay.parentNode.style.width = pos + "px";
        this._slider.style.left = this._img_overlay.parentNode.offsetWidth - (this._slider.offsetWidth / 2) + "px";
    }

    _sizeChanged(elem) {
        const perc = this._img_overlay.parentNode.offsetWidth / this._w;
        const dimmensions = this._img_background.getBoundingClientRect();

        this._w = dimmensions.width;
        this._controls.style.width = dimmensions.width + "px";
        this._parent.style.height = dimmensions.height + "px";
        this._img_overlay.style.height = dimmensions.height + "px";
        this._img_overlay.style.width = dimmensions.width + "px";

        this._img_overlay.parentNode.style.width = (perc * this._w) + "px";
        this._slider.style.left = this._img_overlay.parentNode.offsetWidth - (this._slider.offsetWidth / 2) + "px";
    }
}

window.addEventListener("load", function () {
    document.querySelectorAll(".img-comp-container").forEach(i => {
        new ImageSlider(i);
    });
});

Accompanying SCSS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
img {
  max-width: 100%;
  height: auto;
}

// {{< image-slider >}}
.img-comp-container {
  position: relative;
  margin-bottom: 20px;
}

.img-comp-img {
  position: absolute;
  width: auto;
  height: auto;
  overflow: hidden;
}

.img-comp-overlay {
  padding: 6px;
  max-width: unset !important;
}

.img-comp-background {
  border: 1px solid var(--md-bg-color-dark);
  padding: 5px;
}

.img-comp-slider {
  position: absolute;
  z-index: 9;
  cursor: ew-resize;
  width: 20px;
  height: 20px;
  bottom: -10px;
  background-color: var(--font-color);
  opacity: 0.9;
  border-radius: 50%;
}

.img-comp-controls {
  display: flex;
  justify-content: flex-end;
  padding-top: 10px;
  padding-right: 10px;
}

.img-comp-expand {
  width: 25px;
  height: 25px;
  font-size: 1.25em;
  cursor: zoom-in;
  background-color: rgba(255, 255, 255, 0.5);
  border-radius: $border-radius;
  z-index: 10;
  text-align: center;

  &:hover {
    opacity: 0.7;
  }
}

further improvements

It can be further improved, for example when showing the image modally let the user toggle/choose between showing the images individually (as is currently implemented at the time of writing) or show the slider itself in full screen (or the image’s native resolution).

Modal image

To allow the user to see images in high detail, and save space by showing smaller version in the content, a modal image viewer is nice to have. There are plenty Javascript libraries available or you can make your own version if desired. If you do not want many features a pure CSS modal image viewer will do just fine. I went for an existing Javascript library however to save time and have many features.

Usage

Showing an image modally is entirely handled by Javascript. Except when explicitly disabled all figures on this page should be able to expand modally.

Code

The (fairly heavy) PhotoSwipe library is used to show the image modally. At the time of writing 4.1.3.

There is no particular reason as to why this particular library was picked over others, it supports all features i desired, is robust and has good mobile support; ticks all boxes. Unfortunately it is rather big and requires quite a bit of CSS and additional Javascript.

To use the library one has to create a UI for it in HTML, there is a default UI available. You have to provide the trigger elements to open PhotoSwipe yourself, in our case implemented by the various shortcodes mentioned in this post. The triggers are wired to open the PhotoSwipe library through Javascript. PhotoSwipe has some quirks, the image with and height has to be known before the library is initialized. This is quit annoying if the image is not already loaded before opening it modally. However it will load images lazily and supports initial thumbnail placeholders.

The Javascript first gets the overlay element and configures the settings for PhotoSwipe which will also generate the HTML for the modal image caption. Next, for all element which should trigger modal image(s), wire up the onclick event to create a new PhotoSwipe instance. To pass the image information to PhotoSwipe the attributes of the HTML element are converted to the desired format. Passing the image dimensions for images from the Page resources is not a problem, the dimensions are added as attributes in HTML by Hugo. For external images the dimensions are taken from the already loaded image element itself. Only for the image link shortcode (with external links) the image dimensions are unknown. Therefore those images they have to be pre-fetched before opening PhotoSwipe so we can pass along the image dimensions. PhotoSwipe ironically supports lazy loading images. Other solutions to get the image dimensions exist but this solution works well, is easy to implement and has a low overhead.
Some shortcodes downscale images, to pass the high resolution version to PhotoSwipe the high resolution source is used instead (hsrc attribute). The downscaled src image is used as preview image until PhotoSwipe has lazy loaded the high resolution version.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
function getImgDetails(img) {
    // High definition source
    var hsrc = img.getAttribute("hsrc");
    return {
        src: hsrc ? hsrc : img.src,
        msrc: hsrc ? img.src : "",
        w: hsrc ? img.getAttribute("hwidth") : img.naturalWidth,
        h: hsrc ? img.getAttribute("hheight") : img.naturalHeight,
    };
}

function getImgElementDetails(element) {
    return {
        title: "0", // Dummy value; required otherwise PhotoGallery hides the caption. One could leave this empty if no meta-data is set so the caption bar is hidden. 
        heading: element.getAttribute("imgTitle"),
        caption: element.getAttribute("imgCaption"),
        attr: element.getAttribute("attr"),
        attrLink: element.getAttribute("attrlink"),
        linkTxt: element.getAttribute("linkTxt"),
        link: element.getAttribute("linkhref"),
        linktarget: element.getAttribute("linktarget"),
        linkrel: element.getAttribute("linkrel"),
    };
}

window.addEventListener("load", function () {
    const pswpElement = document.getElementById('pswp');
    const pswpSettings = {
        // Core
        index: 0,
        bgOpacity: 0.8,
        history: false,
        hideAnimationDuration: 0, // For better performance
        showAnimationDuration: 0,

        // UI
        indexIndicatorSep: ' | ',
        shareButtons: [{ id: 'download', label: 'Download image', url: '{{raw_image_url}}', download: true }],
        addCaptionHTMLFn: function (item, captionEl, isFake) {
            let htmlString = '';
            if (item.heading) {
                htmlString += '<strong>' + item.heading + '</strong>';
            }
            if (item.caption) {
                htmlString += ((htmlString.length === 0) ? '' : ' ') + '<small>' + item.caption + '</small>';
            }
            if (item.attr) {
                if (item.attrLink) {
                    htmlString += '<br/><small>Source: <a href="' + item.attrLink + '" target="_blank">' + item.attr + '</a></small>';
                } else {
                    htmlString += '<br/><small>Source: ' + item.attr + '</small>';
                }
            }
            if (item.link) {
                const href = 'href="' + item.link + '"';
                const target = ' target="' + item.linktarget + '"';
                const rel = item.linkrel ? ' rel="' + item.linkrel + '"' : '';
                htmlString += '<br/><small>Link: <a ' + href + target + rel + '>' + (item.linkTxt ? item.linkTxt : item.link) + '</a></small>';
            }

            captionEl.children[0].innerHTML = htmlString;
            return htmlString.length !== 0; // Whether or not there is a caption
        }
    };

    // Figure
    // ------
    document.querySelectorAll(".modalImageTrigger").forEach(i => {
        i.onclick = () => {
            // Images are guaranteed to be loaded at this point
            new PhotoSwipe(pswpElement, PhotoSwipeUI_Default, [{ ...getImgDetails(i), ...getImgElementDetails(i) }], pswpSettings).init();
        };
    });

    // Image link
    // ----------
    document.querySelectorAll(".modalImageTxtTrigger").forEach(i => {
        i.onclick = () => {
            // The image is, probably, not loaded yet.
            // That is unfortunate given https://github.com/dimsemenov/PhotoSwipe/issues/741,
            // this results in PhotoSwipe not lazy loading the img and thus it might be slower to start.
            var imgSrc = new Image();
            imgSrc.onload = () => {
                new PhotoSwipe(pswpElement, PhotoSwipeUI_Default, [{ ...getImgDetails(imgSrc), ...getImgElementDetails(i) }], pswpSettings).init()
            };
            imgSrc.src = i.getAttribute("src");
        };
    });

    // Image slider
    // ------------
    document.querySelectorAll(".img-comp-expand").forEach(s => {
        s.onclick = () => {
            // The images are guaranteed to be loaded
            const imgs = s.parentNode.parentNode.querySelectorAll("img");

            // Images are guaranteed to be loaded at this point
            new PhotoSwipe(pswpElement, PhotoSwipeUI_Default, [{ ...getImgDetails(imgs[0]), ...getImgElementDetails(imgs[0]) }, { ...getImgDetails(imgs[1]), ...getImgElementDetails(imgs[1]) }], pswpSettings).init();
        };
    });

    // Gallery (grid and glider)
    // -------
    document.querySelectorAll(".modalImageGalleryTrigger").forEach(g => {
        const imqs = g.querySelectorAll("img");

        const images = [];
        imqs.forEach(i =>
            // Images are guaranteed to be loaded at this point (glider/grid does not lazy load by default)
            images.push({ ...getImgDetails(i), ...getImgElementDetails(i) })
        );

        imqs.forEach((i, index) =>
            i.onclick = () => {                
                pswpSettings.index = index;
                new PhotoSwipe(pswpElement, PhotoSwipeUI_Default, images, pswpSettings).init();
                pswpSettings.index = 0; // Since Javascript has no out of the box deep copy we just reset it afterwards
            }
        );
    });
});

For the overlay UI we use the default UI provided by PhotoSwipe itself.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<div id="pswp" class="pswp" tabindex="-1" role="dialog" aria-hidden="true">
  <div class="pswp__bg"></div>
  <div class="pswp__scroll-wrap">
    <!-- Container that holds slides.
         PhotoSwipe keeps only 3 of them in DOM to save memory.
         Don't modify these 3 pswp__item elements, data is added later on. -->
    <div class="pswp__container">
      <div class="pswp__item"></div>
      <div class="pswp__item"></div>
      <div class="pswp__item"></div>
    </div>
    <div class="pswp__ui pswp__ui--hidden">
      <div class="pswp__top-bar">
        <div class="pswp__counter"></div>
        <button class="pswp__button pswp__button--close" title="Close (Esc)"></button>
        <button class="pswp__button pswp__button--share" title="Actions"></button>
        <button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button>
        <button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button>
        <div class="pswp__preloader">
          <div class="pswp__preloader__icn">
            <div class="pswp__preloader__cut">
              <div class="pswp__preloader__donut"></div>
            </div>
          </div>
        </div>
      </div>
      <div class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
        <div class="pswp__share-tooltip"></div>
      </div>
      <button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)">
      </button>
      <button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)">
      </button>
      <div class="pswp__caption">
        <div class="pswp__caption__center"></div>
      </div>
    </div>
  </div>
</div>

Only minor modification were made in SCSS compared to the default.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
.pswp__ui--fit .pswp__top-bar,
.pswp__ui--fit .pswp__caption {
  background-color: rgba(44, 44, 44, 0.8);
}

.pswp__caption__center {
  font-size: $font-size;

  small {
    font-size: $font-size-small;
  }

  a {
    color: #BBB;
  }
}

In addition you should also load PhotoSwipe’s, Javascript, (S)CSS and images.

further improvements

When the image resolution is higher than the screen resolution we allow the user to zoom (and pan). It might be useful to always allow to zoom regardless of the original image size. This is in principle supported by PhotoSwipe.


If you do not want to load a fairly heavy Javascript library (+ accompanying CSS) like PhotoSwipe just to show an image modally you can use this really lightweight solution instead. It does not have a gallery feature however.

This section is a modified version of the modal image tutorial, all credit goes to www.w3schools.com.

The code is not listed here but the files are included, it is rather self explanatory.



  1. lazyload will only fetch images if they are/will become visible, it saves bandwidth in case the user does not go through the whole page and makes the page initially faster to load. ↩︎

  2. in Hugo shortcodes and partial templates have a different way of passing parameters so we have to do a conversion. ↩︎

Noticed an error in this post? Corrections are appreciated.

© Nelis Oostens