Skip to content
aviral gupta

// B6.5 · ~38 min · Beginner

Build: an image gallery

You build a gallery from an array of photos: render() draws it, one listener on the thumbnails selects a photo, and buttons and arrow keys move through it and wrap around.

Lesson 5 of 5 in B6 The DOM and events

End of the module

You will be able to

  • Build the gallery from a data array with render(): thumbnails, the main image and its caption
  • Select a photo with one delegated listener and mark the current thumbnail with a class and aria-current
  • Move through the photos with Previous, Next and the arrow keys, wrapping around with %
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B6.4. The click lands on the icon inside the button. What does this print?

    const bar = document.querySelector('#bar');
    bar.addEventListener('click', (event) => {
      const button = event.target.closest('button');
      console.log(event.target.id, button.id, event.currentTarget.id);
    });
    document.querySelector('#icon').click();
  2. Predict · Activity 2 of 7

    Predict before you read on. The gallery shows the last of three photos, and the user presses Next. What does this print?

    const photos = ['fox', 'owl', 'heron'];
    let current = 2;
    current = (current + 1) % photos.length;
    console.log(photos[current]);
  3. Practice · Activity 3 of 7

    render() may run many times. Fill in the method that puts the new items in and takes the old ones out, so the list never grows twice.

    thumbs.____(...items);
    thumbs.(...items);
  4. Practice · Activity 4 of 7

    Match each part of the gallery to its job.

  5. Practice · Activity 5 of 7

    What does this print?

    const thumbs = document.querySelector('#thumbs');
    const photos = ['fox', 'owl', 'heron'];
    let current = 1;
    const items = photos.map((name, index) => {
      const button = document.createElement('button');
      button.textContent = name;
      if (index === current) button.setAttribute('aria-current', 'true');
      return button;
    });
    thumbs.replaceChildren(...items);
    console.log(items.map((b) => String(b.getAttribute('aria-current'))).join(' '));
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The thumbnail is <button data-index="1"><img id="owl" …></button>, and the click lands on the image. What does this print?

    const photos = ['fox', 'owl', 'heron'];
    const thumbs = document.querySelector('#thumbs');
    thumbs.addEventListener('click', (event) => {
      const index = Number(event.target.dataset.index);
      console.log(index, photos[index]);
    });
    document.querySelector('#owl').click();
  7. Apply · Activity 7 of 7

    Mini-task: extend your gallery. Add a paragraph #position that shows "Photo 2 of 3" for the current photo; update it in render(), so every way of moving keeps it right. Make the Home key show the first photo and the End key the last, in the same keydown listener as the arrow keys, through show(). Check by hand that ArrowRight after End wraps to the first photo. Try it through a local web server.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The gallery, start to finish

The page has an empty main image and caption, Previous and Next buttons and an empty list. main.js holds three photos as data and builds everything with render(). One listener on the list selects a photo, show() wraps the index around, and the buttons and arrow keys call show(). At the end the script clicks the third thumbnail's image, then Next, then presses ArrowLeft, the way the exercise tests do. The pictures are small SVG drawings inside data: URLs, made by picture(), so there are no image files to load.

main.js

// Each picture is a small SVG drawing inside a data: URL: no image files to load.
const picture = (colour, label) =>
  'data:image/svg+xml,' +
  encodeURIComponent(
    '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120">' +
      '<rect width="160" height="120" fill="' + colour + '"/>' +
      '<text x="80" y="66" font-size="20" text-anchor="middle" fill="white">' + label + '</text></svg>'
  );

const photos = [
  { src: picture('sienna', 'Fox'), alt: 'A red fox in the snow' },
  { src: picture('slategray', 'Owl'), alt: 'An owl on a branch' },
  { src: picture('steelblue', 'Heron'), alt: 'A heron by the river' }
];
let current = 0;

const thumbs = document.querySelector('#thumbs');
const mainImage = document.querySelector('#main');
const caption = document.querySelector('#caption');

