Photography — Image Grid Hover
A gallery-dark, image-first photo grid where each frame stays quiet until you point at it, then a caption panel wipes up with a clip-path reveal, the image lifts and scales, and the title, photographer and category fade in on a staggered timeline. Includes category filter chips, a keyboard-focusable lightbox, an aspect toggle, and a live shot counter — all vanilla, all self-contained, tuned for a portfolio feel down to phone widths.
MCP
Code
:root {
--ink: #0b0b0c;
--bg: #101012;
--surface: #17171b;
--surface-2: #1e1e23;
--paper: #f6f4ef;
--sand: #c9b79c;
--sand-d: #a8916f;
--muted: #9a9aa2;
--line: rgba(255, 255, 255, 0.10);
--line-2: rgba(255, 255, 255, 0.18);
--white: #fff;
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(201, 183, 156, 0.08), transparent 60%),
var(--bg);
color: var(--paper);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.wrap {
max-width: 1180px;
margin: 0 auto;
padding: clamp(28px, 5vw, 64px) clamp(18px, 4vw, 40px) 48px;
}
/* ---------- Masthead ---------- */
.masthead__top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.kicker {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--sand);
}
.kicker--muted { color: var(--muted); }
.title {
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: clamp(38px, 8vw, 76px);
line-height: 0.98;
letter-spacing: -0.01em;
margin: 14px 0 0;
}
.lede {
color: var(--muted);
max-width: 52ch;
margin: 14px 0 0;
font-size: 15px;
}
/* ---------- Controls ---------- */
.controls {
margin-top: 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
appearance: none;
border: 1px solid var(--line-2);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 9px 16px;
border-radius: 999px;
cursor: pointer;
transition: color 0.2s, background 0.2s, border-color 0.2s, transform 0.12s;
}
.chip:hover { color: var(--paper); border-color: var(--sand-d); }
.chip:active { transform: translateY(1px); }
.chip.is-active {
background: var(--paper);
color: var(--ink);
border-color: var(--paper);
}
.ratio-toggle {
appearance: none;
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--paper);
font: inherit;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 9px 16px;
border-radius: 999px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.ratio-toggle:hover { border-color: var(--sand-d); }
.ratio-toggle .dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--sand);
box-shadow: 0 0 0 4px rgba(201, 183, 156, 0.16);
transition: transform 0.3s;
}
.ratio-toggle[aria-pressed="true"] .dot { transform: scale(1.35); background: var(--white); }
:focus-visible {
outline: 2px solid var(--sand);
outline-offset: 3px;
border-radius: var(--r-sm);
}
/* ---------- Grid ---------- */
.grid {
list-style: none;
margin: 30px 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.frame {
position: relative;
display: block;
width: 100%;
aspect-ratio: 3 / 4;
border: 0;
padding: 0;
border-radius: var(--r-md);
overflow: hidden;
cursor: pointer;
background: var(--surface-2);
text-align: left;
color: inherit;
font: inherit;
transition: aspect-ratio 0.5s ease;
transform: translateZ(0);
}
.grid.is-wide .frame { aspect-ratio: 16 / 11; }
.frame__img {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
transform: scale(1.001);
transition: transform 0.7s cubic-bezier(0.16, 1, 0.3, 1), filter 0.5s;
filter: saturate(0.92) brightness(0.92);
}
.frame:hover .frame__img,
.frame:focus-visible .frame__img {
transform: scale(1.08);
filter: saturate(1.05) brightness(1);
}
.frame__scrim {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(11, 11, 12, 0.86) 0%, rgba(11, 11, 12, 0.32) 40%, rgba(11, 11, 12, 0) 62%);
opacity: 0.55;
transition: opacity 0.5s;
}
.frame:hover .frame__scrim,
.frame:focus-visible .frame__scrim { opacity: 1; }
.frame__index {
position: absolute;
top: 12px;
left: 12px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--paper);
background: rgba(11, 11, 12, 0.5);
backdrop-filter: blur(4px);
border: 1px solid var(--line);
padding: 5px 8px;
border-radius: 999px;
z-index: 2;
}
/* caption reveal */
.frame__cap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 18px 16px 16px;
z-index: 2;
clip-path: inset(100% 0 0 0);
transition: clip-path 0.55s cubic-bezier(0.16, 1, 0.3, 1);
}
.frame:hover .frame__cap,
.frame:focus-visible .frame__cap {
clip-path: inset(0 0 0 0);
}
.frame__cap .cat,
.frame__cap h3,
.frame__cap .by {
opacity: 0;
transform: translateY(10px);
transition: opacity 0.45s ease, transform 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
.frame:hover .frame__cap .cat,
.frame:focus-visible .frame__cap .cat { opacity: 1; transform: none; transition-delay: 0.08s; }
.frame:hover .frame__cap h3,
.frame:focus-visible .frame__cap h3 { opacity: 1; transform: none; transition-delay: 0.16s; }
.frame:hover .frame__cap .by,
.frame:focus-visible .frame__cap .by { opacity: 1; transform: none; transition-delay: 0.24s; }
.frame__cap .cat {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--sand);
margin-bottom: 6px;
}
.frame__cap h3 {
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: 20px;
line-height: 1.1;
margin: 0;
color: var(--white);
}
.frame__cap .by {
margin: 6px 0 0;
font-size: 12px;
color: var(--muted);
}
/* thin sand hairline that draws across the bottom on hover */
.frame::after {
content: "";
position: absolute;
left: 16px;
right: 16px;
bottom: 0;
height: 2px;
background: var(--sand);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 3;
}
.frame:hover::after,
.frame:focus-visible::after { transform: scaleX(1); }
.empty {
color: var(--muted);
text-align: center;
padding: 60px 0;
font-size: 15px;
}
/* ---------- Footer ---------- */
.foot {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid var(--line);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
color: var(--muted);
font-size: 12px;
}
kbd {
font-family: inherit;
font-size: 11px;
background: var(--surface-2);
border: 1px solid var(--line-2);
border-radius: 5px;
padding: 1px 6px;
color: var(--paper);
}
/* ---------- Lightbox ---------- */
.lightbox {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 20px;
}
.lightbox[hidden] { display: none; }
.lightbox__scrim {
position: absolute;
inset: 0;
background: rgba(6, 6, 7, 0.82);
backdrop-filter: blur(6px);
animation: fade 0.3s ease;
}
.lightbox__panel {
position: relative;
margin: 0;
width: min(760px, 100%);
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
overflow: hidden;
animation: rise 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.lightbox__media {
aspect-ratio: 16 / 10;
background-size: cover;
background-position: center;
}
.lightbox__cap {
padding: 22px 24px 18px;
}
.lightbox__cap .cat {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--sand);
}
.lightbox__cap h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: clamp(24px, 4vw, 34px);
margin: 8px 0 6px;
line-height: 1.05;
}
.lightbox__cap p { margin: 0; color: var(--muted); font-size: 14px; }
.lightbox__close {
position: absolute;
top: 12px;
right: 12px;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid var(--line-2);
background: rgba(11, 11, 12, 0.6);
color: var(--paper);
font-size: 22px;
line-height: 1;
cursor: pointer;
z-index: 4;
transition: background 0.2s, transform 0.12s;
}
.lightbox__close:hover { background: var(--surface-2); }
.lightbox__close:active { transform: scale(0.94); }
.lightbox__nav {
display: flex;
gap: 10px;
padding: 0 24px 22px;
}
.navbtn {
flex: 1;
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--paper);
font-size: 16px;
padding: 11px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.navbtn:hover { background: var(--surface); border-color: var(--sand-d); }
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 20px);
background: var(--paper);
color: var(--ink);
font-size: 13px;
font-weight: 600;
padding: 11px 18px;
border-radius: 999px;
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.4);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s, transform 0.3s;
z-index: 60;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
/* ---------- Responsive ---------- */
@media (max-width: 820px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 520px) {
.grid { grid-template-columns: 1fr; gap: 14px; }
.controls { flex-direction: column; align-items: stretch; }
.ratio-toggle { justify-content: center; }
.frame { aspect-ratio: 4 / 5; }
.grid.is-wide .frame { aspect-ratio: 3 / 2; }
/* On touch, keep captions visible so content is reachable without hover */
.frame__cap { clip-path: inset(0 0 0 0); }
.frame__cap .cat,
.frame__cap h3,
.frame__cap .by { opacity: 1; transform: none; }
.frame__scrim { opacity: 1; }
.foot { flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}(function () {
"use strict";
// Fictional archive. Unsplash source URLs are used as royalty-free placeholders.
var PHOTOS = [
{ id: 1, title: "Salt Flats at Dawn", by: "Mara Okonkwo", cat: "landscape",
img: "1500534623283-312aade485b7", loc: "Uyuni, 05:12" },
{ id: 2, title: "The Watchmaker", by: "Elias Vaughn", cat: "portrait",
img: "1552058544-f2b08422138a", loc: "Studio 9" },
{ id: 3, title: "Rain on 6th", by: "Junko Halvorsen", cat: "street",
img: "1519681393784-d120267933ba", loc: "Downtown" },
{ id: 4, title: "Concrete Cathedral", by: "Theo Marchetti", cat: "architecture",
img: "1487958449943-2429e8be8625", loc: "Brutalist Quarter" },
{ id: 5, title: "Fjord Silence", by: "Mara Okonkwo", cat: "landscape",
img: "1441974231531-c6227db76b6e", loc: "Northern Coast" },
{ id: 6, title: "Amber Hour", by: "Priya Anand", cat: "portrait",
img: "1531123897727-8f129e1688ce", loc: "Rooftop" },
{ id: 7, title: "Neon Crossing", by: "Junko Halvorsen", cat: "street",
img: "1493514789931-586cb221d7a7", loc: "District 12" },
{ id: 8, title: "Stairwell Spiral", by: "Theo Marchetti", cat: "architecture",
img: "1470071459604-3b5ec3a7fe05", loc: "Municipal Tower" },
{ id: 9, title: "Low Tide Mirror", by: "Sven Alcaraz", cat: "landscape",
img: "1470252649378-9c29740c9fa8", loc: "The Estuary" }
];
var UNSPLASH = "https://images.unsplash.com/photo-";
function imgUrl(id, w) {
return UNSPLASH + id + "?auto=format&fit=crop&w=" + w + "&q=70";
}
function catLabel(c) { return c.charAt(0).toUpperCase() + c.slice(1); }
var grid = document.getElementById("grid");
var empty = document.getElementById("empty");
var shotCount = document.getElementById("shotCount");
var toastEl = document.getElementById("toast");
var ratioToggle = document.getElementById("ratioToggle");
var ratioLabel = document.getElementById("ratioLabel");
var currentFilter = "all";
var visible = [];
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
function render() {
visible = PHOTOS.filter(function (p) {
return currentFilter === "all" || p.cat === currentFilter;
});
grid.innerHTML = "";
visible.forEach(function (p, i) {
var li = document.createElement("li");
var btn = document.createElement("button");
btn.className = "frame";
btn.type = "button";
btn.setAttribute("data-id", String(p.id));
btn.setAttribute("aria-label",
p.title + ", " + catLabel(p.cat) + " by " + p.by + ". Open viewer.");
var media = document.createElement("span");
media.className = "frame__img";
media.style.backgroundImage = "url('" + imgUrl(p.img, 640) + "')";
var scrim = document.createElement("span");
scrim.className = "frame__scrim";
var idx = document.createElement("span");
idx.className = "frame__index";
idx.textContent = String(i + 1).padStart(2, "0");
var cap = document.createElement("span");
cap.className = "frame__cap";
cap.innerHTML =
'<span class="cat">' + catLabel(p.cat) + "</span>" +
"<h3>" + p.title + "</h3>" +
'<span class="by">' + p.by + " · " + p.loc + "</span>";
btn.appendChild(media);
btn.appendChild(scrim);
btn.appendChild(idx);
btn.appendChild(cap);
btn.addEventListener("click", function () { openLightbox(p.id); });
li.appendChild(btn);
grid.appendChild(li);
});
empty.hidden = visible.length !== 0;
var n = visible.length;
shotCount.textContent = n + " shot" + (n === 1 ? "" : "s");
}
// ---- Filter chips ----
var chips = Array.prototype.slice.call(document.querySelectorAll(".chip"));
chips.forEach(function (chip) {
chip.addEventListener("click", function () {
chips.forEach(function (c) {
c.classList.remove("is-active");
c.setAttribute("aria-pressed", "false");
});
chip.classList.add("is-active");
chip.setAttribute("aria-pressed", "true");
currentFilter = chip.getAttribute("data-filter");
render();
toast(currentFilter === "all"
? "Showing all frames"
: "Filtered · " + catLabel(currentFilter));
});
});
// ---- Aspect ratio toggle ----
var wide = false;
ratioToggle.addEventListener("click", function () {
wide = !wide;
grid.classList.toggle("is-wide", wide);
ratioToggle.setAttribute("aria-pressed", String(wide));
ratioLabel.textContent = wide ? "Landscape frames" : "Portrait frames";
toast(wide ? "Landscape crop" : "Portrait crop");
});
// ---- Lightbox ----
var lb = document.getElementById("lightbox");
var lbMedia = document.getElementById("lbMedia");
var lbCat = document.getElementById("lbCat");
var lbTitle = document.getElementById("lbTitle");
var lbMeta = document.getElementById("lbMeta");
var lbPrev = document.getElementById("lbPrev");
var lbNext = document.getElementById("lbNext");
var lbIndex = 0;
var lastFocused = null;
function openLightbox(id) {
lbIndex = visible.findIndex(function (p) { return p.id === id; });
if (lbIndex < 0) lbIndex = 0;
lastFocused = document.activeElement;
fillLightbox();
lb.hidden = false;
document.body.style.overflow = "hidden";
document.getElementById("lbNext").focus();
}
function fillLightbox() {
var p = visible[lbIndex];
if (!p) return;
lbMedia.style.backgroundImage = "url('" + imgUrl(p.img, 1200) + "')";
lbCat.textContent = catLabel(p.cat);
lbTitle.textContent = p.title;
lbMeta.textContent = p.by + " · " + p.loc + " · Frame "
+ (lbIndex + 1) + " of " + visible.length;
}
function step(dir) {
if (!visible.length) return;
lbIndex = (lbIndex + dir + visible.length) % visible.length;
fillLightbox();
}
function closeLightbox() {
lb.hidden = true;
document.body.style.overflow = "";
if (lastFocused && lastFocused.focus) lastFocused.focus();
}
lbPrev.addEventListener("click", function () { step(-1); });
lbNext.addEventListener("click", function () { step(1); });
Array.prototype.slice.call(lb.querySelectorAll("[data-close]")).forEach(function (el) {
el.addEventListener("click", closeLightbox);
});
document.addEventListener("keydown", function (e) {
if (lb.hidden) return;
if (e.key === "Escape") closeLightbox();
else if (e.key === "ArrowRight") step(1);
else if (e.key === "ArrowLeft") step(-1);
});
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aperture — Hover-Reveal Photo Grid</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@400;600&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="wrap">
<header class="masthead">
<div class="masthead__top">
<span class="kicker">Aperture — Vol. 04</span>
<span class="kicker kicker--muted" id="shotCount">— shots</span>
</div>
<h1 class="title">The Long Exposure</h1>
<p class="lede">A hover-reveal gallery of fictional field work. Point at a frame to draw the caption; open one for the full plate.</p>
<div class="controls" role="region" aria-label="Gallery controls">
<div class="chips" role="group" aria-label="Filter by category">
<button class="chip is-active" data-filter="all" aria-pressed="true">All</button>
<button class="chip" data-filter="landscape" aria-pressed="false">Landscape</button>
<button class="chip" data-filter="portrait" aria-pressed="false">Portrait</button>
<button class="chip" data-filter="street" aria-pressed="false">Street</button>
<button class="chip" data-filter="architecture" aria-pressed="false">Architecture</button>
</div>
<button class="ratio-toggle" id="ratioToggle" aria-pressed="false">
<span class="dot" aria-hidden="true"></span>
<span id="ratioLabel">Portrait frames</span>
</button>
</div>
</header>
<main>
<ul class="grid" id="grid" aria-label="Photograph grid"></ul>
<p class="empty" id="empty" hidden>No frames in this category yet.</p>
</main>
<footer class="foot">
<span>Fictional archive · Stealthis</span>
<span>Press <kbd>Esc</kbd> to close · <kbd>←</kbd> <kbd>→</kbd> to browse</span>
</footer>
</div>
<!-- Lightbox -->
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-labelledby="lbTitle" hidden>
<div class="lightbox__scrim" data-close></div>
<figure class="lightbox__panel">
<button class="lightbox__close" data-close aria-label="Close viewer">×</button>
<div class="lightbox__media" id="lbMedia"></div>
<figcaption class="lightbox__cap">
<span class="cat" id="lbCat"></span>
<h2 id="lbTitle"></h2>
<p id="lbMeta"></p>
</figcaption>
<div class="lightbox__nav">
<button class="navbtn" id="lbPrev" aria-label="Previous photograph">←</button>
<button class="navbtn" id="lbNext" aria-label="Next photograph">→</button>
</div>
</figure>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>A gallery-grade photo grid built for portfolios and editorial sites. Each tile holds a full-bleed image behind a caption panel that is clipped out of view until interaction. On hover or keyboard focus the image scales and lifts, a soft gradient scrim deepens, and the caption wipes upward via an animated clip-path, with the title, photographer credit and category label fading in on a short staggered cascade so the type reads like a curtain being drawn.
Above the grid, uppercase category chips filter the collection with an animated count, an aspect toggle switches every frame between a tall portrait ratio and a wide landscape ratio, and a running counter reports how many shots are currently on view. Clicking any frame opens a minimal lightbox with the full caption and Escape-to-close plus arrow-key navigation.
The whole component ships as three files with no dependencies. Interactions are keyboard reachable with visible focus rings, contrast meets WCAG AA against the dark surface, reduced-motion preferences collapse the animations, and a responsive breakpoint reflows the grid to a single column at phone widths.