Photography — Fullscreen Slideshow
A gallery-dark, full-bleed slideshow that crossfades between frames with a slow Ken-Burns zoom, auto-advancing on a timed loop with a live progress ring, tappable dot navigation, keyboard arrows, and an elegant caption card naming each photograph, its location, and the shutter speed behind it.
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;
--dwell:7s;
}
*,*::before,*::after{box-sizing:border-box;}
html,body{height:100%;}
body{
margin:0;
background: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;
overflow:hidden;
}
/* ---------- Stage ---------- */
.stage{
position:relative;
height:100vh;
height:100dvh;
width:100%;
overflow:hidden;
background:var(--ink);
}
/* ---------- Slides ---------- */
.slides{position:absolute;inset:0;}
.slide{
position:absolute;
inset:0;
margin:0;
opacity:0;
visibility:hidden;
transition:opacity 1.2s ease;
will-change:opacity;
}
.slide.is-active{
opacity:1;
visibility:visible;
z-index:1;
}
.slide-img{
position:absolute;
inset:0;
background-size:cover;
background-position:center;
transform:scale(1.02);
}
/* Ken-Burns only on the active slide while playing */
.slide.is-active.kb .slide-img{
animation:kenburns var(--dwell) linear both;
}
@keyframes kenburns{
from{transform:scale(1.02) translate3d(0,0,0);}
to{transform:scale(1.14) translate3d(-1.5%,-2%,0);}
}
/* bottom + top gradient for legibility */
.scrim{
position:absolute;
inset:0;
z-index:2;
pointer-events:none;
background:
linear-gradient(to top, rgba(11,11,12,0.82) 0%, rgba(11,11,12,0.28) 32%, rgba(11,11,12,0) 55%),
linear-gradient(to bottom, rgba(11,11,12,0.55) 0%, rgba(11,11,12,0) 24%);
}
/* ---------- Top bar ---------- */
.topbar{
position:absolute;
top:0;left:0;right:0;
z-index:5;
display:flex;
align-items:center;
justify-content:space-between;
padding:26px 34px;
}
.brand{display:flex;align-items:center;gap:12px;}
.brand-mark{
width:22px;height:22px;
border:1.5px solid var(--sand);
border-radius:50%;
position:relative;
}
.brand-mark::after{
content:"";
position:absolute;
inset:6px;
background:var(--sand);
border-radius:50%;
}
.brand-name{
font-weight:700;
font-size:.78rem;
letter-spacing:.32em;
color:var(--paper);
}
.counter{
font-family:"Fraunces",Georgia,serif;
font-size:.92rem;
letter-spacing:.14em;
color:var(--paper);
display:flex;
align-items:baseline;
gap:6px;
}
.counter-sep{color:var(--sand);}
#counter-total{color:var(--muted);}
/* ---------- Caption ---------- */
.caption{
position:absolute;
left:34px;
bottom:120px;
z-index:5;
max-width:min(560px,72vw);
}
.caption > *{
animation:capIn .9s cubic-bezier(.2,.7,.2,1) both;
}
.caption .cap-title{animation-delay:.06s;}
.caption .cap-meta{animation-delay:.12s;}
@keyframes capIn{
from{opacity:0;transform:translateY(14px);}
to{opacity:1;transform:translateY(0);}
}
.eyebrow{
margin:0 0 12px;
font-size:.68rem;
font-weight:600;
letter-spacing:.34em;
text-transform:uppercase;
color:var(--sand);
}
.cap-title{
margin:0;
font-family:"Fraunces",Georgia,serif;
font-weight:600;
font-size:clamp(1.9rem,4.6vw,3.4rem);
line-height:1.08;
letter-spacing:-.01em;
color:var(--white);
text-shadow:0 2px 30px rgba(0,0,0,.4);
}
.cap-meta{
margin:16px 0 0;
display:flex;
align-items:center;
gap:12px;
flex-wrap:wrap;
font-size:.86rem;
color:var(--paper);
opacity:.9;
}
.cap-meta .dot{
width:4px;height:4px;border-radius:50%;
background:var(--sand-d);
flex:none;
}
#cap-shot{
font-variant-numeric:tabular-nums;
color:var(--muted);
}
/* ---------- Controls ---------- */
.controls{
position:absolute;
right:34px;
bottom:120px;
z-index:6;
display:flex;
align-items:center;
gap:14px;
}
.nav,.play{
border:1px solid var(--line-2);
background:rgba(23,23,27,.55);
backdrop-filter:blur(8px);
color:var(--paper);
cursor:pointer;
display:grid;
place-items:center;
transition:background .2s ease,border-color .2s ease,transform .12s ease,color .2s ease;
}
.nav{
width:46px;height:46px;
border-radius:50%;
}
.nav:hover{background:rgba(30,30,35,.85);border-color:var(--sand);color:var(--sand);}
.nav:active{transform:scale(.94);}
.play{
position:relative;
width:60px;height:60px;
border-radius:50%;
background:rgba(23,23,27,.45);
}
.play:hover{border-color:var(--sand);}
.play:active{transform:scale(.95);}
.ring{
position:absolute;
inset:0;
width:60px;height:60px;
transform:rotate(-90deg);
}
.ring-bg{fill:none;stroke:var(--line);stroke-width:2;}
.ring-fg{
fill:none;
stroke:var(--sand);
stroke-width:2;
stroke-linecap:round;
stroke-dasharray:125.66;
stroke-dashoffset:125.66;
}
.play-glyph{
width:14px;height:16px;
background:var(--paper);
/* pause bars via mask */
clip-path:polygon(0 0,38% 0,38% 100%,0 100%,0 0,62% 0,100% 0,100% 100%,62% 100%,62% 0);
transition:background .2s ease;
}
.play:hover .play-glyph{background:var(--sand);}
/* paused state → show a play triangle */
.play.is-paused .play-glyph{
width:0;height:0;
background:transparent;
clip-path:none;
border-left:15px solid var(--paper);
border-top:9px solid transparent;
border-bottom:9px solid transparent;
margin-left:4px;
}
.play.is-paused:hover .play-glyph{border-left-color:var(--sand);}
/* freeze ring animation when paused handled in JS */
/* focus */
.nav:focus-visible,.play:focus-visible,.dot:focus-visible{
outline:2px solid var(--sand);
outline-offset:3px;
}
/* ---------- Dots ---------- */
.dots{
position:absolute;
left:0;right:0;
bottom:44px;
z-index:6;
display:flex;
justify-content:center;
gap:14px;
}
.dot{
position:relative;
width:46px;height:3px;
padding:12px 0;
border:none;
background:none;
cursor:pointer;
}
.dot::before{
content:"";
position:absolute;
left:0;top:50%;
transform:translateY(-50%);
width:100%;height:3px;
border-radius:99px;
background:var(--line-2);
transition:background .25s ease;
}
.dot:hover::before{background:var(--sand-d);}
.dot .fill{
position:absolute;
left:0;top:50%;
transform:translateY(-50%) scaleX(0);
transform-origin:left center;
width:100%;height:3px;
border-radius:99px;
background:var(--sand);
}
.dot.is-active .fill{
transform-origin:left center;
}
/* ---------- Toast ---------- */
.toast{
position:absolute;
left:50%;top:88px;
transform:translate(-50%,-14px);
z-index:20;
padding:9px 18px;
border-radius:99px;
background:rgba(23,23,27,.9);
border:1px solid var(--line-2);
color:var(--paper);
font-size:.8rem;
font-weight:500;
letter-spacing:.01em;
backdrop-filter:blur(10px);
opacity:0;
pointer-events:none;
transition:opacity .25s ease,transform .25s ease;
}
.toast.show{opacity:1;transform:translate(-50%,0);}
/* ---------- Responsive ---------- */
@media (max-width:820px){
.caption{bottom:150px;}
.controls{bottom:150px;}
}
@media (max-width:520px){
.topbar{padding:18px 20px;}
.brand-name{font-size:.66rem;letter-spacing:.24em;}
.caption{
left:20px;right:20px;
bottom:180px;
max-width:none;
}
.cap-meta{gap:8px;font-size:.8rem;}
.controls{
left:20px;right:20px;
bottom:120px;
justify-content:center;
}
.dots{bottom:30px;gap:10px;}
.dot{width:30px;}
.toast{top:74px;}
}
@media (prefers-reduced-motion:reduce){
.slide.is-active.kb .slide-img{animation:none;transform:scale(1.04);}
.caption > *{animation:none;}
.slide{transition:opacity .4s ease;}
}(function () {
"use strict";
// ---------- Data ----------
var SLIDES = [
{
eyebrow: "Wilderness Series",
title: "Vellum Ridge at First Light",
loc: "Dolomites, Italy",
shot: "1/250s · f/8 · ISO 100",
},
{
eyebrow: "Wilderness Series",
title: "The Long Green Silence",
loc: "Trollstigen, Norway",
shot: "1/160s · f/5.6 · ISO 200",
},
{
eyebrow: "Field Notes",
title: "Understory, After Rain",
loc: "Olympic Forest, USA",
shot: "1/60s · f/2.8 · ISO 400",
},
{
eyebrow: "Field Notes",
title: "Where the Road Gives Up",
loc: "Isle of Skye, Scotland",
shot: "1/320s · f/11 · ISO 100",
},
{
eyebrow: "Nightfall",
title: "A Quiet Cathedral of Pines",
loc: "Banff, Canada",
shot: "1/125s · f/4 · ISO 800",
},
];
var DWELL = 7000; // ms per slide (matches --dwell)
var RING_LEN = 125.66; // 2πr, r=20
// ---------- Elements ----------
var stage = document.getElementById("stage");
var slideEls = Array.prototype.slice.call(document.querySelectorAll(".slide"));
var dotsWrap = document.getElementById("dots");
var playBtn = document.getElementById("play");
var playGlyph = document.getElementById("play-glyph");
var ring = document.getElementById("ring");
var prevBtn = document.getElementById("prev");
var nextBtn = document.getElementById("next");
var toastEl = document.getElementById("toast");
var capEyebrow = document.getElementById("cap-eyebrow");
var capTitle = document.getElementById("cap-title");
var capLoc = document.getElementById("cap-loc");
var capShot = document.getElementById("cap-shot");
var counterCur = document.getElementById("counter-cur");
var counterTotal = document.getElementById("counter-total");
var caption = document.getElementById("caption");
var reduceMotion =
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---------- State ----------
var index = 0;
var playing = true;
var startTime = 0;
var elapsedBeforePause = 0;
var rafId = null;
var dotEls = [];
// ---------- Build dots ----------
SLIDES.forEach(function (_, i) {
var b = document.createElement("button");
b.type = "button";
b.className = "dot";
b.setAttribute("aria-label", "Go to photo " + (i + 1));
var fill = document.createElement("span");
fill.className = "fill";
b.appendChild(fill);
b.addEventListener("click", function () {
goTo(i, true);
showToast("Photo " + (i + 1) + " of " + SLIDES.length);
});
dotsWrap.appendChild(b);
dotEls.push({ btn: b, fill: fill });
});
counterTotal.textContent = pad(SLIDES.length);
// ---------- Helpers ----------
function pad(n) {
return n < 10 ? "0" + n : "" + n;
}
var toastTimer = null;
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 1600);
}
function setCaption(i) {
var s = SLIDES[i];
capEyebrow.textContent = s.eyebrow;
capTitle.textContent = s.title;
capLoc.textContent = s.loc;
capShot.textContent = s.shot;
counterCur.textContent = pad(i + 1);
// retrigger caption entrance animation
if (!reduceMotion) {
caption.querySelectorAll(":scope > *").forEach(function (el) {
el.style.animation = "none";
// force reflow
void el.offsetWidth;
el.style.animation = "";
});
}
}
function updateDots(i) {
dotEls.forEach(function (d, di) {
var fill = d.fill;
fill.style.transition = "none";
if (di < i) {
fill.style.transform = "translateY(-50%) scaleX(1)";
} else if (di > i) {
fill.style.transform = "translateY(-50%) scaleX(0)";
} else {
fill.style.transform = "translateY(-50%) scaleX(0)";
}
d.btn.classList.toggle("is-active", di === i);
d.btn.setAttribute("aria-current", di === i ? "true" : "false");
});
}
function applyActiveSlide(i) {
slideEls.forEach(function (el, si) {
var active = si === i;
el.classList.toggle("is-active", active);
// reset then (maybe) apply ken-burns for a fresh run
el.classList.remove("kb");
if (active && !reduceMotion && playing) {
// reflow to restart animation
void el.offsetWidth;
el.classList.add("kb");
} else if (active && !reduceMotion) {
void el.offsetWidth;
el.classList.add("kb");
// pause immediately
var img = el.querySelector(".slide-img");
if (img) img.style.animationPlayState = "paused";
}
});
}
// ---------- Progress loop ----------
function tick(now) {
if (!startTime) startTime = now;
var elapsed = elapsedBeforePause + (now - startTime);
var frac = Math.min(elapsed / DWELL, 1);
// ring
ring.style.strokeDashoffset = RING_LEN * (1 - frac);
// active dot fill
if (dotEls[index]) {
dotEls[index].fill.style.transition = "none";
dotEls[index].fill.style.transform =
"translateY(-50%) scaleX(" + frac + ")";
}
if (frac >= 1) {
goTo((index + 1) % SLIDES.length, false);
return;
}
rafId = requestAnimationFrame(tick);
}
function startProgress() {
stopProgress();
startTime = 0;
rafId = requestAnimationFrame(tick);
}
function stopProgress() {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
// ---------- Navigation ----------
function goTo(i, userInitiated) {
index = (i + SLIDES.length) % SLIDES.length;
elapsedBeforePause = 0;
applyActiveSlide(index);
setCaption(index);
updateDots(index);
ring.style.strokeDashoffset = RING_LEN;
if (playing) {
startProgress();
setSlideAnimState("running");
} else {
setSlideAnimState("paused");
}
}
function setSlideAnimState(state) {
var active = slideEls[index];
if (!active) return;
var img = active.querySelector(".slide-img");
if (img) img.style.animationPlayState = state;
}
function next() {
goTo(index + 1, true);
}
function prev() {
goTo(index - 1, true);
}
// ---------- Play / pause ----------
function setPlaying(state) {
playing = state;
playBtn.classList.toggle("is-paused", !playing);
playBtn.setAttribute("aria-pressed", String(playing));
playBtn.setAttribute(
"aria-label",
playing ? "Pause slideshow" : "Play slideshow"
);
if (playing) {
startProgress();
setSlideAnimState("running");
} else {
stopProgress();
// freeze elapsed so resume continues from same point
// (recompute from current ring offset)
var offset = parseFloat(ring.style.strokeDashoffset || RING_LEN);
var frac = 1 - offset / RING_LEN;
elapsedBeforePause = frac * DWELL;
setSlideAnimState("paused");
}
}
function togglePlay() {
setPlaying(!playing);
showToast(playing ? "Playing" : "Paused");
}
// ---------- Events ----------
playBtn.addEventListener("click", togglePlay);
nextBtn.addEventListener("click", function () {
next();
showToast("Next");
});
prevBtn.addEventListener("click", function () {
prev();
showToast("Previous");
});
// hover pauses timer (without flipping the play button state)
var hoverPaused = false;
stage.addEventListener("mouseenter", function () {
if (playing) {
hoverPaused = true;
stopProgress();
var offset = parseFloat(ring.style.strokeDashoffset || RING_LEN);
var frac = 1 - offset / RING_LEN;
elapsedBeforePause = frac * DWELL;
setSlideAnimState("paused");
}
});
stage.addEventListener("mouseleave", function () {
if (playing && hoverPaused) {
hoverPaused = false;
startProgress();
setSlideAnimState("running");
}
});
// keyboard
document.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") {
e.preventDefault();
next();
showToast("Next");
} else if (e.key === "ArrowLeft") {
e.preventDefault();
prev();
showToast("Previous");
} else if (e.key === " " || e.code === "Space") {
// avoid toggling when a button is focused (button handles its own)
if (document.activeElement && document.activeElement.tagName === "BUTTON")
return;
e.preventDefault();
togglePlay();
}
});
// pause when tab hidden, resume when visible
document.addEventListener("visibilitychange", function () {
if (document.hidden) {
stopProgress();
setSlideAnimState("paused");
} else if (playing && !hoverPaused) {
startProgress();
setSlideAnimState("running");
}
});
// basic touch swipe
var touchX = null;
stage.addEventListener(
"touchstart",
function (e) {
touchX = e.changedTouches[0].clientX;
},
{ passive: true }
);
stage.addEventListener(
"touchend",
function (e) {
if (touchX === null) return;
var dx = e.changedTouches[0].clientX - touchX;
if (Math.abs(dx) > 45) {
if (dx < 0) next();
else prev();
}
touchX = null;
},
{ passive: true }
);
// ---------- Init ----------
goTo(0, false);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Photography — Fullscreen Slideshow</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>
<main class="stage" id="stage" aria-roledescription="carousel" aria-label="Fullscreen photography slideshow">
<!-- Slides injected/enhanced by JS, but authored here for no-JS fallback -->
<div class="slides" id="slides">
<figure class="slide is-active" role="group" aria-roledescription="slide" aria-label="1 of 5">
<div class="slide-img" style="background-image:url('https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=2400&q=80')"></div>
</figure>
<figure class="slide" role="group" aria-roledescription="slide" aria-label="2 of 5">
<div class="slide-img" style="background-image:url('https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&fit=crop&w=2400&q=80')"></div>
</figure>
<figure class="slide" role="group" aria-roledescription="slide" aria-label="3 of 5">
<div class="slide-img" style="background-image:url('https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=2400&q=80')"></div>
</figure>
<figure class="slide" role="group" aria-roledescription="slide" aria-label="4 of 5">
<div class="slide-img" style="background-image:url('https://images.unsplash.com/photo-1426604966848-d7adac402bff?auto=format&fit=crop&w=2400&q=80')"></div>
</figure>
<figure class="slide" role="group" aria-roledescription="slide" aria-label="5 of 5">
<div class="slide-img" style="background-image:url('https://images.unsplash.com/photo-1500534623283-312aade485b7?auto=format&fit=crop&w=2400&q=80')"></div>
</figure>
</div>
<div class="scrim" aria-hidden="true"></div>
<!-- Top bar -->
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">ALDEN & VOSS</span>
</div>
<div class="counter" aria-live="off">
<span id="counter-cur">01</span><span class="counter-sep">/</span><span id="counter-total">05</span>
</div>
</header>
<!-- Caption -->
<div class="caption" id="caption">
<p class="eyebrow" id="cap-eyebrow">Wilderness Series</p>
<h1 class="cap-title" id="cap-title">Vellum Ridge at First Light</h1>
<p class="cap-meta" id="cap-meta">
<span id="cap-loc">Dolomites, Italy</span>
<span class="dot" aria-hidden="true"></span>
<span id="cap-shot">1/250s · f/8 · ISO 100</span>
</p>
</div>
<!-- Controls -->
<div class="controls">
<button class="nav" id="prev" type="button" aria-label="Previous photo">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="play" id="play" type="button" aria-label="Pause slideshow" aria-pressed="true">
<svg class="ring" viewBox="0 0 44 44" aria-hidden="true">
<circle class="ring-bg" cx="22" cy="22" r="20"></circle>
<circle class="ring-fg" id="ring" cx="22" cy="22" r="20"></circle>
</svg>
<span class="play-glyph" id="play-glyph" aria-hidden="true"></span>
</button>
<button class="nav" id="next" type="button" aria-label="Next photo">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<!-- Progress dots -->
<nav class="dots" id="dots" aria-label="Choose photo"></nav>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>A cinematic, full-viewport slideshow built for photographers who want their work to fill the screen. Each frame crossfades into the next while a slow Ken-Burns transform drifts and zooms the image, so even a still gallery feels alive. A thin uppercase eyebrow, serif title, and metadata line float in a caption card over a soft bottom gradient, keeping text legible without a heavy scrim.
The controls stay out of the way until you need them. A play/pause button carries a live progress ring that fills over each slide’s dwell time, and a row of progress dots lets you jump straight to any frame. Everything is keyboard-driven too — left/right arrows step between photos, the spacebar toggles autoplay, and hovering the stage pauses the timer so viewers can linger on a shot.
The layout is fully responsive down to small phones, collapses the metadata gracefully, and respects prefers-reduced-motion by disabling the Ken-Burns drift. A small toast confirms actions like pausing or restarting, and all controls expose accessible labels and visible focus rings for AA-level usability.