function render() {
  const items = photos.map((photo, index) => {
    const img = document.createElement('img');
    img.setAttribute('src', photo.src);
    img.setAttribute('alt', photo.alt);
    const button = document.createElement('button');
    button.type = 'button';
    button.dataset.index = String(index);
    if (index === current) {
      button.classList.add('selected');
      button.setAttribute('aria-current', 'true');
    }
    button.append(img);
    const li = document.createElement('li');
    li.append(button);
    return li;
  });
  thumbs.replaceChildren(...items);
  mainImage.setAttribute('src', photos[current].src);
  mainImage.setAttribute('alt', photos[current].alt);
  caption.textContent = photos[current].alt;
}

// One listener for all thumbnails, also the ones render() builds later.
thumbs.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button || !thumbs.contains(button)) return;
  current = Number(button.dataset.index);
  render();
});

function show(index) {
  current = (index + photos.length) % photos.length; // wrap around at both ends
  render();
}

document.querySelector('#prev').addEventListener('click', () => show(current - 1));
document.querySelector('#next').addEventListener('click', () => show(current + 1));
document.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowLeft') show(current - 1);
  if (event.key === 'ArrowRight') show(current + 1);
});

render();

// The script tries its own page, the way the exercise tests do.
thumbs.querySelectorAll('img')[2].click();
console.log('thumbnail 3:', caption.textContent);
document.querySelector('#next').click();
console.log('Next:', caption.textContent);
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
console.log('ArrowLeft:', mainImage.getAttribute('alt'));
console.log('current thumbnail:', thumbs.querySelector('[aria-current="true"]').dataset.index);

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Gallery</title>
</head>
<body>
  <h1>Gallery</h1>
  <figure>
    <img id="main" src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22/%3E" alt="">
    <figcaption id="caption"></figcaption>
  </figure>
  <button id="prev" type="button">Previous</button>
  <button id="next" type="button">Next</button>
  <ul id="thumbs"></ul>
  <script type="module" src="main.js"></script>
</body>
</html>

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Output

thumbnail 3: A heron by the river
Next: A red fox in the snow
ArrowLeft: A heron by the river
current thumbnail: 2
<!-- #caption -->
<figcaption id="caption">A heron by the river</figcaption>
  • The click landed on the img; closest('button') found its button, and data-index "2" became the number 2.
  • The snapshot shows the caption, built by render(); the thumbnails' src values are long data: URLs, so the script prints the index of the current thumbnail instead.
  • Next after the heron wrapped around to the fox: (2 + 1) % 3 is 0.
  • ArrowLeft, sent to body, bubbled up to the listener on document, and (0 - 1 + 3) % 3 is 2: the heron again.
  • Exactly one button, the third, has aria-current="true"; render() rebuilt all three.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 3

Render the gallery from the data

Finish render(). Each photo becomes an li holding a button with type="button" and data-index set to its index, and inside the button an img with the photo's src and alt. The button of the current photo, and only that one, gets the class selected and aria-current="true". The main image #main gets the current photo's src and alt, and #caption shows its alt as text. The starter puts bare images without alt into the list and leaves the main view alone.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the list holds images without buttons or alt, and the main image and caption are unchanged.

  2. Hint 2

    In the map callback, take (photo, index). Create the img, the button and the li, set button.dataset.index = String(index), and append img to button and button to li.

  3. Hint 3

    if (index === current) add the class and setAttribute('aria-current', 'true'). After replaceChildren, set src and alt of #main with setAttribute and the caption's textContent.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Each picture is a small SVG drawing inside a data: URL: no image files to load.
const picture = (colour, label) =>
  'data:image/svg+xml,' +
  encodeURIComponent(
    '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120">' +
      '<rect width="160" height="120" fill="' + colour + '"/>' +
      '<text x="80" y="66" font-size="20" text-anchor="middle" fill="white">' + label + '</text></svg>'
  );

const photos = [
  { src: picture('sienna', 'Fox'), alt: 'A red fox in the snow' },
  { src: picture('slategray', 'Owl'), alt: 'An owl on a branch' },
  { src: picture('steelblue', 'Heron'), alt: 'A heron by the river' }
];
let current = 0;

const thumbs = document.querySelector('#thumbs');
const mainImage = document.querySelector('#main');
const caption = document.querySelector('#caption');

