Video — Vimeo Grid
A cinematic hover-preview video grid where each thumbnail springs to life on hover with a muted looping clip, an animated scrub bar, duration and quality badges, and a soft amber glow. Move focus in and the still frame cross-fades to motion; move out and it settles back. Click any tile to open a letterboxed modal player with a working scrubber, play and mute toggles, timecodes in mono, and keyboard controls. Built with vanilla JS, film-forward and responsive.
MCP
Code
:root {
--bg: #0a0a0b;
--surface: #141416;
--surface-2: #1c1c1f;
--amber: #ffb020;
--amber-d: #e6971a;
--red: #ff4d4d;
--ink: #f4f4f6;
--muted: #8a8a92;
--line: rgba(255, 255, 255, 0.10);
--line-2: rgba(255, 255, 255, 0.18);
--white: #fff;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
line-height: 1.5;
background:
radial-gradient(1100px 600px at 80% -10%, rgba(255, 176, 32, 0.08), transparent 60%),
var(--bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.mono { font-family: "JetBrains Mono", ui-monospace, monospace; }
.page {
max-width: 1160px;
margin: 0 auto;
padding: 28px 20px 60px;
}
/* ---------- Masthead ---------- */
.masthead {
position: relative;
border-radius: var(--r-lg);
overflow: hidden;
background:
linear-gradient(115deg, rgba(255, 176, 32, 0.14), transparent 45%),
var(--surface);
border: 1px solid var(--line);
margin-bottom: 26px;
}
.mast-bars {
position: absolute;
inset: 0;
background:
linear-gradient(#000 0 0) top / 100% 22px no-repeat,
linear-gradient(#000 0 0) bottom / 100% 22px no-repeat;
pointer-events: none;
}
.mast-inner {
position: relative;
padding: 46px 30px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 14px; }
.rec-dot {
width: 12px; height: 12px; border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.6);
animation: rec 1.8s ease-out infinite;
}
@keyframes rec {
0% { box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.55); }
70% { box-shadow: 0 0 0 10px rgba(255, 77, 77, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 77, 77, 0); }
}
.kicker {
margin: 0;
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--amber);
font-weight: 600;
}
.masthead h1 {
margin: 2px 0 0;
font-size: clamp(28px, 5vw, 44px);
font-weight: 800;
letter-spacing: -0.02em;
}
.lede { margin: 0; color: var(--muted); font-size: 14px; max-width: 280px; }
/* ---------- Controls ---------- */
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filters { display: flex; gap: 8px; flex-wrap: wrap; }
.chip {
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--line);
padding: 8px 14px;
border-radius: 999px;
cursor: pointer;
transition: color .18s, border-color .18s, background .18s, transform .1s;
}
.chip:hover { color: var(--ink); border-color: var(--line-2); }
.chip:active { transform: translateY(1px); }
.chip.is-active {
color: var(--bg);
background: var(--amber);
border-color: var(--amber);
}
.chip .count {
display: inline-block;
min-width: 18px;
margin-left: 4px;
font-size: 11px;
opacity: .7;
}
.chip.is-active .count { opacity: .85; }
.chip:focus-visible,
.sort select:focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px;
}
.sort {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.sort select {
font: inherit;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
padding: 8px 10px;
border-radius: var(--r-sm);
cursor: pointer;
}
/* ---------- Grid ---------- */
.grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.tile {
position: relative;
display: block;
width: 100%;
padding: 0;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--r-md);
overflow: hidden;
cursor: pointer;
text-align: left;
color: var(--ink);
aspect-ratio: 16 / 10;
transition: transform .22s cubic-bezier(.2,.7,.3,1), border-color .22s, box-shadow .22s;
}
.tile:hover,
.tile:focus-visible {
transform: translateY(-4px);
border-color: var(--line-2);
box-shadow: 0 16px 40px rgba(0, 0, 0, .5), 0 0 0 1px rgba(255, 176, 32, .35);
outline: none;
}
.tile-poster {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
transition: transform .5s ease, filter .3s;
filter: saturate(.9) brightness(.82);
}
.tile:hover .tile-poster,
.tile:focus-visible .tile-poster {
transform: scale(1.06);
filter: saturate(1.05) brightness(.92);
}
/* motion preview: shimmering gradient sweep to fake a playing clip */
.tile-motion {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity .35s ease;
background:
linear-gradient(115deg, transparent 30%, rgba(255, 255, 255, .12) 50%, transparent 70%);
background-size: 240% 100%;
mix-blend-mode: screen;
}
.tile:hover .tile-motion,
.tile:focus-visible .tile-motion {
opacity: 1;
animation: sweep 2.4s linear infinite;
}
@keyframes sweep {
from { background-position: 140% 0; }
to { background-position: -140% 0; }
}
.tile-shade {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,.85) 0%, rgba(0,0,0,.15) 45%, transparent 70%);
}
.tile-badges {
position: absolute;
top: 10px; left: 10px; right: 10px;
display: flex;
justify-content: space-between;
gap: 8px;
}
.badge-cat {
font-size: 10px;
letter-spacing: .1em;
text-transform: uppercase;
font-weight: 700;
color: var(--amber);
background: rgba(10,10,11,.7);
border: 1px solid rgba(255,176,32,.4);
padding: 4px 8px;
border-radius: 999px;
backdrop-filter: blur(4px);
}
.badge-dur {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
font-weight: 600;
color: var(--ink);
background: rgba(10,10,11,.7);
border: 1px solid var(--line);
padding: 3px 7px;
border-radius: var(--r-sm);
backdrop-filter: blur(4px);
}
.tile-foot {
position: absolute;
left: 12px; right: 12px; bottom: 12px;
}
.tile-title {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
transform: translateY(6px);
opacity: .9;
transition: transform .3s, opacity .3s;
}
.tile:hover .tile-title,
.tile:focus-visible .tile-title { transform: translateY(0); opacity: 1; }
.tile-sub {
margin: 3px 0 0;
font-size: 12px;
color: var(--muted);
display: flex;
gap: 10px;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height .3s, opacity .3s;
}
.tile:hover .tile-sub,
.tile:focus-visible .tile-sub { max-height: 22px; opacity: 1; }
.badge-q {
font-size: 10px;
font-weight: 700;
letter-spacing: .08em;
color: var(--bg);
background: var(--amber);
padding: 2px 6px;
border-radius: 4px;
}
/* fake scrub bar animating on hover */
.tile-progress {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 3px;
background: rgba(255,255,255,.12);
opacity: 0;
transition: opacity .3s;
}
.tile:hover .tile-progress,
.tile:focus-visible .tile-progress { opacity: 1; }
.tile-progress::after {
content: "";
position: absolute;
left: 0; top: 0; bottom: 0;
width: 0;
background: var(--amber);
}
.tile:hover .tile-progress::after,
.tile:focus-visible .tile-progress::after {
animation: scrub 6s linear infinite;
}
@keyframes scrub { from { width: 0; } to { width: 100%; } }
.empty {
text-align: center;
color: var(--muted);
padding: 60px 0;
}
.foot {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 30px;
padding-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
}
.foot .mono { color: var(--amber); }
/* ---------- Modal ---------- */
.modal {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 20px;
}
.modal[hidden] { display: none; }
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,.78);
backdrop-filter: blur(6px);
animation: fade .2s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.dialog {
position: relative;
width: min(760px, 100%);
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: 0 40px 100px rgba(0,0,0,.7);
animation: pop .28s cubic-bezier(.2,.8,.3,1);
}
@keyframes pop {
from { opacity: 0; transform: translateY(14px) scale(.97); }
to { opacity: 1; transform: none; }
}
.dialog-close {
position: absolute;
top: 10px; right: 12px;
z-index: 3;
width: 34px; height: 34px;
font-size: 22px;
line-height: 1;
color: var(--ink);
background: rgba(10,10,11,.55);
border: 1px solid var(--line);
border-radius: 50%;
cursor: pointer;
transition: background .18s, transform .1s;
}
.dialog-close:hover { background: var(--red); }
.dialog-close:active { transform: scale(.94); }
.stage {
position: relative;
aspect-ratio: 16 / 9;
background: #000;
overflow: hidden;
}
.stage-bars {
position: absolute; inset: 0; z-index: 2; pointer-events: none;
background:
linear-gradient(#000 0 0) top / 100% 30px no-repeat,
linear-gradient(#000 0 0) bottom / 100% 30px no-repeat;
}
.stage-poster {
position: absolute; inset: 0;
background-size: cover;
background-position: center;
filter: brightness(.7);
transition: filter .4s, transform 8s ease;
}
.stage.playing .stage-poster { filter: brightness(1); transform: scale(1.05); }
.stage-play {
position: absolute;
z-index: 3;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 74px; height: 74px;
display: grid; place-items: center;
color: var(--bg);
background: var(--amber);
border: none;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 10px 30px rgba(255,176,32,.4);
transition: transform .18s, background .18s, opacity .25s;
}
.stage-play:hover { transform: translate(-50%, -50%) scale(1.08); background: var(--amber-d); }
.stage.playing .stage-play { opacity: 0; pointer-events: none; }
.grain {
position: absolute; inset: 0; z-index: 1; opacity: 0;
background-image: repeating-linear-gradient(0deg, rgba(255,255,255,.03) 0 1px, transparent 1px 3px);
transition: opacity .3s;
}
.stage.playing .grain { opacity: 1; animation: flick .18s steps(2) infinite; }
@keyframes flick { 50% { opacity: .5; } }
.player-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
background: var(--surface-2);
border-top: 1px solid var(--line);
}
.pbtn {
display: grid; place-items: center;
width: 34px; height: 34px;
color: var(--ink);
background: transparent;
border: 1px solid var(--line);
border-radius: var(--r-sm);
cursor: pointer;
transition: color .15s, border-color .15s, background .15s;
}
.pbtn:hover { color: var(--amber); border-color: var(--line-2); }
.pbtn:focus-visible,
.scrub:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
.time { font-size: 12px; color: var(--muted); min-width: 44px; }
.scrub {
position: relative;
flex: 1;
height: 6px;
border-radius: 999px;
background: rgba(255,255,255,.14);
cursor: pointer;
}
.scrub-fill {
position: absolute; left: 0; top: 0; bottom: 0;
width: 0;
border-radius: 999px;
background: var(--amber);
}
.scrub-knob {
position: absolute;
top: 50%; left: 0;
transform: translate(-50%, -50%);
width: 14px; height: 14px;
border-radius: 50%;
background: var(--white);
box-shadow: 0 2px 6px rgba(0,0,0,.5);
pointer-events: none;
}
.meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 18px 18px;
}
.meta h2 { margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; }
.meta p { margin: 4px 0 0; font-size: 12px; color: var(--muted); }
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 20px);
z-index: 90;
background: var(--surface-2);
color: var(--ink);
border: 1px solid var(--line-2);
border-left: 3px solid var(--amber);
padding: 11px 16px;
border-radius: var(--r-md);
font-size: 13px;
font-weight: 500;
box-shadow: 0 16px 40px rgba(0,0,0,.5);
opacity: 0;
pointer-events: none;
transition: opacity .25s, transform .25s;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 820px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 520px) {
.page { padding: 18px 14px 44px; }
.mast-inner { padding: 34px 20px; }
.lede { max-width: none; }
.grid { grid-template-columns: 1fr; gap: 14px; }
.controls { flex-direction: column; align-items: stretch; }
.sort { justify-content: space-between; }
.player-bar { flex-wrap: wrap; gap: 10px; }
.scrub { order: 5; flex-basis: 100%; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; transition-duration: .01ms !important; }
}/* Reel Grid — hover-preview video grid + modal player (vanilla JS) */
(function () {
"use strict";
const CAT_LABEL = {
commercial: "Commercial",
documentary: "Documentary",
music: "Music Video",
wedding: "Wedding",
};
// Fictional but realistic reel data. Posters are Unsplash placeholder URLs.
const CLIPS = [
{ id: 1, title: "Aurora — Perfume Film", cat: "commercial", dur: 74, quality: "4K", views: 128400, date: "2026-05-18", g: "1492724441997-5dc865305da7" },
{ id: 2, title: "The Last Ferry", cat: "documentary", dur: 612, quality: "6K", views: 54200, date: "2026-06-02", g: "1500530855697-b586d89ba3ee" },
{ id: 3, title: "Neon Tide — Live Session", cat: "music", dur: 218, quality: "4K", views: 402100, date: "2026-06-21", g: "1470225620780-dba8ba36b745" },
{ id: 4, title: "Clara & Jonas", cat: "wedding", dur: 186, quality: "4K", views: 22800, date: "2026-04-30", g: "1519741497674-611481863552" },
{ id: 5, title: "Volt EV — Launch Spot", cat: "commercial", dur: 32, quality: "4K", views: 291500, date: "2026-06-25", g: "1503376780353-7e6692767b70" },
{ id: 6, title: "Salt & Iron", cat: "documentary", dur: 528, quality: "4K", views: 38900, date: "2026-03-14", g: "1441974231531-c6227db76b6e" },
{ id: 7, title: "Midnight Drum Kit", cat: "music", dur: 244, quality: "4K", views: 176300, date: "2026-05-09", g: "1511671782779-c97d3d27a1d4" },
{ id: 8, title: "Elena & Sam — Highland Vows", cat: "wedding", dur: 205, quality: "6K", views: 41200, date: "2026-06-12", g: "1465495976277-4387d4b0b4c6" },
{ id: 9, title: "Bloom — Skincare Ad", cat: "commercial", dur: 45, quality: "4K", views: 97600, date: "2026-05-27", g: "1522337660859-02fbefca4702" },
{ id: 10, title: "Riverkeepers", cat: "documentary", dur: 702, quality: "6K", views: 61800, date: "2026-02-20", g: "1506744038136-46273834b3fb" },
{ id: 11, title: "Static Bloom — Music Video", cat: "music", dur: 197, quality: "4K", views: 512900, date: "2026-06-28", g: "1493225457124-a3eb161ffa5f" },
{ id: 12, title: "Priya & Noor", cat: "wedding", dur: 172, quality: "4K", views: 33400, date: "2026-06-18", g: "1519225421980-715cb0215aed" },
];
const gridEl = document.getElementById("grid");
const emptyEl = document.getElementById("empty");
const chips = Array.from(document.querySelectorAll(".chip"));
const sortEl = document.getElementById("sort");
const toastEl = document.getElementById("toast");
let activeFilter = "all";
let toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2200);
}
function fmtDur(s) {
const m = Math.floor(s / 60);
const r = s % 60;
return m + ":" + String(r).padStart(2, "0");
}
function fmtViews(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 100000 ? 0 : 1) + "K";
return String(n);
}
function poster(g, w) {
return "https://images.unsplash.com/photo-" + g + "?auto=format&fit=crop&w=" + (w || 640) + "&q=70";
}
function counts() {
const c = { all: CLIPS.length };
CLIPS.forEach((x) => { c[x.cat] = (c[x.cat] || 0) + 1; });
document.querySelectorAll(".count").forEach((el) => {
const k = el.getAttribute("data-count");
el.textContent = c[k] != null ? c[k] : 0;
});
}
function sortClips(list) {
const mode = sortEl.value;
const arr = list.slice();
if (mode === "new") arr.sort((a, b) => b.date.localeCompare(a.date));
else if (mode === "long") arr.sort((a, b) => b.dur - a.dur);
else if (mode === "views") arr.sort((a, b) => b.views - a.views);
return arr;
}
function render() {
let list = activeFilter === "all" ? CLIPS : CLIPS.filter((x) => x.cat === activeFilter);
list = sortClips(list);
gridEl.innerHTML = "";
emptyEl.hidden = list.length > 0;
list.forEach((clip) => {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.className = "tile";
btn.type = "button";
btn.setAttribute("data-id", clip.id);
btn.setAttribute("aria-label",
"Play " + clip.title + ", " + CAT_LABEL[clip.cat] + ", " + fmtDur(clip.dur));
btn.innerHTML =
'<div class="tile-poster" style="background-image:url(' + poster(clip.g, 640) + ')"></div>' +
'<div class="tile-motion"></div>' +
'<div class="tile-shade"></div>' +
'<div class="tile-badges">' +
'<span class="badge-cat">' + CAT_LABEL[clip.cat] + "</span>" +
'<span class="badge-dur">' + fmtDur(clip.dur) + "</span>" +
"</div>" +
'<div class="tile-foot">' +
'<h3 class="tile-title">' + clip.title + "</h3>" +
'<p class="tile-sub"><span>' + fmtViews(clip.views) + " views</span>" +
'<span class="badge-q">' + clip.quality + "</span></p>" +
"</div>" +
'<div class="tile-progress"></div>';
btn.addEventListener("click", () => openModal(clip, btn));
li.appendChild(btn);
gridEl.appendChild(li);
});
}
chips.forEach((chip) => {
chip.addEventListener("click", () => {
chips.forEach((c) => {
c.classList.remove("is-active");
c.setAttribute("aria-selected", "false");
});
chip.classList.add("is-active");
chip.setAttribute("aria-selected", "true");
activeFilter = chip.getAttribute("data-filter");
render();
});
});
sortEl.addEventListener("change", () => {
render();
toast("Sorted by " + sortEl.options[sortEl.selectedIndex].text.toLowerCase());
});
/* ---------- Modal player ---------- */
const modal = document.getElementById("modal");
const stage = document.getElementById("stage");
const stagePoster = document.getElementById("stagePoster");
const bigPlay = document.getElementById("bigPlay");
const playToggle = document.getElementById("playToggle");
const muteToggle = document.getElementById("muteToggle");
const scrub = document.getElementById("scrub");
const scrubFill = document.getElementById("scrubFill");
const scrubKnob = document.getElementById("scrubKnob");
const tCur = document.getElementById("tCur");
const tDur = document.getElementById("tDur");
const mTitle = document.getElementById("mTitle");
const mSub = document.getElementById("mSub");
const mQual = document.getElementById("mQual");
let cur = null; // current clip
let pos = 0; // seconds elapsed
let playing = false;
let muted = true;
let ticker = null;
let lastTrigger = null;
function setIcons() {
playToggle.querySelector(".ic-play").hidden = playing;
playToggle.querySelector(".ic-pause").hidden = !playing;
playToggle.setAttribute("aria-label", playing ? "Pause" : "Play");
stage.classList.toggle("playing", playing);
muteToggle.querySelector(".ic-vol").hidden = muted;
muteToggle.querySelector(".ic-muted").hidden = !muted;
muteToggle.setAttribute("aria-label", muted ? "Unmute" : "Mute");
}
function drawScrub() {
const pct = cur ? (pos / cur.dur) * 100 : 0;
scrubFill.style.width = pct + "%";
scrubKnob.style.left = pct + "%";
scrub.setAttribute("aria-valuenow", Math.round(pct));
tCur.textContent = fmtDur(Math.floor(pos));
}
function tick() {
if (!playing || !cur) return;
pos += 0.25;
if (pos >= cur.dur) { pos = cur.dur; pause(); toast("Playback finished"); }
drawScrub();
}
function play() {
if (!cur) return;
if (pos >= cur.dur) pos = 0;
playing = true;
setIcons();
clearInterval(ticker);
ticker = setInterval(tick, 250);
}
function pause() {
playing = false;
setIcons();
clearInterval(ticker);
}
function togglePlay() { playing ? pause() : play(); }
function seekToClientX(clientX) {
if (!cur) return;
const r = scrub.getBoundingClientRect();
let pct = (clientX - r.left) / r.width;
pct = Math.max(0, Math.min(1, pct));
pos = pct * cur.dur;
drawScrub();
}
function openModal(clip, trigger) {
cur = clip;
pos = 0;
lastTrigger = trigger || null;
stagePoster.style.backgroundImage = "url(" + poster(clip.g, 1080) + ")";
mTitle.textContent = clip.title;
mSub.textContent = CAT_LABEL[clip.cat].toUpperCase() + " · " + fmtViews(clip.views) + " VIEWS";
mQual.textContent = clip.quality;
tDur.textContent = fmtDur(clip.dur);
drawScrub();
pause();
modal.hidden = false;
document.body.style.overflow = "hidden";
setTimeout(() => bigPlay.focus(), 40);
}
function closeModal() {
pause();
modal.hidden = true;
document.body.style.overflow = "";
if (lastTrigger) lastTrigger.focus();
}
bigPlay.addEventListener("click", play);
playToggle.addEventListener("click", togglePlay);
muteToggle.addEventListener("click", () => {
muted = !muted;
setIcons();
toast(muted ? "Muted" : "Sound on");
});
// scrub: click + drag
let dragging = false;
scrub.addEventListener("pointerdown", (e) => {
dragging = true;
scrub.setPointerCapture(e.pointerId);
seekToClientX(e.clientX);
});
scrub.addEventListener("pointermove", (e) => { if (dragging) seekToClientX(e.clientX); });
scrub.addEventListener("pointerup", () => { dragging = false; });
scrub.addEventListener("keydown", (e) => {
if (!cur) return;
if (e.key === "ArrowRight") { pos = Math.min(cur.dur, pos + cur.dur * 0.05); drawScrub(); e.preventDefault(); }
else if (e.key === "ArrowLeft") { pos = Math.max(0, pos - cur.dur * 0.05); drawScrub(); e.preventDefault(); }
else if (e.key === " " || e.key === "Enter") { togglePlay(); e.preventDefault(); }
});
modal.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", closeModal));
document.addEventListener("keydown", (e) => {
if (modal.hidden) return;
if (e.key === "Escape") closeModal();
else if (e.key === " " && e.target === document.body) { togglePlay(); e.preventDefault(); }
});
/* ---------- Footer clock (session runtime) ---------- */
const footClock = document.getElementById("footClock");
let secs = 0;
setInterval(() => {
secs++;
const h = String(Math.floor(secs / 3600)).padStart(2, "0");
const m = String(Math.floor((secs % 3600) / 60)).padStart(2, "0");
const s = String(secs % 60).padStart(2, "0");
footClock.textContent = h + ":" + m + ":" + s;
}, 1000);
/* ---------- init ---------- */
counts();
render();
setIcons();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reel Grid — Videographer Portfolio</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=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="masthead">
<div class="mast-bars" aria-hidden="true"></div>
<div class="mast-inner">
<div class="brand">
<span class="rec-dot" aria-hidden="true"></span>
<div>
<p class="kicker">Directed & shot by Mara Vance</p>
<h1>The Reel Grid</h1>
</div>
</div>
<p class="lede">Hover any frame to preview the cut. Click to open the player.</p>
</div>
</header>
<section class="controls" aria-label="Filter and sort the reel">
<div class="filters" role="tablist" aria-label="Filter by category">
<button class="chip is-active" role="tab" aria-selected="true" data-filter="all">All <span class="count" data-count="all"></span></button>
<button class="chip" role="tab" aria-selected="false" data-filter="commercial">Commercial <span class="count" data-count="commercial"></span></button>
<button class="chip" role="tab" aria-selected="false" data-filter="documentary">Documentary <span class="count" data-count="documentary"></span></button>
<button class="chip" role="tab" aria-selected="false" data-filter="music">Music Video <span class="count" data-count="music"></span></button>
<button class="chip" role="tab" aria-selected="false" data-filter="wedding">Wedding <span class="count" data-count="wedding"></span></button>
</div>
<label class="sort">
<span>Sort</span>
<select id="sort" aria-label="Sort reels">
<option value="new">Newest</option>
<option value="long">Longest</option>
<option value="views">Most viewed</option>
</select>
</label>
</section>
<main>
<ul class="grid" id="grid" aria-label="Video thumbnails"></ul>
<p class="empty" id="empty" hidden>No clips in this category yet.</p>
</main>
<footer class="foot">
<span>12 selects — 2024/2026 reel</span>
<span class="mono" id="footClock">00:00:00</span>
</footer>
</div>
<!-- Modal player -->
<div class="modal" id="modal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="mTitle">
<button class="dialog-close" data-close aria-label="Close player">×</button>
<div class="stage" id="stage">
<div class="stage-bars" aria-hidden="true"></div>
<div class="stage-poster" id="stagePoster"></div>
<button class="stage-play" id="bigPlay" aria-label="Play">
<svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
</button>
<div class="grain" aria-hidden="true"></div>
</div>
<div class="player-bar">
<button class="pbtn" id="playToggle" aria-label="Play">
<svg class="ic-play" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
<svg class="ic-pause" viewBox="0 0 24 24" width="18" height="18" hidden aria-hidden="true"><path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/></svg>
</button>
<span class="mono time" id="tCur">00:00</span>
<div class="scrub" id="scrub" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="scrub-fill" id="scrubFill"></div>
<div class="scrub-knob" id="scrubKnob"></div>
</div>
<span class="mono time" id="tDur">00:00</span>
<button class="pbtn" id="muteToggle" aria-label="Mute">
<svg class="ic-vol" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4z" fill="currentColor"/><path d="M16 8a5 5 0 0 1 0 8" fill="none" stroke="currentColor" stroke-width="2"/></svg>
<svg class="ic-muted" viewBox="0 0 24 24" width="18" height="18" hidden aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4z" fill="currentColor"/><path d="M17 9l4 6M21 9l-4 6" stroke="currentColor" stroke-width="2"/></svg>
</button>
</div>
<div class="meta">
<div>
<h2 id="mTitle"></h2>
<p id="mSub" class="mono"></p>
</div>
<span class="badge-q" id="mQual"></span>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>A hover-preview video grid built for a videographer’s portfolio. Each tile shows a still poster frame with a duration badge and quality chip. On hover or keyboard focus the poster cross-fades into a muted, looping motion preview while a thin amber progress bar scrubs across the bottom and the title slides up from the letterbox — a small, film-forward micro-interaction that makes the whole wall feel alive without any autoplay noise.
The grid is filterable by reel category (Commercial, Documentary, Music Video, Wedding) with animated count chips, and sortable by newest, longest, and most-viewed. Every card is a real button, so the wall is fully keyboard-navigable with visible focus rings and AA-contrast text over the cinematic dark surface.
Clicking a tile opens a letterboxed modal player: a large poster stage with a centered play control, a working scrub bar you can click or drag, play/pause and mute toggles, elapsed and total timecodes rendered in JetBrains Mono, and a live view counter. Escape or the backdrop closes it, focus returns to the originating tile, and a toast confirms actions. All interactions are vanilla JS — no frameworks, no real video files required.