Structure and accessibility attributes

The gallery is built from a list of images, each wrapped in a button, and the whole thing sits inside a main landmark alongside a short paragraph explaining how to use it.

<ul class="js-favs">
  <li>
    <button>
      <img src="https://www.smashingmagazine.com/path/to/image" alt="" />
    </button>
  </li>
  ...
</ul>

Three adjustments make that markup accessible:

  • Every image carries a descriptive alt attribute.
  • The aria-expanded attribute tells assistive technologies whether an image is currently expanded.
  • role="list" keeps the list announcement intact, since some screen readers drop it.
“It’s not just using list-style: none, but any CSS that would remove the bullet or number indicators of a list’s items will also remove the semantics.”

— “Fixing” Lists, Scott O’Hara

The demo wraps images in aria-expanded buttons for simplicity, but a cleaner approach is to ship plain image tags and let JavaScript wrap them in a button carrying aria-expanded. That counts as progressive enhancement, since the expanding effect requires JavaScript regardless.

Grid and transition styles

The layout uses CSS Grid with auto-fit: tracks fill the available space while refusing to shrink below a set width, so the number of visible items adapts across viewports without a pile of media queries.

:root {
  --gap: 4px;
}

ul {
  display: grid;
  grid-template-columns: repeat(1, 1fr);
  grid-gap: var(--gap);
}

@media screen and (min-width: 640px) {
  ul {
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  }
}

To keep the image’s aspect ratio, use the aspect-ratio property. The button style resets with all: initial, and its overflow is hidden. Fitting the image to the button relies on object-fit: cover together with width and height set to 100%:

button {
  all: initial;
  display: block;
  width: 100%;
  aspect-ratio: 2/1;
  overflow: hidden;
  cursor: pointer;
}

img {
  height: 100%;
  width: 100%;
  object-fit: cover;
}

Expansion is handled by the scale transformation, with a transition applied by default. For users who prefer less motion, a prefers-reduced-motion media query sets transition-duration to 0s.

:root {
  --duration-shrink: .5s;
  --duration-expand: .25s;
  --no-duration: 0s;
}

li {
  transition-property: transform, opacity;
  transition-timing-function: ease-in-out;
  transition-duration: var(--duration-expand);
}

li.is-zoomed {
  transition-duration: var(--duration-shrink);
}

@media (prefers-reduced-motion) {
  li,
  li.is-zoomed {    
    transition-duration: var(--no-duration);
  }
}

Preparation: measuring for the calculation

Before anything can expand, the script gathers a few values. It reads the transition duration from the --duration-on CSS Custom Property:

let timeout = 0

// Get the transition timeout from CSS
const getTimeouts = () => {
  const durationOn = parseFloat(getComputedStyle(document.documentElement)
    .getPropertyValue('--duration-on'));
  
  timeout = parseFloat(durationOn) * 1000
}

It then stores data attributes for the gap between grid items, the width of a single item, and the items per row. Gap and width come from the computed CSS style. Deriving the column count requires iterating over the tiles and comparing their top position: as soon as the position changes, a new row has begun, which reveals how many items fit per row.

// Set data attributes for calculations
const setDataAttrs = ($elems, $parent) => {
  // Get the top offset of the first element
  let top = getTop($elems[0])

  // Set grid gap from CSS
  const gridColumnGap = parseFloat(getComputedStyle(document.documentElement)
    .getPropertyValue('--gap'))
  $parent.setAttribute('data-gap', gridColumnGap)

  // Set grid item width from CSS
  const eStyle = getComputedStyle($elems[0])
  $parent.setAttribute('data-width', eStyle.width)

  // Iterate through grid items
  for (let i = 0; i < $elems.length; i++) {
    const t = getTop($elems[i])

    // Check when top offset changes
    if (t != top) {
      // Set the number of columns and break stop the loop
      $parent.setAttribute('data-cols', i)
      break;
    }
  }
}

Expansion geometry

The direction of expansion depends on position. If a tile sits in the last row and at the end of that row, it should grow upward, which means its transform-origin is set to bottom.

Important: If the element should expand to one direction, its transform-origin property should be set to an “opposite” value. Note that vertical and horizontal values should be combined.

// Set active item
const activateElem = ($elems, $parent, $elem, $button, lengthOfElems, i) => {
  // Get data attributes from parent
  const cols = parseInt($parent.getAttribute('data-cols'))
  const width = parseFloat($parent.getAttribute('data-width'))
  const gap = parseFloat($parent.getAttribute('data-gap'))

  // Calculate the number of rows
  const rows = Math.ceil(lengthOfElems / cols) - 1

  // Calculate if the item is in the last row
  const isLastRow = i + 1 > rows * cols
  // Set default transform direction to top (expand down) 
  let transformOrigin = 'top'

  if (isLastRow) {
    // If the item is in the last row, set transform direction to bottom (expand up) 
    transformOrigin = 'bottom'
  }

  // Calculate if the item is the most right
  const isRight = (i + 1) % cols !== 0

  if (isRight) {
    // If the item is the most right, set transform direction to left (expand right) 
    transformOrigin += ' left'
  } else {
    // If the item is the most right, set transform direction to right (expand left) 
    transformOrigin += ' right'
  }

  $elem.style.transformOrigin = transformOrigin
}

