feat: add accessible image lightbox for post images (#654)

* feat: add accessible image lightbox for post images

* fix: iOS scroll lock, backdrop blur, dvh image sizing for lightbox

* feat: add vanilla pinch-to-zoom to image lightbox

* fix: improve touch event handling and scaling logic in image lightbox

---------

Co-authored-by: satnaing <satnaingdev@gmail.com>
This commit is contained in:
kOaDT
2026-06-06 04:08:21 +02:00
committed by GitHub
parent 38c4f39fde
commit 98f15ee3bc
2 changed files with 274 additions and 0 deletions
+266
View File
@@ -260,6 +260,272 @@ const ogImage = ogImageUrl
}
attachCopyButtons();
/** Accessible image lightbox for article images. */
function initLightbox() {
const article = document.getElementById("article");
if (!article) return;
const prefersReducedMotion = () =>
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let overlay = null;
let lastFocused = null;
// Defer attribute mutations so they don't push the LCP timestamp.
// Event listeners below use delegation and don't need the attributes to exist yet.
requestAnimationFrame(() => {
const images = Array.from(article.querySelectorAll("img"));
for (const image of images) {
if (image.closest("a")) continue;
image.setAttribute("role", "button");
image.setAttribute("tabindex", "0");
image.setAttribute("aria-haspopup", "dialog");
image.setAttribute(
"aria-label",
image.alt ? `Zoom image: ${image.alt}` : "Zoom image"
);
}
});
function open(src, alt, trigger) {
if (overlay) return;
lastFocused = trigger ?? document.activeElement;
overlay = document.createElement("div");
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute(
"aria-label",
alt ? `Image preview: ${alt}` : "Image preview"
);
overlay.className =
"fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/70 backdrop-blur-sm opacity-0 transition-opacity duration-200 motion-reduce:transition-none";
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.setAttribute("aria-label", "Close image preview");
closeButton.className =
"absolute end-4 top-4 rounded p-2 text-3xl leading-none text-white";
closeButton.innerHTML = "&#10005;";
closeButton.addEventListener("click", close);
const image = document.createElement("img");
image.src = src;
image.alt = "";
image.className =
"max-h-[90dvh] max-w-[90dvw] cursor-default object-contain";
overlay.append(closeButton, image);
overlay.addEventListener("click", e => {
if (e.target === overlay && currentScale <= 1) close();
});
let currentScale = 1;
let translateX = 0;
let translateY = 0;
let initialDist = 0;
let initialScale = 1;
let panStartX = 0;
let panStartY = 0;
let panStartTranslateX = 0;
let panStartTranslateY = 0;
let lastTapTime = 0;
function applyTransform() {
image.style.transform = `scale(${currentScale}) translate(${translateX}px, ${translateY}px)`;
}
function resetTransform() {
currentScale = 1;
translateX = 0;
translateY = 0;
image.style.transform = "";
}
overlay.addEventListener(
"touchstart",
e => {
const t = e.touches;
if (t.length === 2) {
initialDist = Math.hypot(
t[1].clientX - t[0].clientX,
t[1].clientY - t[0].clientY
);
initialScale = currentScale;
} else if (t.length === 1) {
const now = Date.now();
if (now - lastTapTime < 300) {
e.preventDefault();
if (currentScale > 1) {
resetTransform();
} else {
currentScale = 2;
translateX = 0;
translateY = 0;
applyTransform();
}
lastTapTime = 0;
panStartX = t[0].clientX;
panStartY = t[0].clientY;
panStartTranslateX = translateX;
panStartTranslateY = translateY;
} else {
lastTapTime = now;
if (currentScale > 1) {
panStartX = t[0].clientX;
panStartY = t[0].clientY;
panStartTranslateX = translateX;
panStartTranslateY = translateY;
}
}
}
},
{ passive: false }
);
overlay.addEventListener(
"touchmove",
e => {
const t = e.touches;
if (t.length === 2) {
e.preventDefault();
const dist = Math.hypot(
t[1].clientX - t[0].clientX,
t[1].clientY - t[0].clientY
);
currentScale = Math.min(
4,
Math.max(1, initialScale * (dist / initialDist))
);
applyTransform();
} else if (t.length === 1) {
if (currentScale > 1) {
e.preventDefault();
translateX =
panStartTranslateX + (t[0].clientX - panStartX) / currentScale;
translateY =
panStartTranslateY + (t[0].clientY - panStartY) / currentScale;
const maxX = Math.max(
0,
(image.clientWidth - overlay.clientWidth / currentScale) / 2
);
const maxY = Math.max(
0,
(image.clientHeight - overlay.clientHeight / currentScale) / 2
);
translateX = Math.min(maxX, Math.max(-maxX, translateX));
translateY = Math.min(maxY, Math.max(-maxY, translateY));
applyTransform();
} else {
e.preventDefault();
}
}
},
{ passive: false }
);
overlay.addEventListener("touchend", e => {
if (e.touches.length === 0 && currentScale <= 1.05) {
resetTransform();
}
});
overlay.addEventListener("touchcancel", e => {
if (e.touches.length === 0 && currentScale <= 1.05) {
resetTransform();
}
});
document.body.appendChild(overlay);
document.body.style.overflow = "hidden";
document.addEventListener("keydown", onKeyDown);
window.__closeLightbox = close;
requestAnimationFrame(() => overlay?.classList.add("opacity-100"));
closeButton.focus();
}
function close() {
if (!overlay) return;
const el = overlay;
overlay = null;
window.__closeLightbox = null;
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
lastFocused?.focus();
lastFocused = null;
if (prefersReducedMotion()) {
el.remove();
return;
}
const remove = () => el.remove();
el.addEventListener("transitionend", remove, { once: true });
setTimeout(remove, 250); // fallback in case transitionend never fires
el.classList.remove("opacity-100");
}
function onKeyDown(e) {
if (e.key === "Escape") {
close();
} else if (e.key === "Tab") {
trapFocus(e);
}
}
// Keep keyboard focus inside the open dialog.
function trapFocus(e) {
if (!overlay) return;
const focusables = overlay.querySelectorAll(
'a[href], button, [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
function triggerFromEvent(e) {
const image = e.target.closest("img");
if (!image || !article.contains(image) || image.closest("a")) return null;
return image;
}
function activate(image) {
open(image.currentSrc || image.src, image.alt, image);
}
article.addEventListener("click", e => {
const image = triggerFromEvent(e);
if (!image) return;
e.preventDefault();
activate(image);
});
article.addEventListener("keydown", e => {
if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return;
const image = triggerFromEvent(e);
if (!image) return;
e.preventDefault();
activate(image);
});
}
initLightbox();
if (!window.__lightboxSwapBound) {
window.__lightboxSwapBound = true;
document.addEventListener("astro:before-swap", () =>
window.__closeLightbox?.()
);
}
document.addEventListener("astro:after-swap", () =>
window.scrollTo({ left: 0, top: 0, behavior: "instant" })
);
+8
View File
@@ -47,6 +47,14 @@
@apply border-border mx-auto border;
}
img[role="button"] {
@apply cursor-zoom-in;
&:focus-visible {
@apply outline-2 outline-offset-2 outline-dashed;
}
}
figcaption {
@apply opacity-75;
}