Video — Portfolio Grid
A cinematic dark portfolio grid for a videographer: thumbnail cards with duration and category badges, hover-scrub that sweeps through preview frames, type filters with animated counts, a letterboxed hero reel, and a full-screen detail lightbox showing credits, gear, and a scrubbable timeline. Monospace timecodes, amber film accents, keyboard-navigable, and fully responsive down to phone widths.
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;
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: inherit; }
.skip-link {
position: absolute;
left: 12px;
top: -48px;
background: var(--amber);
color: #1a1300;
padding: 8px 14px;
border-radius: var(--r-sm);
font-weight: 700;
z-index: 60;
transition: top .18s ease;
}
.skip-link:focus { top: 12px; }
:focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px;
}
/* ---------- Topbar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 20px;
padding: 14px clamp(16px, 5vw, 48px);
background: rgba(10, 10, 11, 0.82);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: .04em; }
.brand-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--amber);
box-shadow: 0 0 0 4px rgba(255, 176, 32, 0.18);
}
.brand-name { font-size: 1rem; }
.brand-accent { color: var(--amber); }
.topnav { margin-left: auto; display: flex; gap: 22px; }
.topnav a {
text-decoration: none;
color: var(--muted);
font-size: .9rem;
font-weight: 500;
transition: color .16s ease;
}
.topnav a:hover { color: var(--ink); }
.btn-hire {
border: 1px solid var(--amber);
background: linear-gradient(180deg, var(--amber), var(--amber-d));
color: #1a1300;
font-weight: 700;
font-size: .85rem;
padding: 9px 16px;
border-radius: var(--r-sm);
cursor: pointer;
transition: transform .14s ease, box-shadow .14s ease;
}
.btn-hire:hover { transform: translateY(-1px); box-shadow: 0 8px 22px rgba(255, 176, 32, 0.25); }
.btn-hire:active { transform: translateY(0); }
/* ---------- Hero ---------- */
.hero {
position: relative;
overflow: hidden;
background:
radial-gradient(120% 80% at 80% -10%, rgba(255, 176, 32, 0.14), transparent 55%),
radial-gradient(90% 70% at 0% 110%, rgba(255, 77, 77, 0.10), transparent 60%),
linear-gradient(180deg, #101012, #0a0a0b);
}
.letterbox { height: clamp(28px, 6vh, 64px); background: #000; }
.hero-inner {
padding: clamp(36px, 8vw, 88px) clamp(16px, 6vw, 72px);
max-width: 820px;
}
.hero-eyebrow {
display: inline-flex;
align-items: center;
gap: 9px;
font-family: var(--mono);
font-size: .72rem;
letter-spacing: .18em;
color: var(--muted);
margin: 0 0 18px;
}
.rec-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--red);
animation: rec 1.6s ease-in-out infinite;
}
@keyframes rec { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
.hero-title {
margin: 0 0 18px;
font-size: clamp(2.4rem, 7vw, 4.6rem);
line-height: 1.02;
font-weight: 800;
letter-spacing: -.02em;
}
.hero-title em {
font-style: italic;
color: var(--amber);
}
.hero-sub {
margin: 0 0 30px;
max-width: 46ch;
color: var(--muted);
font-size: clamp(.98rem, 2vw, 1.12rem);
}
.hero-actions { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
.btn-play {
display: inline-flex;
align-items: center;
gap: 12px;
border: none;
background: var(--white);
color: #111;
font-weight: 700;
font-size: .95rem;
padding: 12px 20px;
border-radius: 999px;
cursor: pointer;
transition: transform .14s ease, box-shadow .14s ease;
}
.btn-play:hover { transform: translateY(-2px); box-shadow: 0 14px 30px rgba(0, 0, 0, .5); }
.play-tri {
width: 0; height: 0;
border-left: 12px solid #111;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
}
.btn-play .tc {
font-family: var(--mono);
font-size: .78rem;
color: #555;
padding-left: 4px;
border-left: 1px solid rgba(0, 0, 0, .2);
}
.btn-ghost {
border: 1px solid var(--line-2);
background: transparent;
color: var(--ink);
font-weight: 600;
font-size: .9rem;
padding: 12px 20px;
border-radius: 999px;
cursor: pointer;
transition: border-color .16s ease, background .16s ease;
}
.btn-ghost:hover { border-color: var(--amber); background: rgba(255, 176, 32, 0.08); }
/* ---------- Work section ---------- */
.work { padding: clamp(32px, 6vw, 64px) clamp(16px, 5vw, 48px) 64px; }
.work-head {
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 26px;
}
.work-title { margin: 0; font-size: clamp(1.4rem, 4vw, 2rem); font-weight: 800; letter-spacing: -.01em; }
.work-count { margin: 4px 0 0; font-family: var(--mono); font-size: .78rem; color: var(--muted); }
.filters { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
font-size: .82rem;
font-weight: 600;
padding: 8px 14px;
border-radius: 999px;
cursor: pointer;
transition: color .16s ease, border-color .16s ease, background .16s ease;
}
.chip:hover { color: var(--ink); border-color: var(--line-2); }
.chip.is-active {
color: #1a1300;
background: var(--amber);
border-color: var(--amber);
}
.chip-n {
font-family: var(--mono);
font-size: .68rem;
opacity: .8;
padding: 1px 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.10);
}
.chip.is-active .chip-n { background: rgba(0, 0, 0, 0.15); }
/* ---------- Grid ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.card {
position: relative;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--r-lg);
overflow: hidden;
cursor: pointer;
text-align: left;
padding: 0;
color: inherit;
font: inherit;
transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease, opacity .3s ease;
animation: rise .4s both;
}
@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
.card:hover, .card:focus-visible {
transform: translateY(-4px);
border-color: var(--line-2);
box-shadow: 0 18px 40px rgba(0, 0, 0, .55);
}
.thumb {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--surface-2);
}
.thumb-frame {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity .28s ease;
}
.thumb-frame.is-on { opacity: 1; }
.thumb-frame:first-child { opacity: 1; }
.thumb::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 45%, rgba(0, 0, 0, .55));
pointer-events: none;
}
.dur {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 2;
font-family: var(--mono);
font-size: .72rem;
font-weight: 600;
color: var(--white);
background: rgba(0, 0, 0, .68);
padding: 3px 8px;
border-radius: var(--r-sm);
backdrop-filter: blur(2px);
}
.cat {
position: absolute;
left: 10px;
top: 10px;
z-index: 2;
font-size: .68rem;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--amber);
background: rgba(10, 10, 11, .7);
border: 1px solid rgba(255, 176, 32, 0.35);
padding: 3px 9px;
border-radius: 999px;
}
.scrub {
position: absolute;
left: 0; bottom: 0;
z-index: 3;
height: 3px;
width: 0;
background: var(--amber);
box-shadow: 0 0 8px var(--amber);
transition: width .1s linear;
}
.play-hint {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
opacity: 0;
transition: opacity .2s ease;
}
.card:hover .play-hint, .card:focus-visible .play-hint { opacity: 1; }
.play-hint span {
width: 52px; height: 52px;
border-radius: 50%;
background: rgba(10, 10, 11, .55);
border: 1px solid var(--line-2);
display: grid;
place-items: center;
backdrop-filter: blur(3px);
}
.play-hint span::before {
content: "";
width: 0; height: 0;
margin-left: 3px;
border-left: 13px solid var(--white);
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
}
.card-body { padding: 14px 16px 16px; }
.card-title { margin: 0 0 3px; font-size: 1.02rem; font-weight: 700; letter-spacing: -.01em; }
.card-sub { margin: 0; font-size: .82rem; color: var(--muted); }
.card-tags { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }
.tag {
font-family: var(--mono);
font-size: .66rem;
color: var(--muted);
border: 1px solid var(--line);
padding: 2px 7px;
border-radius: var(--r-sm);
}
.empty { text-align: center; color: var(--muted); padding: 48px 0; }
/* ---------- Footer ---------- */
.foot {
display: flex;
justify-content: space-between;
align-items: center;
padding: 22px clamp(16px, 5vw, 48px);
border-top: 1px solid var(--line);
color: var(--muted);
font-size: .82rem;
}
.foot-tc { font-family: var(--mono); }
/* ---------- Lightbox ---------- */
.lightbox {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: clamp(12px, 4vw, 40px);
}
.lb-backdrop {
position: absolute;
inset: 0;
background: rgba(4, 4, 5, .8);
backdrop-filter: blur(6px);
animation: fade .2s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.lb-panel {
position: relative;
width: min(880px, 100%);
max-height: 92vh;
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
box-shadow: 0 40px 90px rgba(0, 0, 0, .7);
animation: pop .24s cubic-bezier(.2, .8, .2, 1);
}
@keyframes pop { from { opacity: 0; transform: translateY(20px) scale(.98); } to { opacity: 1; transform: none; } }
.lb-close {
position: absolute;
top: 12px; right: 12px;
z-index: 5;
width: 38px; height: 38px;
border-radius: 50%;
border: 1px solid var(--line-2);
background: rgba(10, 10, 11, .7);
color: var(--ink);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
transition: background .16s ease, transform .16s ease;
}
.lb-close:hover { background: var(--red); border-color: var(--red); transform: rotate(90deg); }
.lb-stage { position: relative; background: #000; }
.lb-stage .letterbox { height: clamp(20px, 4vw, 40px); }
.lb-frame { aspect-ratio: 16 / 9; transition: background .4s ease; }
.lb-badge {
position: absolute;
left: 16px; top: calc(clamp(20px, 4vw, 40px) + 12px);
font-size: .68rem;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--amber);
background: rgba(10, 10, 11, .7);
border: 1px solid rgba(255, 176, 32, 0.35);
padding: 3px 9px;
border-radius: 999px;
}
.lb-timeline { padding: 14px 20px 4px; }
.lb-timeline input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 5px;
border-radius: 999px;
background: var(--surface-2);
cursor: pointer;
}
.lb-timeline input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--amber);
border: 3px solid #1a1300;
box-shadow: 0 0 10px rgba(255, 176, 32, 0.5);
}
.lb-timeline input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px;
border-radius: 50%;
background: var(--amber);
border: 3px solid #1a1300;
}
.lb-times {
display: flex;
justify-content: space-between;
margin-top: 8px;
}
.lb-times .tc { font-family: var(--mono); font-size: .76rem; color: var(--muted); }
.lb-body { padding: 8px 20px 24px; }
.lb-title { margin: 8px 0 6px; font-size: clamp(1.3rem, 4vw, 1.8rem); font-weight: 800; letter-spacing: -.01em; }
.lb-syn { margin: 0 0 20px; color: var(--muted); max-width: 60ch; }
.lb-meta {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px 20px;
margin: 0 0 22px;
}
.lb-meta dt {
font-family: var(--mono);
font-size: .66rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 3px;
}
.lb-meta dd { margin: 0; font-weight: 600; font-size: .95rem; }
.lb-actions { display: flex; flex-wrap: wrap; gap: 12px; }
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 20px);
z-index: 100;
background: var(--surface-2);
border: 1px solid var(--line-2);
color: var(--ink);
font-size: .88rem;
font-weight: 500;
padding: 12px 18px;
border-radius: var(--r-md);
box-shadow: 0 16px 40px rgba(0, 0, 0, .6);
opacity: 0;
pointer-events: none;
transition: opacity .22s ease, transform .22s ease;
}
.toast.is-on { opacity: 1; transform: translate(-50%, 0); }
.toast::before {
content: "";
display: inline-block;
width: 8px; height: 8px;
margin-right: 8px;
border-radius: 50%;
background: var(--amber);
vertical-align: middle;
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.topnav { display: none; }
.btn-hire { margin-left: auto; }
.grid { grid-template-columns: 1fr; gap: 16px; }
.work-head { flex-direction: column; align-items: stretch; }
.filters { overflow-x: auto; flex-wrap: nowrap; padding-bottom: 4px; -webkit-overflow-scrolling: touch; }
.chip { flex: 0 0 auto; }
.lb-meta { grid-template-columns: 1fr; }
.hero-actions { flex-direction: column; align-items: stretch; }
.btn-play, .btn-ghost { justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; transition-duration: .05ms !important; scroll-behavior: auto; }
}/* Nova Reel — Videographer Portfolio Grid
Vanilla JS. Renders cards, filters by type, hover-scrubs preview frames,
and opens a letterboxed detail lightbox with a scrubbable timeline. */
(function () {
"use strict";
var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ---- Data (fictional) ---------------------------------------------- */
var CATS = {
all: "All",
commercial: "Commercial",
music: "Music Video",
doc: "Documentary",
short: "Short Film"
};
// Each project carries a set of gradient "frames" the hover-scrub cycles
// through, plus metadata for the detail lightbox.
var PROJECTS = [
{
id: "p-atlas", cat: "commercial", title: "Atlas — Electric",
client: "Atlas Motors", role: "Director / DP", year: "2026",
camera: "Sony FX6 · 24mm", dur: "02:41", durMs: 161000,
tags: ["4K DCI", "Anamorphic"],
syn: "A dusk-lit hero spot for the Atlas EV — long dolly moves over wet asphalt, amber street reflections, cut to a driving synth pulse.",
frames: ["#3a2a12", "#5c3d10", "#8a5a12", "#c07c14", "#5c3d10"]
},
{
id: "p-neon", cat: "music", title: "Neon Tide",
client: "Kova (feat. Lys)", role: "Director", year: "2026",
camera: "RED Komodo · 35mm", dur: "03:58", durMs: 238000,
tags: ["6K", "Strobe"],
syn: "A one-take performance video drenched in magenta haze, practical neon rigs, and a slow 360° orbit that locks to the drop.",
frames: ["#2a1030", "#4a1250", "#7a1470", "#b0208a", "#4a1250"]
},
{
id: "p-shore", cat: "doc", title: "The Last Shore",
client: "Meridian Films", role: "Cinematographer", year: "2025",
camera: "ARRI Amira · 50mm", dur: "12:07", durMs: 727000,
tags: ["Verité", "Handheld"],
syn: "A short documentary following coastal fishers at first light — natural grade, salt-worn faces, and the sea doing the talking.",
frames: ["#0d2430", "#124050", "#186278", "#2a8299", "#124050"]
},
{
id: "p-ember", cat: "short", title: "Ember Room",
client: "Independent", role: "Writer / Director", year: "2025",
camera: "Blackmagic 6K · 85mm", dur: "08:22", durMs: 502000,
tags: ["Narrative", "Low-key"],
syn: "A two-hander drama shot almost entirely by firelight — warm falloff, close coverage, tension held in the silences.",
frames: ["#301505", "#4c2208", "#70330c", "#a04a10", "#4c2208"]
},
{
id: "p-flux", cat: "commercial", title: "Flux Skincare",
client: "Flux Labs", role: "DP", year: "2026",
camera: "Sony FX3 · 90mm macro", dur: "00:45", durMs: 45000,
tags: ["Macro", "Highspeed"],
syn: "Glossy product film — 1000fps droplet macros, seamless white cyc, and a clean minty grade for a launch campaign.",
frames: ["#123028", "#164a3a", "#1c6a52", "#28926f", "#164a3a"]
},
{
id: "p-pulse", cat: "music", title: "Pulsewave",
client: "Distant Rooms", role: "Director / Editor", year: "2025",
camera: "RED Komodo · 18mm", dur: "02:57", durMs: 177000,
tags: ["Projection", "In-camera"],
syn: "In-camera projection mapping across a live band — geometric light spilling over instruments, no post comps.",
frames: ["#101a3a", "#14245c", "#1a3488", "#2a4ab0", "#14245c"]
},
{
id: "p-north", cat: "doc", title: "North of Quiet",
client: "Boreal Trust", role: "DP", year: "2025",
camera: "ARRI Amira · 28mm", dur: "09:41", durMs: 581000,
tags: ["Aerial", "Timelapse"],
syn: "Landscape doc on a rewilding project — drone glides over frost, day-to-night timelapses, an unhurried, cold-blue palette.",
frames: ["#12222c", "#1a3644", "#245262", "#367888", "#1a3644"]
},
{
id: "p-solace", cat: "short", title: "Solace",
client: "Nightfall Collective", role: "Cinematographer", year: "2024",
camera: "Blackmagic 6K · 35mm", dur: "06:14", durMs: 374000,
tags: ["Rain rig", "Night ext"],
syn: "A rain-soaked night exterior piece — sodium streetlamps, reflective puddles, a lone figure and a slow push-in.",
frames: ["#241028", "#3a1642", "#54205e", "#7a3084", "#3a1642"]
},
{
id: "p-crest", cat: "commercial", title: "Crest Coffee",
client: "Crest Roasters", role: "Director / DP", year: "2026",
camera: "Sony FX6 · 50mm", dur: "01:12", durMs: 72000,
tags: ["Tabletop", "Slider"],
syn: "A cozy morning brand film — steam catching window light, tactile slider moves, and a golden, grain-kissed grade.",
frames: ["#2c1a08", "#48280c", "#6a3c12", "#985a18", "#48280c"]
},
{
id: "p-vertigo", cat: "music", title: "Vertigo Lights",
client: "Halcyon", role: "Director", year: "2024",
camera: "RED Komodo · 24mm", dur: "04:03", durMs: 243000,
tags: ["Gimbal", "Long-lens"],
syn: "A rooftop performance at blue hour — sweeping gimbal orbits, city bokeh, and a cool teal-to-amber contrast grade.",
frames: ["#0e2830", "#123c44", "#185462", "#c07c14", "#123c44"]
},
{
id: "p-relay", cat: "doc", title: "Relay",
client: "Field & Signal", role: "DP", year: "2024",
camera: "ARRI Amira · 40mm", dur: "10:58", durMs: 658000,
tags: ["Interview", "Archival"],
syn: "A documentary on amateur radio operators — warm interview coverage intercut with grainy archival transfers.",
frames: ["#2a1e0c", "#443210", "#634a16", "#8c6a1e", "#443210"]
},
{
id: "p-hollow", cat: "short", title: "Hollow Hour",
client: "Independent", role: "Writer / Director", year: "2024",
camera: "Blackmagic 6K · 100mm", dur: "07:36", durMs: 456000,
tags: ["Thriller", "Steadicam"],
syn: "A slow-burn thriller short — long Steadicam corridors, cold key light, and a desaturated, high-contrast look.",
frames: ["#141820", "#1e2632", "#2c3848", "#465468", "#1e2632"]
}
];
/* ---- Helpers -------------------------------------------------------- */
var $ = function (s, r) { return (r || document).querySelector(s); };
var toastEl = $("#toast");
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-on");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("is-on"); }, 2200);
}
function frameBg(color) {
// Layer a soft vignette + directional light over the base tone.
return (
"radial-gradient(120% 90% at 30% 15%, rgba(255,255,255,.12), transparent 55%)," +
"radial-gradient(90% 80% at 85% 100%, rgba(0,0,0,.5), transparent 60%)," +
"linear-gradient(160deg, " + color + ", #0a0a0b)"
);
}
function msToTc(ms) {
var t = Math.max(0, Math.round(ms / 1000));
var m = Math.floor(t / 60);
var s = t % 60;
return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
}
/* ---- Card rendering ------------------------------------------------- */
var grid = $("#grid");
var countEl = $("#count");
var emptyEl = $("#empty");
function buildCard(p, i) {
var card = document.createElement("button");
card.className = "card";
card.type = "button";
card.setAttribute("role", "listitem");
card.dataset.id = p.id;
card.dataset.cat = p.cat;
card.style.animationDelay = (reduceMotion ? 0 : Math.min(i, 8) * 45) + "ms";
card.setAttribute("aria-label", p.title + " — " + CATS[p.cat] + ", " + p.dur);
var framesHtml = p.frames
.map(function (c, fi) {
return '<div class="thumb-frame' + (fi === 0 ? " is-on" : "") +
'" style="background:' + frameBg(c) + '"></div>';
})
.join("");
var tagsHtml = p.tags
.map(function (t) { return '<span class="tag">' + t + "</span>"; })
.join("");
card.innerHTML =
'<div class="thumb">' +
framesHtml +
'<span class="cat">' + CATS[p.cat] + "</span>" +
'<span class="dur">' + p.dur + "</span>" +
'<span class="play-hint"><span></span></span>' +
'<span class="scrub"></span>' +
"</div>" +
'<div class="card-body">' +
'<h3 class="card-title">' + p.title + "</h3>" +
'<p class="card-sub">' + p.client + " · " + p.year + "</p>" +
'<div class="card-tags">' + tagsHtml + "</div>" +
"</div>";
attachScrub(card, p);
card.addEventListener("click", function () { openLightbox(p.id); });
return card;
}
/* ---- Hover scrub ---------------------------------------------------- */
function attachScrub(card, p) {
if (reduceMotion) return;
var frames = card.querySelectorAll(".thumb-frame");
var scrub = $(".scrub", card);
var idx = 0;
var timer = null;
function step() {
frames[idx].classList.remove("is-on");
idx = (idx + 1) % frames.length;
frames[idx].classList.add("is-on");
scrub.style.width = ((idx / (frames.length - 1)) * 100) + "%";
}
function start() {
if (timer) return;
timer = setInterval(step, 520);
}
function stop() {
clearInterval(timer);
timer = null;
frames.forEach(function (f, i) { f.classList.toggle("is-on", i === 0); });
idx = 0;
scrub.style.width = "0";
}
card.addEventListener("mouseenter", start);
card.addEventListener("mouseleave", stop);
card.addEventListener("focus", start);
card.addEventListener("blur", stop);
}
/* ---- Filtering ------------------------------------------------------ */
var current = "all";
function render() {
grid.innerHTML = "";
var list = PROJECTS.filter(function (p) {
return current === "all" || p.cat === current;
});
list.forEach(function (p, i) { grid.appendChild(buildCard(p, i)); });
var n = list.length;
countEl.textContent = n + (n === 1 ? " project" : " projects");
emptyEl.hidden = n !== 0;
}
var chips = document.querySelectorAll(".chip");
chips.forEach(function (chip) {
chip.addEventListener("click", function () {
chips.forEach(function (c) { c.classList.remove("is-active"); });
chip.classList.add("is-active");
chip.setAttribute("aria-pressed", "true");
current = chip.dataset.filter;
render();
});
});
/* ---- Lightbox ------------------------------------------------------- */
var lb = $("#lightbox");
var lbFrame = $("#lb-frame");
var lbCat = $("#lb-cat");
var lbTitle = $("#lb-title");
var lbSyn = $("#lb-syn");
var lbClient = $("#lb-client");
var lbRole = $("#lb-role");
var lbYear = $("#lb-year");
var lbCam = $("#lb-cam");
var lbScrub = $("#lb-scrub");
var lbCur = $("#lb-cur");
var lbDur = $("#lb-dur");
var lastFocus = null;
var activeProject = null;
function paintScrubFrame(p, ratio) {
// Map the 0..1 playhead onto the project's frame palette.
var fi = Math.min(p.frames.length - 1, Math.floor(ratio * p.frames.length));
lbFrame.style.background = frameBg(p.frames[fi]);
lbCur.textContent = msToTc(ratio * p.durMs);
}
function fillLightbox(p) {
activeProject = p;
lbCat.textContent = CATS[p.cat];
lbTitle.textContent = p.title;
lbSyn.textContent = p.syn;
lbClient.textContent = p.client;
lbRole.textContent = p.role;
lbYear.textContent = p.year;
lbCam.textContent = p.camera;
lbDur.textContent = p.dur;
lbScrub.value = 0;
paintScrubFrame(p, 0);
}
function openLightbox(id) {
var p = PROJECTS.find(function (x) { return x.id === id; });
if (!p) return;
lastFocus = document.activeElement;
fillLightbox(p);
lb.hidden = false;
lb.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
$(".lb-close", lb).focus();
}
function closeLightbox() {
lb.hidden = true;
lb.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
activeProject = null;
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
// Visible (filtered) order, so prev/next respects the active filter.
function visibleList() {
return PROJECTS.filter(function (p) {
return current === "all" || p.cat === current;
});
}
function step(dir) {
if (!activeProject) return;
var list = visibleList();
var i = list.findIndex(function (p) { return p.id === activeProject.id; });
if (i === -1) list = PROJECTS, i = PROJECTS.findIndex(function (p) { return p.id === activeProject.id; });
var next = (i + dir + list.length) % list.length;
fillLightbox(list[next]);
}
lbScrub.addEventListener("input", function () {
if (!activeProject) return;
paintScrubFrame(activeProject, lbScrub.value / 1000);
});
lb.addEventListener("click", function (e) {
if (e.target.hasAttribute("data-close")) closeLightbox();
});
document.addEventListener("keydown", function (e) {
if (lb.hidden) return;
if (e.key === "Escape") { closeLightbox(); return; }
if (e.key === "ArrowRight") { e.preventDefault(); step(1); }
if (e.key === "ArrowLeft") { e.preventDefault(); step(-1); }
if (e.key === "Tab") {
// Simple focus trap within the panel.
var f = lb.querySelectorAll("button, input, a[href]");
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
/* ---- Global click actions (copy / scroll / open) -------------------- */
document.addEventListener("click", function (e) {
var copyEl = e.target.closest("[data-copy]");
if (copyEl) {
var text = copyEl.getAttribute("data-copy");
if (activeProject && text.slice(-1) === "/") text += activeProject.id.replace(/^p-/, "");
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function () { toast("Copied: " + text); },
function () { toast(text); }
);
} else {
toast(text);
}
return;
}
var scrollEl = e.target.closest("[data-scroll]");
if (scrollEl) {
var target = $(scrollEl.getAttribute("data-scroll"));
if (target) target.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth" });
return;
}
var openEl = e.target.closest("[data-open]");
if (openEl) openLightbox(openEl.getAttribute("data-open"));
});
/* ---- Footer timecode ticker ---------------------------------------- */
var footTc = $(".foot-tc");
if (footTc && !reduceMotion) {
var frame = 0;
setInterval(function () {
frame = (frame + 1) % 24;
var d = new Date();
var pad = function (n) { return (n < 10 ? "0" : "") + n; };
footTc.textContent =
"TC " + pad(d.getHours()) + ":" + pad(d.getMinutes()) +
":" + pad(d.getSeconds()) + ":" + pad(frame);
}, 1000 / 24);
}
/* ---- Init ----------------------------------------------------------- */
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nova Reel — 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>
<a class="skip-link" href="#work">Skip to work</a>
<header class="topbar">
<div class="brand">
<span class="brand-dot" aria-hidden="true"></span>
<span class="brand-name">NOVA<span class="brand-accent">REEL</span></span>
</div>
<nav class="topnav" aria-label="Primary">
<a href="#work">Work</a>
<a href="#work">About</a>
<a href="#work">Contact</a>
</nav>
<button class="btn-hire" type="button" data-copy="[email protected]">Book a shoot</button>
</header>
<main>
<!-- HERO / SHOWREEL -->
<section class="hero" aria-label="Showreel">
<div class="letterbox letterbox-top" aria-hidden="true"></div>
<div class="hero-inner">
<p class="hero-eyebrow"><span class="rec-dot" aria-hidden="true"></span> 2026 SHOWREEL · 4K DCI</p>
<h1 class="hero-title">Frames that<br /><em>move</em> people.</h1>
<p class="hero-sub">
Cinematic commercials, music videos, and documentary work by
Mara Vidalis — shot handheld, graded warm, cut for tension.
</p>
<div class="hero-actions">
<button class="btn-play" type="button" data-open="p-atlas">
<span class="play-tri" aria-hidden="true"></span>
Play reel
<span class="tc" aria-hidden="true">02:41</span>
</button>
<button class="btn-ghost" type="button" data-scroll="#work">Browse work</button>
</div>
</div>
<div class="letterbox letterbox-bottom" aria-hidden="true"></div>
</section>
<!-- FILTER + GRID -->
<section class="work" id="work" aria-label="Portfolio">
<div class="work-head">
<div>
<h2 class="work-title">Selected work</h2>
<p class="work-count" id="count" aria-live="polite">12 projects</p>
</div>
<div class="filters" role="group" aria-label="Filter by type">
<button class="chip is-active" type="button" data-filter="all">All <span class="chip-n">12</span></button>
<button class="chip" type="button" data-filter="commercial">Commercial <span class="chip-n">4</span></button>
<button class="chip" type="button" data-filter="music">Music Video <span class="chip-n">3</span></button>
<button class="chip" type="button" data-filter="doc">Documentary <span class="chip-n">3</span></button>
<button class="chip" type="button" data-filter="short">Short Film <span class="chip-n">2</span></button>
</div>
</div>
<div class="grid" id="grid" role="list"><!-- cards injected by script.js --></div>
<p class="empty" id="empty" hidden>No projects in this category yet.</p>
</section>
</main>
<footer class="foot">
<span>© 2026 Nova Reel Studio</span>
<span class="foot-tc" aria-hidden="true">TC 00:00:00:00</span>
</footer>
<!-- DETAIL LIGHTBOX -->
<div class="lightbox" id="lightbox" hidden aria-hidden="true">
<div class="lb-backdrop" data-close></div>
<div class="lb-panel" role="dialog" aria-modal="true" aria-labelledby="lb-title">
<button class="lb-close" type="button" data-close aria-label="Close">×</button>
<div class="lb-stage" id="lb-stage">
<div class="letterbox" aria-hidden="true"></div>
<div class="lb-frame" id="lb-frame"></div>
<div class="letterbox" aria-hidden="true"></div>
<span class="lb-badge" id="lb-cat"></span>
</div>
<div class="lb-timeline">
<input type="range" id="lb-scrub" min="0" max="1000" value="0" aria-label="Scrub playhead" />
<div class="lb-times">
<span class="tc" id="lb-cur">00:00</span>
<span class="tc" id="lb-dur">00:00</span>
</div>
</div>
<div class="lb-body">
<h3 class="lb-title" id="lb-title"></h3>
<p class="lb-syn" id="lb-syn"></p>
<dl class="lb-meta">
<div><dt>Client</dt><dd id="lb-client"></dd></div>
<div><dt>Role</dt><dd id="lb-role"></dd></div>
<div><dt>Year</dt><dd id="lb-year"></dd></div>
<div><dt>Camera</dt><dd id="lb-cam"></dd></div>
</dl>
<div class="lb-actions">
<button class="btn-hire" type="button" data-copy="https://novareel.studio/w/">Copy share link</button>
<button class="btn-ghost" type="button" data-close>Close</button>
</div>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>A film-forward portfolio page for a freelance videographer. A letterboxed hero reel sets the cinematic tone with black bars and an amber play affordance, then a responsive grid of project cards follows — each carrying a category tag, a monospace duration badge, and a gradient poster frame. Filter chips across the top switch the visible set by type (Commercial, Music Video, Documentary, Short Film) with live counts, and the grid re-flows with a soft stagger.
Hovering a card triggers a scrub: the poster cross-fades through a sequence of gradient preview frames while a thin amber progress line sweeps left to right, mimicking a video timeline preview. Every card is keyboard-focusable, and pressing Enter or clicking opens a full-screen detail lightbox with the project’s title, client, role, gear list, a synopsis, and a scrubbable timeline you can drag or arrow-key through to update the displayed timecode.
The whole thing runs on vanilla JS — no frameworks. A tiny toast helper confirms actions like copying a share link, the lightbox traps focus and closes on Escape or backdrop click, and a prefers-reduced-motion guard disables the scrub animation for users who ask for less motion.