function render() {
  const items = photos.map((photo, index) => {
    const img = document.createElement('img');
    img.setAttribute('src', photo.src);
    img.setAttribute('alt', photo.alt);
    const button = document.createElement('button');
    button.type = 'button';
    button.dataset.index = String(index);
    if (index === current) {
      button.classList.add('selected');
      button.setAttribute('aria-current', 'true');
    }
    button.append(img);
    const li = document.createElement('li');
    li.append(button);
    return li;
  });
  thumbs.replaceChildren(...items);
  mainImage.setAttribute('src', photos[current].src);
  mainImage.setAttribute('alt', photos[current].alt);
  caption.textContent = photos[current].alt;
}

render();
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Each picture is a small SVG drawing inside a data: URL: no image files to load.
const picture = (colour, label) =>
  'data:image/svg+xml,' +
  encodeURIComponent(
    '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120">' +
      '<rect width="160" height="120" fill="' + colour + '"/>' +
      '<text x="80" y="66" font-size="20" text-anchor="middle" fill="white">' + label + '</text></svg>'
  );

const photos = [
  { src: picture('sienna', 'Fox'), alt: 'A red fox in the snow' },
  { src: picture('slategray', 'Owl'), alt: 'An owl on a branch' },
  { src: picture('steelblue', 'Heron'), alt: 'A heron by the river' }
];
let current = 0;

const thumbs = document.querySelector('#thumbs');
const mainImage = document.querySelector('#main');
const caption = document.querySelector('#caption');

function render() {
  const items = photos.map((photo) => {
    const img = document.createElement('img');
    img.setAttribute('src', photo.src);
    const li = document.createElement('li');
    li.append(img);
    return li;
  });
  thumbs.replaceChildren(...items);
  // TODO: put each image in a button with data-index, give the image its alt,
  // mark the current button, and show the current photo with its caption.
}

render();

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const buttons = (page) => [...page.document.querySelectorAll('#thumbs li > button')];
const images = (page) => [...page.document.querySelectorAll('#thumbs li > button > img')];

test('Every photo gets a thumbnail button', async () => {
  const page = await loadPage();
  const indexes = buttons(page).map((b) => b.dataset.index);
  assert.deepEqual(indexes, ['0', '1', '2'], 'the thumbnail buttons have the data-index values ' + JSON.stringify(indexes));
  const types = buttons(page).map((b) => b.getAttribute('type'));
  assert.deepEqual(types, ['button', 'button', 'button'], 'the thumbnail buttons have the types ' + JSON.stringify(types));
});

test('Each thumbnail shows its photo with alt text', async () => {
  const page = await loadPage();
  const alts = images(page).map((img) => img.getAttribute('alt'));
  assert.deepEqual(
    alts,
    ['A red fox in the snow', 'An owl on a branch', 'A heron by the river'],
    'the thumbnail alt texts are ' + JSON.stringify(alts)
  );
  const data = images(page).map((img) => String(img.getAttribute('src')).startsWith('data:image/svg+xml,'));
  assert.deepEqual(data, [true, true, true], 'a data: URL as src, per thumbnail: ' + JSON.stringify(data));
});

test('The main image shows the first photo', async () => {
  const page = await loadPage();
  const main = page.document.querySelector('#main');
  const same = main.getAttribute('src') === images(page)[0].getAttribute('src');
  assert.equal(same, true, 'the main image does not have the src of the first thumbnail');
  const shown = main.getAttribute('alt');
  assert.equal(shown, 'A red fox in the snow', 'the main image has the alt ' + JSON.stringify(shown));
});

test('The caption shows the alt text', async () => {
  const page = await loadPage();
  const text = page.document.querySelector('#caption').textContent;
  assert.equal(text, 'A red fox in the snow', 'the caption shows ' + JSON.stringify(text));
});

