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
altattribute. - The
aria-expandedattribute 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 usinglist-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)
})
}
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
- Accessible SVGs: Inclusiveness Beyond Patterns, Carie Fisher
- A Complete Guide To Accessible Front-End Components, Vitaly Friedman
- Creating An Accessible Dialog From Scratch, Kitty Giraudel
- When CSS Isn’t Enough: JavaScript Requirements For Accessible Components, Stephanie Eckles




