Where photos and video go, and how they're framed — patterns lifted from
real production sites that convert. Gallery pages use placeholder frames (this page's gradients);
on a real client site each frame becomes a real <video> or
<picture> per the wiring under each demo. House rule: video is always
muted-first — autoplay never carries sound; sound is opt-in via an unmute button.
Use for: the cinematic moment — space/atmosphere footage (gym floor,
dining room, studio) directly under the hero or between sections. ~60–70vh, cover-fit, gradient
scrim for legibility, lazy-loaded via data-src + IntersectionObserver so nothing
downloads until it scrolls near.
Text sits on the scrim, not the raw footage
<section class="video-band">
<video class="band-video" muted loop playsinline preload="none"
data-src="/media/space-loop.mp4" poster="/media/space-poster.jpg"
aria-label="Video of the space"></video>
</section>
.video-band { height: 70vh; min-height: 480px; position: relative; overflow: hidden; background: #000; }
.video-band video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.video-band::after { content: ""; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(to bottom, rgb(10 10 10 / .18), rgb(10 10 10 / .45)); }
// lazy-load at 25% visible, play once loaded; catch the play() promise
const v = document.querySelector('.band-video');
new IntersectionObserver((entries, obs) => {
entries.forEach(e => { if (e.intersectionRatio >= 0.25) {
v.src = v.dataset.src; v.load();
const p = v.play(); if (p && p.catch) p.catch(() => {});
obs.disconnect();
}});
}, { threshold: [0, 0.25] }).observe(v);
// reduce-motion users get the poster, not autoplay
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
v.removeAttribute('autoplay'); v.pause();
}Use for: a talking-head or explainer moment mid-page ("meet the owner", "why we do it this way"). Centered rounded card, max-width ~650px desktop, autoplay muted with a corner unmute button — sound is opt-in, never on by default. For HLS (Bunny-style streams), attach with hls.js and fall back to native HLS on Safari.
<div class="video-card">
<video id="introVideo" autoplay muted playsinline poster="/media/intro-poster.jpg"></video>
<button class="unmute" id="unmute">🔇 Tap for sound</button>
</div>
/* plain MP4: just add src. HLS stream (e.g. BunnyCDN playlist.m3u8): */
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
const src = 'https://vz-ZONE.b-cdn.net/VIDEO-GUID/playlist.m3u8';
if (typeof Hls !== 'undefined' && Hls.isSupported()) {
const hls = new Hls(); hls.loadSource(src); hls.attachMedia(introVideo);
} else if (introVideo.canPlayType('application/vnd.apple.mpegurl')) {
introVideo.src = src; // Safari native HLS
}
unmute.onclick = () => { introVideo.muted = !introVideo.muted;
unmute.textContent = introVideo.muted ? '🔇 Tap for sound' : '🔊 Sound on'; };Use for: customer/member testimonials — 9:16 portrait phone footage, 2-up grid with the quote beside it. The page loads only a styled play button (zero video cost); the real player is injected on click, with sound, because the click IS the opt-in.
"Pull the best line of the video out as text."
"The quote makes the video worth clicking."
<div class="testi-video">
<button class="testi-play" data-embed-src="https://iframe.mediadelivery.net/embed/LIB/GUID?responsive=true"
aria-label="Play NAME testimonial video">
<span class="testi-play-icon" aria-hidden="true"></span>
</button>
</div>
/* 9:16 frame: aspect-ratio: 9/16; width ~280px; rounded; overflow hidden */
/* play glyph = CSS triangle on a circle — no icon font needed */
document.querySelectorAll('.testi-play').forEach(btn =>
btn.addEventListener('click', () => {
const f = document.createElement('iframe');
f.src = btn.dataset.embedSrc + '&autoplay=true';
f.loading = 'lazy'; f.allowFullscreen = true;
f.allow = 'autoplay; encrypted-media; picture-in-picture';
f.title = btn.getAttribute('aria-label');
btn.replaceWith(f); // iframe fills the same absolutely-positioned frame
}));Three treatments cover almost everything: 3:4 portrait card tops
(team/service cards), round accent-ringed avatars (compact team rows), and a square gallery grid
with zoom-on-hover (space/product shots). Always object-fit: cover, explicit
width/height (zero CLS), webp with jpg fallback, loading="lazy" below the fold.
Team member
Card body continues below the portrait.
Round avatar
3px accent ring · 80–100px
Rules of thumb
center 20%)<picture>fetchpriority="high", rest lazy
<picture>
<source srcset="/img/team-monica.webp" type="image/webp">
<img src="/img/team-monica.jpg" alt="Monica — senior stylist"
class="portrait-photo" width="600" height="800" loading="lazy">
</picture>
.portrait-photo { width: 100%; aspect-ratio: 3/4; object-fit: cover; object-position: center 20%; }
.avatar { width: 96px; height: 96px; border-radius: 50%; object-fit: cover;
border: 3px solid var(--accent); }
/* gallery zoom: wrapper overflow:hidden; img scale(1.05) on hover, .5s ease */Motion techniques: toolbox/animations.html · Quality bar: STANDARDS.md