test('Only the first thumbnail is marked as current', async () => {
  const page = await loadPage();
  const marks = buttons(page).map((b) => b.classList.contains('selected') + ' ' + b.getAttribute('aria-current'));
  assert.deepEqual(marks, ['true true', 'false null', 'false null'], 'class selected and aria-current per thumbnail: ' + JSON.stringify(marks));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Gallery</title>
</head>
<body>
  <h1>Gallery</h1>
  <figure>
    <img id="main" src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22/%3E" alt="">
    <figcaption id="caption"></figcaption>
  </figure>
  <button id="prev" type="button">Previous</button>
  <button id="next" type="button">Next</button>
  <ul id="thumbs"></ul>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 2 of 3

Select a photo with one listener

A click on a thumbnail, also on the image inside it, should make that photo current and call render(): the main image, the caption and the marked thumbnail follow. Replace the starter's loop with one click listener on #thumbs that uses closest and checks contains. A click on the list between the thumbnails changes nothing. The starter adds a listener to each button once; render() then replaces the buttons, so the second click does nothing.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the first click works, the second does nothing, because render() built new buttons without listeners.

  2. Hint 2

    Delete the loop. Add thumbs.addEventListener('click', (event) => { … }) once, outside render().

  3. Hint 3

    Inside: const button = event.target.closest('button'); return when !button || !thumbs.contains(button); then current = Number(button.dataset.index) and render().

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Each picture is a small SVG drawing inside a data: URL: no image files to load.
const picture = (colour, label) =>
  'data:image/svg+xml,' +
  encodeURIComponent(
    '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120">' +
      '<rect width="160" height="120" fill="' + colour + '"/>' +
      '<text x="80" y="66" font-size="20" text-anchor="middle" fill="white">' + label + '</text></svg>'
  );

const photos = [
  { src: picture('sienna', 'Fox'), alt: 'A red fox in the snow' },
  { src: picture('slategray', 'Owl'), alt: 'An owl on a branch' },
  { src: picture('steelblue', 'Heron'), alt: 'A heron by the river' }
];
let current = 0;

const thumbs = document.querySelector('#thumbs');
const mainImage = document.querySelector('#main');
const caption = document.querySelector('#caption');

function render() {
  const items = photos.map((photo, index) => {
    const img = document.createElement('img');
    img.setAttribute('src', photo.src);
    img.setAttribute('alt', photo.alt);
    const button = document.createElement('button');
    button.type = 'button';
    button.dataset.index = String(index);
    if (index === current) {
      button.classList.add('selected');
      button.setAttribute('aria-current', 'true');
    }
    button.append(img);
    const li = document.createElement('li');
    li.append(button);
    return li;
  });
  thumbs.replaceChildren(...items);
  mainImage.setAttribute('src', photos[current].src);
  mainImage.setAttribute('alt', photos[current].alt);
  caption.textContent = photos[current].alt;
}

// One listener for all thumbnails, also the ones render() builds later.
thumbs.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button || !thumbs.contains(button)) return;
  current = Number(button.dataset.index);
  render();
});

render();
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Each picture is a small SVG drawing inside a data: URL: no image files to load.
const picture = (colour, label) =>
  'data:image/svg+xml,' +
  encodeURIComponent(
    '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120">' +
      '<rect width="160" height="120" fill="' + colour + '"/>' +
      '<text x="80" y="66" font-size="20" text-anchor="middle" fill="white">' + label + '</text></svg>'
  );

const photos = [
  { src: picture('sienna', 'Fox'), alt: 'A red fox in the snow' },
  { src: picture('slategray', 'Owl'), alt: 'An owl on a branch' },
  { src: picture('steelblue', 'Heron'), alt: 'A heron by the river' }
];
let current = 0;

const thumbs = document.querySelector('#thumbs');
const mainImage = document.querySelector('#main');
const caption = document.querySelector('#caption');

function render() {
  const items = photos.map((photo, index) => {
    const img = document.createElement('img');
    img.setAttribute('src', photo.src);
    img.setAttribute('alt', photo.alt);
    const button = document.createElement('button');
    button.type = 'button';
    button.dataset.index = String(index);
    if (index === current) {
      button.classList.add('selected');
      button.setAttribute('aria-current', 'true');
    }
    button.append(img);
    const li = document.createElement('li');
    li.append(button);
    return li;
  });
  thumbs.replaceChildren(...items);
  mainImage.setAttribute('src', photos[current].src);
  mainImage.setAttribute('alt', photos[current].alt);
  caption.textContent = photos[current].alt;
}

render();

