Quickly navigate through the dashboard with your keyboard using the command menu.
A row of vertical image panels where clicking, hovering or focusing one expands it via flex-grow to reveal its caption while the others compress.
Expanding Image Panels is a row of tall image tiles that behave like a horizontal accordion: hover, click, or focus one and it grows to take most of the width while the others compress, revealing a caption as it opens. It feels crisp and editorial — the kind of gallery you see on architecture or interiors sites. Reach for it when you have a handful of strong photos and want an interactive "featured spaces" strip instead of a static grid.
This gem is mostly CSS with a small accessibility script — no libraries, no build step, no GSAP.
index.html, assets/style.css, and assets/script.js into one folder.assets/byq-brand.css and the whole assets/medias/ folder along with them (medias/ holds 01.webp through 05.webp, the five panel photos).index.html in any modern browser.assets/byq-brand.css supplies the BYQ brand tokens (colors, fonts, radii) used throughout, so keep that stylesheet linked or the var(--byq-…) values won't resolve.
The whole effect is one namespaced <section> wrapping a <ul> with role="tablist". Each panel is a list item whose entire surface is a <button> acting as a tab. Here is one panel (the markup repeats five times):
<section class="byq-gem byq-gem--expanding-panels-01">
<ul class="byq-gem--expanding-panels-01__row" role="tablist" aria-label="Featured spaces">
<li class="byq-gem--expanding-panels-01__panel is-active">
<button class="byq-gem--expanding-panels-01__trigger" type="button"
role="tab" aria-selected="true" aria-controls="byq-ep01-cap-0">
<img class="byq-gem--expanding-panels-01__img" src="assets/medias/01.webp"
alt="A sunlit minimalist living room with warm plaster walls."
width="640" height="880" loading="lazy" />
<span class="byq-gem--expanding-panels-01__scrim" aria-hidden="true"></span>
<span class="byq-gem--expanding-panels-01__index" aria-hidden="true">01</span>
<span class="byq-gem--expanding-panels-01__caption" id="byq-ep01-cap-0">
<span class="byq-gem--expanding-panels-01__eyebrow">Living</span>
<span class="byq-gem--expanding-panels-01__title">Plaster & Sun</span>
<span class="byq-gem--expanding-panels-01__meta">Copenhagen · 62m²</span>
</span>
</button>
</li>
<!-- four more panels … -->
</ul>
</section>The <button> makes each panel keyboard-focusable and clickable for free. Inside it are four layers: the __img, a __scrim gradient for caption legibility, a vertical __index number shown while collapsed, and a __caption block that appears when the panel opens. role="tab", aria-selected, and aria-controls (pointing at each caption's id) make it a real ARIA tablist. The first panel starts with is-active.
Every selector is prefixed with .byq-gem--expanding-panels-01 so nothing leaks. The layout is a flexbox row where each panel starts at an equal flex: 1 1 0.
.byq-gem--expanding-panels-01__row {
display: flex;
gap: 0.6rem;
height: min(30rem, 62vh);
}
.byq-gem--expanding-panels-01__panel {
flex: 1 1 0;
min-width: 0;
transition: flex-grow 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
.byq-gem--expanding-panels-01__panel.is-active { flex-grow: 5.2; }The whole effect is one animated property: flex-grow. The open panel gets flex-grow: 5.2, so it claims roughly five shares to everyone else's one, and the eased transition on flex-grow makes the resize glide. Hover uses the same trick but overrides the persisted click state so it feels immediate — while the row is hovered, the .is-active panel drops back to 1 and the hovered panel jumps to 5.2:
.byq-gem--expanding-panels-01__row:hover
.byq-gem--expanding-panels-01__panel.is-active { flex-grow: 1; }
.byq-gem--expanding-panels-01__row:hover
.byq-gem--expanding-panels-01__panel:hover { flex-grow: 5.2; }Opening a panel also swaps the image and caption. The photo starts dimmed and slightly zoomed (grayscale(0.35) brightness(0.82) and scale(1.06)); when the panel is active, hovered, or focused it snaps to full color at scale(1), the scrim fades in, and the caption slides up from translateY(14px) to 0:
.byq-gem--expanding-panels-01__panel.is-active .byq-gem--expanding-panels-01__img,
.byq-gem--expanding-panels-01__panel:hover .byq-gem--expanding-panels-01__img {
filter: grayscale(0) brightness(0.92);
transform: scale(1);
}
.byq-gem--expanding-panels-01__panel.is-active .byq-gem--expanding-panels-01__caption,
.byq-gem--expanding-panels-01__panel:hover .byq-gem--expanding-panels-01__caption {
opacity: 1;
transform: translateY(0);
}A @media (max-width: 34rem) block turns the flex row into a vertical accordion (animating height instead of flex-grow), and a prefers-reduced-motion block collapses every transition to 0.001ms.
Hover is pure CSS — the script exists only to persist a clicked or keyboard-selected panel and to wire up the tablist. It's scoped to the gem root and never touches window.
DOMContentLoaded, grab the root and collect the panels and their triggers into arrays. Bail early if the root or panels are missing.const root = document.querySelector(".byq-gem--expanding-panels-01");
if (!root) return;
const panels = Array.from(root.querySelectorAll(".byq-gem--expanding-panels-01__panel"));
const triggers = panels.map((p) => p.querySelector(".byq-gem--expanding-panels-01__trigger"));select(index) function is the source of truth: it toggles is-active on the chosen panel and sets aria-selected on every trigger to match, so the visual state and the ARIA state never drift apart.function select(index) {
panels.forEach((panel, i) => {
const on = i === index;
panel.classList.toggle("is-active", on);
if (triggers[i]) triggers[i].setAttribute("aria-selected", on ? "true" : "false");
});
}Each trigger gets a click listener calling select(i). Because triggers are real <button>s, Enter and Space fire click for free — no extra key handling for activation.
A keydown listener implements roving arrow-key navigation, the standard tablist pattern: Arrow keys step to the neighbor, Home/End jump to the ends, and the index wraps with (next + triggers.length) % triggers.length so it loops around the edges. It then selects that panel and moves focus to it.
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = i + 1;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = i - 1;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = triggers.length - 1;
if (next === null) return;
e.preventDefault();
next = (next + triggers.length) % triggers.length;
select(next);
triggers[next].focus();01.webp–05.webp in assets/medias/ and update each <img src>. object-fit: cover handles any crop; the 640×880 width/height keep the tall aspect.<li> block. Keep each caption's id unique and matched by the trigger's aria-controls; the JS adapts to any count automatically.flex-grow: 5.2 on .is-active (and the matching :hover rule). Higher makes the open panel dominate more; lower keeps neighbors wider.height: min(30rem, 62vh) on __row, and the 0.7s cubic-bezier(0.16, 1, 0.3, 1) timing to make the resize faster or looser.filter: grayscale(0.35) brightness(0.82) and transform: scale(1.06) to change how dim and zoomed the resting photos look.var(--byq-accent-solar) and the focus ring pairs it with var(--byq-bg); titles use var(--byq-font-display), meta uses var(--byq-font-mono). Swap those tokens to re-skin the captions.34rem media query controls when it stacks into a vertical accordion, and .is-active { height: 17rem } sets the open height there.