Photography — Client Proofing
A gallery-dark client proofing board where couples review every frame from a shoot, tap the heart to favorite a proof or the checkbox to select it for retouching, watch a live selected counter tick as a sticky action bar, filter between Favorites and All, and fire a download-selection button that confirms with a toast — full keyboard support and buttery hover reveals throughout.
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;
}
.wrap {
max-width: 1180px;
margin: 0 auto;
padding: 28px 32px 64px;
}
.label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--paper);
}
.muted { color: var(--muted); }
/* Masthead */
.masthead {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark { color: var(--sand); font-size: 20px; }
.brand-name {
font-family: "Fraunces", Georgia, serif;
font-size: 19px;
font-weight: 600;
letter-spacing: 0.01em;
}
.mast-nav { display: flex; align-items: center; gap: 10px; }
.mast-nav .dot { color: var(--line-2); }
/* Intro */
.intro { padding: 44px 0 30px; max-width: 640px; }
.eyebrow {
margin: 0 0 14px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--sand);
}
.intro h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 400;
font-size: clamp(30px, 5vw, 46px);
line-height: 1.08;
margin: 0 0 16px;
letter-spacing: -0.01em;
}
.lede { margin: 0; color: var(--muted); font-size: 15.5px; max-width: 520px; }
/* Sticky proof bar */
.proofbar {
position: sticky;
top: 12px;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 14px 18px;
background: rgba(23, 23, 27, 0.82);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: 0 20px 50px -30px rgba(0, 0, 0, 0.9);
}
.proofbar-stats { display: flex; align-items: center; gap: 20px; }
.stat { display: flex; align-items: baseline; gap: 8px; }
.stat-num {
font-family: "Fraunces", Georgia, serif;
font-size: 26px;
font-weight: 600;
line-height: 1;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.stat-num.bump { transform: scale(1.28); color: var(--sand); }
.stat-lbl {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
}
.stat-sep { width: 1px; height: 26px; background: var(--line-2); }
.proofbar-actions { display: flex; align-items: center; gap: 10px; }
.segmented {
display: inline-flex;
padding: 3px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
}
.seg {
appearance: none;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 13px;
font-weight: 600;
padding: 7px 16px;
border-radius: 999px;
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.seg.is-active { background: var(--paper); color: var(--ink); }
.seg:not(.is-active):hover { color: var(--paper); }
.btn-ghost {
appearance: none;
border: 1px solid var(--line-2);
background: transparent;
color: var(--paper);
font: inherit;
font-size: 13px;
font-weight: 600;
padding: 9px 15px;
border-radius: 999px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.btn-ghost:hover { background: var(--surface-2); border-color: var(--paper); }
.btn-primary {
appearance: none;
border: 0;
background: var(--sand);
color: var(--ink);
font: inherit;
font-size: 13px;
font-weight: 700;
padding: 10px 18px;
border-radius: 999px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 7px;
transition: background 0.2s, transform 0.12s, box-shadow 0.2s;
box-shadow: 0 10px 24px -12px rgba(201, 183, 156, 0.7);
}
.btn-primary:hover { background: var(--white); }
.btn-primary:active { transform: translateY(1px); }
.btn-primary:disabled {
background: var(--surface-2);
color: var(--muted);
cursor: not-allowed;
box-shadow: none;
}
:where(button, a):focus-visible {
outline: 2px solid var(--sand);
outline-offset: 3px;
}
/* Grid */
.grid {
list-style: none;
margin: 26px 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 16px;
}
.proof {
position: relative;
border-radius: var(--r-md);
overflow: hidden;
background: var(--surface);
border: 1px solid var(--line);
aspect-ratio: 4 / 5;
transition: transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 0.35s, border-color 0.25s;
}
.proof.tall { aspect-ratio: 4 / 6; }
.proof.wide { aspect-ratio: 5 / 4; }
.proof-img {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
transition: transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1), filter 0.35s;
}
.proof:hover .proof-img { transform: scale(1.05); }
.proof-shade {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(11, 11, 12, 0.45) 0%, transparent 26%, transparent 55%, rgba(11, 11, 12, 0.72) 100%);
opacity: 0.85;
transition: opacity 0.3s;
}
.proof:hover .proof-shade { opacity: 1; }
.proof-meta {
position: absolute;
left: 12px;
bottom: 11px;
right: 12px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 8px;
transform: translateY(6px);
opacity: 0;
transition: transform 0.3s, opacity 0.3s;
}
.proof:hover .proof-meta,
.proof:focus-within .proof-meta,
.proof.is-selected .proof-meta { transform: translateY(0); opacity: 1; }
.proof-id {
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--paper);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}
/* Selection sequence stamp */
.proof-seq {
position: absolute;
top: 11px;
left: 11px;
min-width: 26px;
height: 26px;
padding: 0 7px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--sand);
color: var(--ink);
font-size: 12px;
font-weight: 800;
transform: scale(0);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 6px 16px -6px rgba(0, 0, 0, 0.7);
}
.proof.is-selected .proof-seq { transform: scale(1); }
/* Controls */
.ctrl {
appearance: none;
border: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--paper);
transition: transform 0.15s, background 0.2s, color 0.2s, border-color 0.2s;
}
.heart {
position: absolute;
top: 10px;
right: 10px;
width: 38px;
height: 38px;
border-radius: 999px;
background: rgba(11, 11, 12, 0.45);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid var(--line-2);
font-size: 17px;
transform: translateY(-6px);
opacity: 0;
}
.proof:hover .heart,
.proof:focus-within .heart,
.proof.is-fav .heart { transform: translateY(0); opacity: 1; }
.heart:hover { transform: translateY(0) scale(1.12); }
.heart.is-on {
background: rgba(201, 183, 156, 0.16);
border-color: var(--sand);
color: #ff5a7a;
}
.heart.pop { animation: pop 0.35s ease; }
.check {
width: auto;
height: 34px;
padding: 0 12px 0 9px;
border-radius: 999px;
gap: 7px;
background: rgba(11, 11, 12, 0.5);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid var(--line-2);
font-size: 12px;
font-weight: 600;
}
.check .box {
width: 17px;
height: 17px;
border-radius: 5px;
border: 1.5px solid var(--paper);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.check:hover { border-color: var(--paper); }
.check.is-on { background: var(--paper); color: var(--ink); }
.check.is-on .box { background: var(--ink); border-color: var(--ink); color: var(--sand); }
.proof.is-selected {
border-color: var(--sand);
box-shadow: 0 0 0 2px var(--sand), 0 26px 50px -28px rgba(0, 0, 0, 0.9);
transform: translateY(-3px);
}
@keyframes pop {
0% { transform: translateY(0) scale(1); }
45% { transform: translateY(0) scale(1.35); }
100% { transform: translateY(0) scale(1); }
}
/* Empty state */
.empty {
text-align: center;
padding: 80px 20px;
border: 1px dashed var(--line-2);
border-radius: var(--r-lg);
margin-top: 26px;
}
.empty-mark { font-size: 34px; color: var(--sand); display: block; margin-bottom: 12px; }
.empty-title { font-family: "Fraunces", serif; font-size: 22px; margin: 0 0 6px; }
.empty-sub { margin: 0; color: var(--muted); }
/* Footer */
.foot {
margin-top: 48px;
padding-top: 22px;
border-top: 1px solid var(--line);
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12.5px;
color: var(--paper);
}
.foot .muted { font-size: 12px; }
/* Toast */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--paper);
color: var(--ink);
font-size: 13.5px;
font-weight: 600;
padding: 12px 20px;
border-radius: 999px;
box-shadow: 0 20px 40px -18px rgba(0, 0, 0, 0.8);
opacity: 0;
pointer-events: none;
transition: transform 0.35s cubic-bezier(0.34, 1.4, 0.64, 1), opacity 0.35s;
z-index: 60;
}
.toast.show { transform: translate(-50%, 0); opacity: 1; }
/* Responsive */
@media (max-width: 820px) {
.proofbar { flex-direction: column; align-items: stretch; }
.proofbar-actions { justify-content: space-between; }
}
@media (max-width: 520px) {
.wrap { padding: 20px 16px 48px; }
.intro { padding: 30px 0 22px; }
.grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.proof, .proof.tall, .proof.wide { aspect-ratio: 3 / 4; }
.mast-nav .label.muted { display: none; }
.proofbar-actions { flex-wrap: wrap; gap: 8px; }
.btn-primary { flex: 1; justify-content: center; }
.segmented { flex: 1; }
.seg { flex: 1; }
.check .lbl { display: none; }
.check { padding: 0 9px; }
.foot { flex-direction: column; gap: 6px; }
}(function () {
"use strict";
// --- Data: fictional proofs from a wedding shoot ---
var IMAGES = [
"1519741497674-611481863552",
"1511285560929-80b456fea0bc",
"1520854221256-17451cc331bf",
"1465495976277-4387d4b0b4c6",
"1522673607200-164d1b6ce486",
"1519225421980-715cb0215aed",
"1583939003579-730e3918a45a",
"1606216794074-735e91aa2c92",
"1537633552985-df8429e8048b",
"1494955870715-970a55b95a63",
"1460978812857-470ed1c77af0",
"1591604466107-ec97de577aff",
"1546032996-6dfacbacbf3f",
"1525258946800-98cfd641d0de",
"1519671482749-fd09be7ccebf",
"1511795409834-ef04bbd61622",
"1470163395405-d2b80e7450ed",
"1481066717861-df19a2a75f5c"
];
var SHAPES = ["", "tall", "wide", "", "", "tall", "", "wide", "", "", "tall", "", "wide", "", "", "tall", "", ""];
var proofs = IMAGES.map(function (id, i) {
return {
id: "AV-" + String(2401 + i),
url: "https://images.unsplash.com/photo-" + id + "?auto=format&fit=crop&w=640&q=70",
shape: SHAPES[i % SHAPES.length],
fav: false,
sel: false
};
});
// --- Elements ---
var grid = document.getElementById("grid");
var empty = document.getElementById("empty");
var selCountEl = document.getElementById("selCount");
var favCountEl = document.getElementById("favCount");
var filterAll = document.getElementById("filterAll");
var filterFav = document.getElementById("filterFav");
var downloadBtn = document.getElementById("downloadBtn");
var clearBtn = document.getElementById("clearBtn");
var toastEl = document.getElementById("toast");
var filter = "all"; // "all" | "fav"
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2600);
}
function bump(el) {
el.classList.remove("bump");
// force reflow to restart the animation
void el.offsetWidth;
el.classList.add("bump");
}
// --- Build cards ---
function render() {
grid.innerHTML = "";
var visible = proofs.filter(function (p) {
return filter === "all" || p.fav;
});
if (visible.length === 0) {
grid.hidden = true;
empty.hidden = false;
} else {
grid.hidden = false;
empty.hidden = true;
}
visible.forEach(function (p) {
var li = document.createElement("li");
li.className = "proof" + (p.shape ? " " + p.shape : "");
if (p.fav) li.classList.add("is-fav");
if (p.sel) li.classList.add("is-selected");
li.dataset.id = p.id;
var img = document.createElement("div");
img.className = "proof-img";
img.style.backgroundImage = "url('" + p.url + "')";
var shade = document.createElement("div");
shade.className = "proof-shade";
var seq = document.createElement("div");
seq.className = "proof-seq";
seq.setAttribute("aria-hidden", "true");
// heart
var heart = document.createElement("button");
heart.type = "button";
heart.className = "ctrl heart" + (p.fav ? " is-on" : "");
heart.innerHTML = p.fav ? "♥" : "♡";
heart.setAttribute("aria-pressed", String(p.fav));
heart.setAttribute("aria-label", (p.fav ? "Remove favorite" : "Add favorite") + " " + p.id);
heart.addEventListener("click", function (e) {
e.stopPropagation();
toggleFav(p, heart, li);
});
// checkbox / select
var check = document.createElement("button");
check.type = "button";
check.className = "ctrl check" + (p.sel ? " is-on" : "");
check.setAttribute("aria-pressed", String(p.sel));
check.setAttribute("aria-label", (p.sel ? "Deselect" : "Select for retouch") + " " + p.id);
check.innerHTML = '<span class="box" aria-hidden="true">✓</span><span class="lbl">' +
(p.sel ? "Selected" : "Select") + "</span>";
check.addEventListener("click", function (e) {
e.stopPropagation();
toggleSel(p, check, li);
});
var meta = document.createElement("div");
meta.className = "proof-meta";
var pid = document.createElement("span");
pid.className = "proof-id";
pid.textContent = p.id;
meta.appendChild(pid);
li.appendChild(img);
li.appendChild(shade);
li.appendChild(seq);
li.appendChild(heart);
li.appendChild(check);
li.appendChild(meta);
grid.appendChild(li);
});
updateSeq();
updateCounts();
}
function toggleFav(p, heart, li) {
p.fav = !p.fav;
heart.classList.toggle("is-on", p.fav);
heart.innerHTML = p.fav ? "♥" : "♡";
heart.setAttribute("aria-pressed", String(p.fav));
heart.classList.remove("pop");
void heart.offsetWidth;
if (p.fav) heart.classList.add("pop");
li.classList.toggle("is-fav", p.fav);
bump(favCountEl);
updateCounts();
// If viewing favorites and this was un-favorited, drop it from the grid
if (filter === "fav" && !p.fav) {
setTimeout(render, 180);
}
}
function toggleSel(p, check, li) {
p.sel = !p.sel;
check.classList.toggle("is-on", p.sel);
check.setAttribute("aria-pressed", String(p.sel));
check.querySelector(".lbl").textContent = p.sel ? "Selected" : "Select";
li.classList.toggle("is-selected", p.sel);
bump(selCountEl);
updateSeq();
updateCounts();
}
// Number selected cards in selection order
function updateSeq() {
var order = 0;
var selectedIds = proofs.filter(function (p) { return p.sel; }).map(function (p) { return p.id; });
var map = {};
selectedIds.forEach(function (id, i) { map[id] = i + 1; });
Array.prototype.forEach.call(grid.children, function (li) {
var seq = li.querySelector(".proof-seq");
if (!seq) return;
var n = map[li.dataset.id];
seq.textContent = n ? n : "";
});
order = selectedIds.length;
return order;
}
function updateCounts() {
var sel = proofs.filter(function (p) { return p.sel; }).length;
var fav = proofs.filter(function (p) { return p.fav; }).length;
selCountEl.textContent = sel;
favCountEl.textContent = fav;
downloadBtn.disabled = sel === 0;
clearBtn.disabled = sel === 0 && fav === 0;
}
// --- Filter ---
function setFilter(next) {
filter = next;
var isAll = next === "all";
filterAll.classList.toggle("is-active", isAll);
filterFav.classList.toggle("is-active", !isAll);
filterAll.setAttribute("aria-pressed", String(isAll));
filterFav.setAttribute("aria-pressed", String(!isAll));
render();
}
filterAll.addEventListener("click", function () { setFilter("all"); });
filterFav.addEventListener("click", function () { setFilter("fav"); });
downloadBtn.addEventListener("click", function () {
var sel = proofs.filter(function (p) { return p.sel; });
if (sel.length === 0) return;
toast("Preparing " + sel.length + " high-res file" + (sel.length === 1 ? "" : "s") + " for download…");
});
clearBtn.addEventListener("click", function () {
var had = proofs.some(function (p) { return p.sel || p.fav; });
proofs.forEach(function (p) { p.sel = false; p.fav = false; });
if (filter === "fav") filter = "all", setFilter("all");
else render();
if (had) toast("Selection and favorites cleared");
});
// Pre-seed a couple of picks so the UI feels alive on load
proofs[2].fav = true;
proofs[2].sel = true;
proofs[7].fav = true;
proofs[10].sel = true;
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aster & Vale — Client Proofing</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="brand">
<span class="brand-mark" aria-hidden="true">◐</span>
<span class="brand-name">Aster & Vale</span>
</div>
<nav class="mast-nav" aria-label="Studio">
<span class="label">Proofing gallery</span>
<span class="dot" aria-hidden="true">·</span>
<span class="label muted">Ref AV-2417</span>
</nav>
</header>
<section class="intro">
<p class="eyebrow">Wedding · June 2026 · Cap Ferrat</p>
<h1>Elena & Marco — First Selection</h1>
<p class="lede">
Take your time. Heart the frames you love, then tick the ones you want us to
retouch. Your picks save as you go — send them over when you're ready.
</p>
</section>
<!-- Sticky action / selection bar -->
<div class="proofbar" role="region" aria-label="Selection actions">
<div class="proofbar-stats">
<div class="stat">
<span class="stat-num" id="selCount" aria-live="polite">0</span>
<span class="stat-lbl">selected</span>
</div>
<span class="stat-sep" aria-hidden="true"></span>
<div class="stat">
<span class="stat-num" id="favCount" aria-live="polite">0</span>
<span class="stat-lbl">favorites</span>
</div>
</div>
<div class="proofbar-actions">
<div class="segmented" role="group" aria-label="Filter proofs">
<button class="seg is-active" id="filterAll" aria-pressed="true">All</button>
<button class="seg" id="filterFav" aria-pressed="false">Favorites</button>
</div>
<button class="btn-ghost" id="clearBtn" type="button">Clear</button>
<button class="btn-primary" id="downloadBtn" type="button">
<span aria-hidden="true">↓</span>
Download selection
</button>
</div>
</div>
<!-- Gallery -->
<main>
<ul class="grid" id="grid" aria-label="Proofs"></ul>
<div class="empty" id="empty" hidden>
<span class="empty-mark" aria-hidden="true">♡</span>
<p class="empty-title">No favorites yet</p>
<p class="empty-sub">Tap the heart on a frame to start your shortlist.</p>
</div>
</main>
<footer class="foot">
<span>Aster & Vale Studio</span>
<span class="muted">Proofs are watermarked previews · final files delivered in full resolution</span>
</footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>A full-bleed proofing gallery built for the moment a client sits down to choose their favorites. Each proof sits in a dense masonry-leaning grid; hovering (or focusing) a card reveals a heart to favorite the image and a rounded checkbox to add it to the retouch selection. Selected cards lift, gain a sand-colored ring, and stamp a sequence number so clients can see exactly what they picked.
A sticky action bar tracks the running total — “X selected” — alongside a favorites tally, a segmented Favorites / All filter, and a primary Download selection button. The filter re-flows the grid instantly, the counter animates on every change, and an empty-favorites state gently nudges the client to start hearting frames.
Everything is keyboard reachable: hearts and checkboxes are real buttons with visible focus rings, the filter uses aria-pressed, and actions surface confirmation through a small toast helper. The palette leans into the gallery-dark theme with a Fraunces display serif for headings and thin uppercase labels spaced out over image-first surfaces.