// TODO: one listener on #thumbs instead of one per button.
for (const button of thumbs.querySelectorAll('button')) {
  button.addEventListener('click', () => {
    current = Number(button.dataset.index);
    render();
  });
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const thumb = (page, n) => page.document.querySelectorAll('#thumbs img')[n];
const caption = (page) => page.document.querySelector('#caption').textContent;
const current = (page) => [...page.document.querySelectorAll('#thumbs button')].map((b) => b.getAttribute('aria-current') === 'true');

test('Clicking a thumbnail shows that photo', async () => {
  const page = await loadPage();
  thumb(page, 1).click();
  const same = page.document.querySelector('#main').getAttribute('src') === thumb(page, 1).getAttribute('src');
  assert.equal(same, true, 'after clicking the second thumbnail the main image does not have its src');
  assert.equal(caption(page), 'An owl on a branch', 'after clicking the second thumbnail the caption is ' + JSON.stringify(caption(page)));
});

test('The clicked thumbnail becomes the only current one', async () => {
  const page = await loadPage();
  thumb(page, 2).click();
  assert.deepEqual(current(page), [false, false, true], 'after clicking the third thumbnail, aria-current is on ' + JSON.stringify(current(page)));
  const selected = page.document.querySelectorAll('#thumbs .selected').length;
  assert.equal(selected, 1, selected + ' thumbnails have the class selected');
});

test('A second click on another thumbnail works too', async () => {
  const page = await loadPage();
  thumb(page, 1).click();
  thumb(page, 2).click();
  assert.equal(caption(page), 'A heron by the river', 'after clicking the second, then the third thumbnail the caption is ' + JSON.stringify(caption(page)));
});

test('A click on the list between thumbnails changes nothing', async () => {
  const page = await loadPage();
  page.document.querySelector('#thumbs').click();
  assert.equal(caption(page), 'A red fox in the snow', 'after a click on the list the caption is ' + JSON.stringify(caption(page)));
  assert.deepEqual(current(page), [true, false, false], 'after a click on the list, aria-current is on ' + JSON.stringify(current(page)));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Gallery</title>
</head>
<body>
  <h1>Gallery</h1>
  <figure>
    <img id="main" src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22/%3E" alt="">
    <figcaption id="caption"></figcaption>
  </figure>
  <button id="prev" type="button">Previous</button>
  <button id="next" type="button">Next</button>
  <ul id="thumbs"></ul>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 3 of 3

Previous, Next and the arrow keys

Write show(index), which wraps index into the range 0 to photos.length - 1, makes it current and calls render(). Next shows the next photo and goes from the last back to the first; Previous shows the one before and goes from the first to the last. A keydown listener on document does the same for ArrowRight and ArrowLeft and ignores other keys. The starter's Next runs past the last photo, where photos[3] is undefined and render() throws; Previous and the keys do nothing.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: the third Next throws a TypeError about src, and Previous and the keys change nothing.

  2. Hint 2

    show(index) sets current = (index + photos.length) % photos.length and calls render(). Adding the length first keeps -1 from staying negative.

  3. Hint 3

    Call show(current + 1) and show(current - 1) from the two buttons, and from document.addEventListener('keydown', …) when event.key is 'ArrowRight' or 'ArrowLeft'.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Each picture is a small SVG drawing inside a data: URL: no image files to load.
const picture = (colour, label) =>
  'data:image/svg+xml,' +
  encodeURIComponent(
    '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120">' +
      '<rect width="160" height="120" fill="' + colour + '"/>' +
      '<text x="80" y="66" font-size="20" text-anchor="middle" fill="white">' + label + '</text></svg>'
  );

const photos = [
  { src: picture('sienna', 'Fox'), alt: 'A red fox in the snow' },
  { src: picture('slategray', 'Owl'), alt: 'An owl on a branch' },
  { src: picture('steelblue', 'Heron'), alt: 'A heron by the river' }
];
let current = 0;

const thumbs = document.querySelector('#thumbs');
const mainImage = document.querySelector('#main');
const caption = document.querySelector('#caption');

function render() {
  const items = photos.map((photo, index) => {
    const img = document.createElement('img');
    img.setAttribute('src', photo.src);
    img.setAttribute('alt', photo.alt);
    const button = document.createElement('button');
    button.type = 'button';
    button.dataset.index = String(index);
    if (index === current) {
      button.classList.add('selected');
      button.setAttribute('aria-current', 'true');
    }
    button.append(img);
    const li = document.createElement('li');
    li.append(button);
    return li;
  });
  thumbs.replaceChildren(...items);
  mainImage.setAttribute('src', photos[current].src);
  mainImage.setAttribute('alt', photos[current].alt);
  caption.textContent = photos[current].alt;
}

// One listener for all thumbnails, also the ones render() builds later.
thumbs.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button || !thumbs.contains(button)) return;
  current = Number(button.dataset.index);
  render();
});

