← Gallery

Media Toolbox

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.

1 · Full-bleed video band

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.

Video slotmuted loop · 16:9 cover · ≤10s ambient footage · lazy data-src

Text sits on the scrim, not the raw footage

Production wiring
<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();
}

2 · Inline video card

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.

Video slotautoplay muted playsinline · rounded 12px
Production wiring
<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'; };

3 · Click-to-play testimonial grid

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."

Customer Name · since 2023

"The quote makes the video worth clicking."

Customer Name · since 2021
Production wiring
<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
  }));

4 · Photo patterns

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.

Photo3:4 portrait · card top

Team member

Card body continues below the portrait.

photo

Round avatar

3px accent ring · 80–100px

Rules of thumb

  • object-fit: cover, always
  • object-position tuned per crop (faces: center 20%)
  • radius 8–12px (50% avatars)
  • webp + jpg fallback via <picture>
  • first image fetchpriority="high", rest lazy
Production wiring
<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