The image doubles in size without disturbing the grid, using the scale transformation with a factor based on twice the element width plus the grid gap.

// Calculate the scale coefficient
const scale = (width * 2 + gap) / width

// Set item CSS transform
$elem.style.transform = `scale(${scale})`

Keyboard support and toggling

Keyboard users get Tab navigation and Enter activation for free; Esc and the arrow keys are added on top. Pressing Esc on an expanded tile returns it to its standard size, detected by inspecting the pressed key code. The arrow keys instead locate the previous or next sibling and emulate a click on it.

// Set sibling as an active item
const activateSibling = ($sibling) => {
  // Find anchor
  const $siblingButton = $sibling.querySelector('button')

  // Unset global active element
  $activeElem = false

  // Focus and click on current
  $siblingButton.focus()
  $siblingButton.click()
}

// Set keyboard events
const setKeyboardEvents = () => {
  document.addEventListener('keydown', (e) => {
    // Take action only if global active element exists
    if ($activeElem) {
      // If key is “escape”, emulate the click on the global active element
      if (e.code === 'Escape') {
        $activeElem.click()
      }

      // If key is “left arrow”, activate the previous sibling
      if (e.code === 'ArrowLeft') {
        const $previousSibling = $activeElem.parentNode.previousElementSibling

        if($previousSibling) {
          activateSibling($previousSibling)
        }
      }

      // If key is “right arrow”, activate the next sibling
      if (e.code === 'ArrowRight') {
        const $nextSibling = $activeElem.parentNode.nextElementSibling

        if($nextSibling) {
          activateSibling($nextSibling)
        }
      }
    }
  })
}

Expanding one element therefore requires deactivating all the others first, and clicking an already expanded element collapses it back.

let $activeElem = false

// Deactivate grid items
const deactiveElems = ($elems, $parent, $currentElem, $button) => {
  // Unset parent class
  $parent.classList.remove('is-zoomed')

  for (let i = 0; i < $elems.length; i++) {
    // Unset item class
    $elems[i].classList.remove('is-zoomed')
    // Unset item CSS transform
    $elems[i].style.transform = 'none'

    // Skip the rest if the item is the current item
    if ($elems[i] === $currentElem) {
      continue
    }
      
    // Unset item aria expanded if element exists
    if($button) {
      $button.setAttribute('aria-expanded', false)
    }
  }
}

// Set active item
const activateElem = ($elems, $parent, $elem, $button, lengthOfElems, i) => {
  ...
  
  // Reset all elements
  deactiveElems($elems, $parent, $elem, $button)

  if ($activeElem) {
    $activeElem = false
    return
  }

  $activeElem = $button
  
  ...
}

// Set click events on anchors
const setClicks = ($elems, $parent) => {
  $elems.forEach(($elem, i) => {
    // Find anchor
    const $button = $elem.querySelector('button')

    $button.addEventListener('click', (e) => {
      // Set active item on click
      activateElem($elems, $parent, $elem, $button, $elems.length, i)
    })
  })
}

Stacking context and resizing

z-index problems are avoided by delaying the transform with a timeout — the same duration computed during preparation.

// Deactivate grid items
const deactiveElems = ($elems, $parent, $currentElem, $button) => {
  for (let i = 0; i < $elems.length; i++) {
    ...

    // After a half of the timeout, reset CSS z-index to avoid overlay issues
    setTimeout(() => {
      $elems[i].style.zIndex = 0
    }, timeout)
  }
}

// Set active item
const activateElem = ($elems, $parent, $elem, $button, lengthOfElems, i) => {
  ...
  setTimeout(() => {
    // Set parent class
    $parent.classList.add('is-zoomed')
    // Set item class
    $elem.classList.add('is-zoomed')
    // Set item CSS transform
    $elem.style.transform = `scale(${scale})`
    // Set item aria expanded
    $button.setAttribute('aria-expanded', true)
    // Set global active item
    $activeElem = $button
  }, timeout)
}

Because the grid is fluid, items shift between rows when the viewport changes size, so the defaults have to be recalculated on resize.

// Set resize events
const setResizeEvents = ($elems, $parent) => {
  window.addEventListener('resize', () => {
    // Set data attributes for calculations
    setDataAttrs($elems, $parent)
    // Deactivate grid items
    deactiveElems($elems, $parent)
  })
}
A preview of what the expandable accessible gallery demo can do. (Try out on CodePen →)

Lessons on accessibility

Testing with a keyboard was straightforward; confirming behavior with VoiceOver on a Mac was the harder task, and the author was never fully confident the ARIA choices were right. Help came from the BoagWorld Slack community, where Todd Libby tested the demo on different devices and corrected the code, and Manuel Matuzović helped clean it up.

The takeaway is the first rule of ARIA use:

“If you can use a native HTML element [HTML51] or attribute with the semantics and behavior you require already built-in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so.”

— First Rule of ARIA Use, W3C Working Draft 27 (Sept. 2018)

Further reading

Smashing Editorial