function show(index) {
  current = (index + photos.length) % photos.length; // wrap around at both ends
  render();
}

document.querySelector('#prev').addEventListener('click', () => show(current - 1));
document.querySelector('#next').addEventListener('click', () => show(current + 1));
document.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowLeft') show(current - 1);
  if (event.key === 'ArrowRight') show(current + 1);
});

render();
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Each picture is a small SVG drawing inside a data: URL: no image files to load.
const picture = (colour, label) =>
  'data:image/svg+xml,' +
  encodeURIComponent(
    '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120">' +
      '<rect width="160" height="120" fill="' + colour + '"/>' +
      '<text x="80" y="66" font-size="20" text-anchor="middle" fill="white">' + label + '</text></svg>'
  );

const photos = [
  { src: picture('sienna', 'Fox'), alt: 'A red fox in the snow' },
  { src: picture('slategray', 'Owl'), alt: 'An owl on a branch' },
  { src: picture('steelblue', 'Heron'), alt: 'A heron by the river' }
];
let current = 0;

const thumbs = document.querySelector('#thumbs');
const mainImage = document.querySelector('#main');
const caption = document.querySelector('#caption');

function render() {
  const items = photos.map((photo, index) => {
    const img = document.createElement('img');
    img.setAttribute('src', photo.src);
    img.setAttribute('alt', photo.alt);
    const button = document.createElement('button');
    button.type = 'button';
    button.dataset.index = String(index);
    if (index === current) {
      button.classList.add('selected');
      button.setAttribute('aria-current', 'true');
    }
    button.append(img);
    const li = document.createElement('li');
    li.append(button);
    return li;
  });
  thumbs.replaceChildren(...items);
  mainImage.setAttribute('src', photos[current].src);
  mainImage.setAttribute('alt', photos[current].alt);
  caption.textContent = photos[current].alt;
}

// One listener for all thumbnails, also the ones render() builds later.
thumbs.addEventListener('click', (event) => {
  const button = event.target.closest('button');
  if (!button || !thumbs.contains(button)) return;
  current = Number(button.dataset.index);
  render();
});

// TODO: wrap around at both ends, add Previous, and the arrow keys.
document.querySelector('#next').addEventListener('click', () => {
  current += 1;
  render();
});

render();

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const shown = (page) => page.document.querySelector('#main').getAttribute('alt');
const press = (page, key) => page.document.body.dispatchEvent(new page.window.KeyboardEvent('keydown', { key, bubbles: true }));
const next = (page) => page.document.querySelector('#next').click();
const prev = (page) => page.document.querySelector('#prev').click();

test('Next shows the next photo', async () => {
  const page = await loadPage();
  next(page);
  assert.equal(shown(page), 'An owl on a branch', 'after one Next the main image is ' + JSON.stringify(shown(page)));
});

test('Next on the last photo goes back to the first', async () => {
  const page = await loadPage();
  next(page);
  next(page);
  next(page);
  assert.equal(shown(page), 'A red fox in the snow', 'after three Next clicks the main image is ' + JSON.stringify(shown(page)));
});

test('Previous on the first photo goes to the last', async () => {
  const page = await loadPage();
  prev(page);
  assert.equal(shown(page), 'A heron by the river', 'after Previous on the first photo the main image is ' + JSON.stringify(shown(page)));
});

test('ArrowRight and ArrowLeft move through the photos', async () => {
  const page = await loadPage();
  press(page, 'ArrowRight');
  assert.equal(shown(page), 'An owl on a branch', 'after ArrowRight the main image is ' + JSON.stringify(shown(page)));
  press(page, 'ArrowLeft');
  press(page, 'ArrowLeft');
  assert.equal(shown(page), 'A heron by the river', 'after ArrowRight and two ArrowLeft the main image is ' + JSON.stringify(shown(page)));
});

test('Other keys change nothing', async () => {
  const page = await loadPage();
  press(page, 'Enter');
  press(page, 'a');
  assert.equal(shown(page), 'A red fox in the snow', 'after Enter and a the main image is ' + JSON.stringify(shown(page)));
});

test('The marked thumbnail follows Previous', async () => {
  const page = await loadPage();
  prev(page);
  const marked = [...page.document.querySelectorAll('#thumbs button')].map((b) => b.getAttribute('aria-current') === 'true');
  assert.deepEqual(marked, [false, false, true], 'after Previous, aria-current is on ' + JSON.stringify(marked));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Gallery</title>
</head>
<body>
  <h1>Gallery</h1>
  <figure>
    <img id="main" src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22/%3E" alt="">
    <figcaption id="caption"></figcaption>
  </figure>
  <button id="prev" type="button">Previous</button>
  <button id="next" type="button">Next</button>
  <ul id="thumbs"></ul>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Common mistakes

Moving past the last photo without wrapping

// Only the alt texts here, to keep the example short.
const photos = [
  { alt: 'A red fox in the snow' },
  { alt: 'An owl on a branch' }
];
let current = 0;
const caption = document.querySelector('#caption');

function next() {
  current += 1;
  caption.textContent = photos[current].alt;
}
next(); // the owl
next(); // one past the end

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'alt')

Why, and the fix

With two photos the indexes are 0 and 1. The second next() makes current 2, and photos[2] is undefined, so reading .alt fails. Wrap the index: current = (current + 1) % photos.length. Firefox and Safari word this message differently.

Calling forEach on element.children

const thumbs = document.querySelector('#thumbs');
// Unmark every thumbnail before marking the new one:
thumbs.children.forEach((li) => li.classList.remove('selected'));

What Node.js prints

TypeError: thumbs.children.forEach is not a function

Why, and the fix

element.children is an HTMLCollection, which has no forEach. Spread it into an array first, [...thumbs.children].forEach(…), or loop with for...of. In the gallery you need neither: render() rebuilds every thumbnail and marks only the current one.

Calling setAttribute with only the name

const button = document.querySelector('#thumbs button');
// Mark the thumbnail as the current one:
button.setAttribute('aria-current');

What Node.js prints

TypeError: Failed to execute 'setAttribute' on 'Element': 2 arguments required, but only 1 present.

Why, and the fix

setAttribute always takes a name and a value. For aria-current the value is "true": button.setAttribute('aria-current', 'true'). An empty value would not count: MDN says an empty aria-current is not exposed to the user.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Data first, then render()

The gallery is an array of photos, each { src, alt }, and one number, current, the index of the photo on show. render() builds the page from them: each photo becomes an li with a button holding a small img, and replaceChildren puts them in, so calling render() again never duplicates anything. The main image gets the current photo's src and alt with setAttribute, and the caption shows the alt with textContent. There are no image files: each src is a data: URL, which carries a small SVG picture inside the URL itself, made by the helper picture(). Alt text is not decoration: a screen reader reads it, and the browser shows it when an image does not load.

One listener for the thumbnails

render() replaces the buttons each time, so listeners added to the old buttons are gone after the first click. Put one listener on #thumbs instead. event.target may be the img inside a button, so find the button with event.target.closest("button"), return early when it is null or not inside the list, and read its data-index. dataset values are strings: convert with Number() before you do sums. Mark the current thumbnail twice: a class such as selected for the look, and aria-current="true" so that screen readers announce it. MDN asks you to mark only one element in a set as current, and render() makes sure of that.

Wrapping around

Next after the last photo should show the first, and Previous before the first the last. The remainder operator does this: (current + 1) % photos.length is 0 after the last index. Going back, (current - 1) % 3 is -1 when current is 0, because % keeps the sign of the left number; add the length first: (current - 1 + photos.length) % photos.length. Put the formula in one function, show(index), and call it from the buttons and the keys. For the keys, listen for keydown on document: key presses bubble up to it from the element that has the focus. event.key is "ArrowLeft" or "ArrowRight"; ignore every other key.

Sources

Last reviewed October 4, 2026