references/gallery-carousel-patterns.md
# Gallery & Carousel Patterns — Grids, Lightbox & Carousel Accessibility
Production-ready patterns for gallery layouts, lightbox overlays, and accessible carousel implementations.
---
## 1. Masonry Grid — CSS Columns
The simplest masonry approach. Works without JavaScript.
```css
.masonry {
columns: 3;
column-gap: 16px;
padding: 16px;
}
.masonry-item {
break-inside: avoid;
margin-bottom: 16px;
border-radius: 12px;
overflow: hidden;
}
.masonry-item img {
width: 100%;
height: auto;
display: block;
}
/* Responsive */
@media (max-width: 1024px) { .masonry { columns: 2; } }
@media (max-width: 640px) { .masonry { columns: 1; } }
```
Limitation: items flow top-to-bottom per column, not left-to-right. Source order and visual order can diverge.
---
## 2. Masonry Grid — CSS Grid with JS Row Span
True masonry feel with CSS Grid, but requires JS to calculate each item's row span.
```css
.masonry-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-auto-rows: 8px; /* small row unit */
gap: 0 16px;
}
.masonry-grid-item {
/* grid-row-end is set dynamically via JS */
padding-bottom: 16px;
}
.masonry-grid-item img {
width: 100%;
height: auto;
display: block;
border-radius: 12px;
}
```
```javascript
function setMasonryRowSpans() {
const grid = document.querySelector('.masonry-grid');
const rowGap = parseInt(getComputedStyle(grid).gridAutoRows);
const items = grid.querySelectorAll('.masonry-grid-item');
items.forEach(item => {
const content = item.querySelector('img');
const contentHeight = content.getBoundingClientRect().height + 16; // + gap
const rowSpan = Math.ceil(contentHeight / rowGap);
item.style.gridRowEnd = `span ${rowSpan}`;
});
}
// Run after images load
window.addEventListener('load', setMasonryRowSpans);
window.addEventListener('resize', setMasonryRowSpans);
```
---
## 3. Justified Grid (Flickr-Style)
All rows fill the full width. Image widths vary to maintain aspect ratios with uniform row height.
```javascript
// Using justified-layout (by Flickr)
import justifiedLayout from 'justified-layout';
const photos = [
{ width: 4000, height: 3000 },
{ width: 2000, height: 3000 },
{ width: 3000, height: 2000 },
// ...
];
const geometry = justifiedLayout(photos.map(p => p.width / p.height), {
containerWidth: containerElement.offsetWidth,
targetRowHeight: 240,
boxSpacing: 8,
});
// geometry.boxes contains { width, height, top, left } for each photo
geometry.boxes.forEach((box, i) => {
const el = photoElements[i];
el.style.position = 'absolute';
el.style.width = `${box.width}px`;
el.style.height = `${box.height}px`;
el.style.top = `${box.top}px`;
el.style.left = `${box.left}px`;
});
// Set container height
container.style.height = `${geometry.containerHeight}px`;
```
---
## 4. Uniform Grid
All images forced to identical dimensions. Simplest and most predictable layout.
```css
.uniform-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
padding: 8px;
}
.uniform-grid-item {
aspect-ratio: 1; /* square */
overflow: hidden;
border-radius: 8px;
cursor: pointer;
position: relative;
}
.uniform-grid-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.uniform-grid-item:hover img {
transform: scale(1.05);
}
/* Hover overlay */
.uniform-grid-item::after {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0);
transition: background 0.3s ease;
}
.uniform-grid-item:hover::after {
background: rgba(0, 0, 0, 0.15);
}
```
### Ratio Variants
```css
/* 4:3 cards */
.grid-4-3 .uniform-grid-item { aspect-ratio: 4/3; }
/* 16:9 widescreen */
.grid-16-9 .uniform-grid-item { aspect-ratio: 16/9; }
/* 3:2 photography */
.grid-3-2 .uniform-grid-item { aspect-ratio: 3/2; }
```
---
## 5. Mosaic / Feature Grid
Predetermined layout with one large featured item and smaller supporting items.
```css
.mosaic-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(2, 200px);
gap: 8px;
}
/* Featured: spans 2 cols + 2 rows */
.mosaic-grid .featured {
grid-column: 1 / 3;
grid-row: 1 / 3;
}
.mosaic-grid .item {
overflow: hidden;
border-radius: 12px;
}
.mosaic-grid .item img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 5-photo Instagram-style layout */
.mosaic-5 {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 200px);
gap: 4px;
}
.mosaic-5 .item:first-child {
grid-column: 1 / 3;
grid-row: 1 / 3;
}
```
### Responsive Mosaic
```css
@media (max-width: 768px) {
.mosaic-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(3, 150px);
}
.mosaic-grid .featured {
grid-column: 1 / 3;
grid-row: 1 / 2;
}
}
@media (max-width: 480px) {
.mosaic-grid {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.mosaic-grid .featured {
grid-column: 1;
grid-row: auto;
}
.mosaic-grid .item { aspect-ratio: 16/9; }
}
```
---
## 6. Lightbox — Full Implementation
### HTML
```html
<!-- Trigger: gallery items -->
<div class="gallery" role="list">
<button class="gallery-item" role="listitem" data-index="0"
data-full-src="photo1-full.webp" data-alt="Mountain at sunrise"
aria-label="View Mountain at sunrise, image 1 of 12">
<img src="photo1-thumb.webp" alt="" width="300" height="200">
</button>
<!-- Repeat for each item -->
</div>
<!-- Lightbox overlay (hidden by default) -->
<div class="lightbox" role="dialog" aria-label="Image viewer" aria-modal="true"
hidden id="lightbox">
<div class="lightbox-backdrop"></div>
<button class="lightbox-close" aria-label="Close image viewer">
<svg><!-- X icon --></svg>
</button>
<button class="lightbox-prev" aria-label="Previous image">
<svg><!-- Left arrow --></svg>
</button>
<button class="lightbox-next" aria-label="Next image">
<svg><!-- Right arrow --></svg>
</button>
<div class="lightbox-content">
<img class="lightbox-image" src="" alt="" />
</div>
<div class="lightbox-caption" aria-live="polite">
<p class="lightbox-alt-text"></p>
<p class="lightbox-counter">1 of 12</p>
</div>
</div>
```
### CSS
```css
.lightbox {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox[hidden] { display: none; }
.lightbox-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.92);
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 85vh;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-image {
max-width: 100%;
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
user-select: none;
}
.lightbox-close {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 48px;
height: 48px;
cursor: pointer;
color: white;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.lightbox-close:hover { background: rgba(255, 255, 255, 0.2); }
.lightbox-prev,
.lightbox-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 48px;
height: 48px;
cursor: pointer;
color: white;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.lightbox-prev { left: 16px; }
.lightbox-next { right: 16px; }
.lightbox-prev:hover,
.lightbox-next:hover { background: rgba(255, 255, 255, 0.2); }
.lightbox-caption {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
text-align: center;
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
}
.lightbox-counter {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-top: 4px;
}
```
### JavaScript (Accessible)
```javascript
class Lightbox {
constructor(gallerySelector) {
this.gallery = document.querySelector(gallerySelector);
this.lightbox = document.getElementById('lightbox');
this.image = this.lightbox.querySelector('.lightbox-image');
this.altText = this.lightbox.querySelector('.lightbox-alt-text');
this.counter = this.lightbox.querySelector('.lightbox-counter');
this.items = [...this.gallery.querySelectorAll('.gallery-item')];
this.currentIndex = 0;
this.triggerElement = null;
this.previouslyFocused = null;
this.bindEvents();
}
bindEvents() {
// Open on gallery item click
this.items.forEach((item, index) => {
item.addEventListener('click', () => this.open(index));
});
// Close
this.lightbox.querySelector('.lightbox-close').addEventListener('click', () => this.close());
this.lightbox.querySelector('.lightbox-backdrop').addEventListener('click', () => this.close());
// Navigate
this.lightbox.querySelector('.lightbox-prev').addEventListener('click', () => this.prev());
this.lightbox.querySelector('.lightbox-next').addEventListener('click', () => this.next());
// Keyboard
this.lightbox.addEventListener('keydown', (e) => this.handleKeydown(e));
}
open(index) {
this.previouslyFocused = document.activeElement;
this.currentIndex = index;
this.updateImage();
this.lightbox.hidden = false;
document.body.style.overflow = 'hidden';
// Focus trap: focus the close button
this.lightbox.querySelector('.lightbox-close').focus();
// Trap focus within lightbox
this.trapFocus();
}
close() {
this.lightbox.hidden = true;
document.body.style.overflow = '';
// Return focus to trigger element
if (this.previouslyFocused) {
this.previouslyFocused.focus();
}
}
prev() {
this.currentIndex = (this.currentIndex - 1 + this.items.length) % this.items.length;
this.updateImage();
}
next() {
this.currentIndex = (this.currentIndex + 1) % this.items.length;
this.updateImage();
}
updateImage() {
const item = this.items[this.currentIndex];
this.image.src = item.dataset.fullSrc;
this.image.alt = item.dataset.alt;
this.altText.textContent = item.dataset.alt;
this.counter.textContent = `${this.currentIndex + 1} of ${this.items.length}`;
}
handleKeydown(e) {
switch (e.key) {
case 'Escape':
this.close();
break;
case 'ArrowLeft':
e.preventDefault();
this.prev();
break;
case 'ArrowRight':
e.preventDefault();
this.next();
break;
}
}
trapFocus() {
const focusable = this.lightbox.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
this.lightbox.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
}
}
// Initialize
new Lightbox('.gallery');
```
---
## 7. Carousel — CSS Scroll Snap
Native-feeling carousel with no JS framework dependency.
### HTML
```html
<div class="carousel-container" role="region" aria-label="Featured products" aria-roledescription="carousel">
<div class="carousel-track" role="list">
<div class="carousel-slide" role="group" aria-roledescription="slide" aria-label="Slide 1 of 5">
<img src="slide1.webp" alt="Product A" width="800" height="450">
</div>
<div class="carousel-slide" role="group" aria-roledescription="slide" aria-label="Slide 2 of 5">
<img src="slide2.webp" alt="Product B" width="800" height="450">
</div>
<!-- ... more slides ... -->
</div>
<div class="carousel-controls">
<button class="carousel-prev" aria-label="Previous slide" aria-controls="carousel-track">
<svg width="24" height="24"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
</button>
<button class="carousel-next" aria-label="Next slide" aria-controls="carousel-track">
<svg width="24" height="24"><path d="M9 6l6 6-6 6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
</button>
</div>
<div class="carousel-dots" role="tablist" aria-label="Slide navigation">
<button role="tab" aria-selected="true" aria-label="Go to slide 1" class="dot active"></button>
<button role="tab" aria-selected="false" aria-label="Go to slide 2" class="dot"></button>
<button role="tab" aria-selected="false" aria-label="Go to slide 3" class="dot"></button>
<button role="tab" aria-selected="false" aria-label="Go to slide 4" class="dot"></button>
<button role="tab" aria-selected="false" aria-label="Go to slide 5" class="dot"></button>
</div>
</div>
```
### CSS
```css
.carousel-container {
position: relative;
overflow: hidden;
}
.carousel-track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.carousel-track::-webkit-scrollbar { display: none; }
.carousel-slide {
flex: 0 0 100%;
scroll-snap-align: start;
}
.carousel-slide img {
width: 100%;
height: auto;
display: block;
}
/* Peek variant: show 10% of next slide */
.carousel-track.peek .carousel-slide {
flex: 0 0 calc(100% - 48px);
margin-right: 8px;
}
/* Multi-item carousel */
.carousel-track.multi .carousel-slide {
flex: 0 0 calc(33.333% - 11px); /* 3 visible, accounting for gaps */
margin-right: 16px;
}
@media (max-width: 1024px) {
.carousel-track.multi .carousel-slide {
flex: 0 0 calc(50% - 8px);
}
}
@media (max-width: 640px) {
.carousel-track.multi .carousel-slide {
flex: 0 0 calc(100% - 32px); /* peek at next */
}
}
/* Arrow buttons */
.carousel-prev, .carousel-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
background: white;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s, background 0.2s;
}
.carousel-prev:hover, .carousel-next:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.carousel-prev { left: 12px; }
.carousel-next { right: 12px; }
/* Hide arrows on touch devices */
@media (pointer: coarse) {
.carousel-prev, .carousel-next { display: none; }
}
/* Dots */
.carousel-dots {
display: flex;
justify-content: center;
gap: 8px;
padding: 12px 0;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
padding: 0;
transition: background 0.2s, transform 0.2s;
}
.dot.active {
background: rgba(0, 0, 0, 0.8);
transform: scale(1.25);
}
.dot:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
```
### JavaScript (Scroll-Based)
```javascript
class ScrollSnapCarousel {
constructor(container) {
this.container = container;
this.track = container.querySelector('.carousel-track');
this.slides = [...container.querySelectorAll('.carousel-slide')];
this.dots = [...container.querySelectorAll('.dot')];
this.prevBtn = container.querySelector('.carousel-prev');
this.nextBtn = container.querySelector('.carousel-next');
this.currentIndex = 0;
this.bindEvents();
this.updateAriaStates();
}
bindEvents() {
// Arrow buttons
this.prevBtn?.addEventListener('click', () => this.goTo(this.currentIndex - 1));
this.nextBtn?.addEventListener('click', () => this.goTo(this.currentIndex + 1));
// Dot buttons
this.dots.forEach((dot, i) => {
dot.addEventListener('click', () => this.goTo(i));
});
// Detect scroll position to update active dot
let scrollTimeout;
this.track.addEventListener('scroll', () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => this.onScrollEnd(), 100);
});
// Keyboard navigation
this.container.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); this.goTo(this.currentIndex - 1); }
if (e.key === 'ArrowRight') { e.preventDefault(); this.goTo(this.currentIndex + 1); }
});
}
goTo(index) {
const clamped = Math.max(0, Math.min(index, this.slides.length - 1));
this.slides[clamped].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
this.currentIndex = clamped;
this.updateAriaStates();
}
onScrollEnd() {
const trackRect = this.track.getBoundingClientRect();
const center = trackRect.left + trackRect.width / 2;
let closest = 0;
let minDist = Infinity;
this.slides.forEach((slide, i) => {
const rect = slide.getBoundingClientRect();
const slideCtr = rect.left + rect.width / 2;
const dist = Math.abs(slideCtr - center);
if (dist < minDist) { minDist = dist; closest = i; }
});
this.currentIndex = closest;
this.updateAriaStates();
}
updateAriaStates() {
// Update dots
this.dots.forEach((dot, i) => {
dot.classList.toggle('active', i === this.currentIndex);
dot.setAttribute('aria-selected', i === this.currentIndex ? 'true' : 'false');
});
// Update slides
this.slides.forEach((slide, i) => {
const isActive = i === this.currentIndex;
slide.setAttribute('aria-hidden', isActive ? 'false' : 'true');
slide.querySelectorAll('a, button, input').forEach(el => {
el.setAttribute('tabindex', isActive ? '0' : '-1');
});
});
// Update arrows
if (this.prevBtn) this.prevBtn.disabled = this.currentIndex === 0;
if (this.nextBtn) this.nextBtn.disabled = this.currentIndex === this.slides.length - 1;
}
}
// Initialize all carousels on page
document.querySelectorAll('.carousel-container').forEach(el => new ScrollSnapCarousel(el));
```
---
## 8. Autoplay Carousel — Accessibility-Compliant
```javascript
class AutoplayCarousel extends ScrollSnapCarousel {
constructor(container, interval = 5000) {
super(container);
this.interval = interval;
this.timer = null;
this.isPaused = false;
this.pauseBtn = container.querySelector('.carousel-pause');
this.setupAutoplay();
}
setupAutoplay() {
// Respect reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
this.startAutoplay();
// Pause on hover
this.container.addEventListener('mouseenter', () => this.pauseAutoplay());
this.container.addEventListener('mouseleave', () => {
if (!this.isPaused) this.startAutoplay();
});
// Pause on focus within
this.container.addEventListener('focusin', () => this.pauseAutoplay());
this.container.addEventListener('focusout', (e) => {
if (!this.container.contains(e.relatedTarget) && !this.isPaused) {
this.startAutoplay();
}
});
// Pause on page hidden
document.addEventListener('visibilitychange', () => {
if (document.hidden) this.pauseAutoplay();
else if (!this.isPaused) this.startAutoplay();
});
// Pause/play button
this.pauseBtn?.addEventListener('click', () => this.togglePause());
// Stop autoplay on manual interaction
this.container.querySelectorAll('.carousel-prev, .carousel-next, .dot').forEach(btn => {
btn.addEventListener('click', () => {
this.isPaused = true;
this.pauseAutoplay();
this.updatePauseButton();
});
});
}
startAutoplay() {
this.pauseAutoplay(); // clear existing
this.timer = setInterval(() => {
const next = (this.currentIndex + 1) % this.slides.length;
this.goTo(next);
}, this.interval);
}
pauseAutoplay() {
clearInterval(this.timer);
this.timer = null;
}
togglePause() {
this.isPaused = !this.isPaused;
if (this.isPaused) this.pauseAutoplay();
else this.startAutoplay();
this.updatePauseButton();
}
updatePauseButton() {
if (!this.pauseBtn) return;
this.pauseBtn.setAttribute('aria-label', this.isPaused ? 'Play slideshow' : 'Pause slideshow');
this.pauseBtn.textContent = this.isPaused ? 'Play' : 'Pause';
}
}
```
---
## 9. Filmstrip / Thumbnail Navigation
Horizontal thumbnail strip that controls a main image viewer.
```html
<div class="filmstrip-viewer">
<div class="filmstrip-main">
<img id="main-image" src="photo1-large.webp" alt="Photo 1" width="800" height="533">
</div>
<div class="filmstrip-strip" role="tablist" aria-label="Photo thumbnails">
<button role="tab" aria-selected="true" class="filmstrip-thumb active" data-full="photo1-large.webp" data-alt="Photo 1">
<img src="photo1-thumb.webp" alt="" width="80" height="80">
</button>
<button role="tab" aria-selected="false" class="filmstrip-thumb" data-full="photo2-large.webp" data-alt="Photo 2">
<img src="photo2-thumb.webp" alt="" width="80" height="80">
</button>
<!-- more thumbnails -->
</div>
</div>
```
```css
.filmstrip-main {
width: 100%;
aspect-ratio: 3/2;
overflow: hidden;
border-radius: 12px;
margin-bottom: 12px;
}
.filmstrip-main img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s ease;
}
.filmstrip-strip {
display: flex;
gap: 8px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
padding: 4px 0;
}
.filmstrip-strip::-webkit-scrollbar { display: none; }
.filmstrip-thumb {
flex: 0 0 72px;
height: 72px;
scroll-snap-align: start;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: none;
padding: 0;
transition: border-color 0.2s;
}
.filmstrip-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.filmstrip-thumb.active {
border-color: #2563eb;
}
.filmstrip-thumb:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
```
---
## 10. Infinite Scroll Gallery
```javascript
class InfiniteGallery {
constructor(container, fetchFn) {
this.container = container;
this.fetchFn = fetchFn;
this.page = 1;
this.loading = false;
this.hasMore = true;
this.sentinel = document.createElement('div');
this.sentinel.className = 'gallery-sentinel';
this.sentinel.setAttribute('aria-hidden', 'true');
this.container.appendChild(this.sentinel);
this.liveRegion = document.createElement('div');
this.liveRegion.setAttribute('aria-live', 'polite');
this.liveRegion.setAttribute('aria-atomic', 'true');
this.liveRegion.className = 'sr-only';
this.container.appendChild(this.liveRegion);
this.setupObserver();
}
setupObserver() {
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !this.loading && this.hasMore) {
this.loadMore();
}
},
{ rootMargin: '400px' }
);
this.observer.observe(this.sentinel);
}
async loadMore() {
this.loading = true;
this.showSpinner();
try {
const { items, hasMore } = await this.fetchFn(this.page);
this.hasMore = hasMore;
this.page++;
const fragment = document.createDocumentFragment();
items.forEach(item => {
const el = this.createItemElement(item);
fragment.appendChild(el);
});
this.container.insertBefore(fragment, this.sentinel);
this.liveRegion.textContent = `Loaded ${items.length} more images`;
} catch (error) {
this.showError();
} finally {
this.loading = false;
this.hideSpinner();
}
}
createItemElement(item) {
const div = document.createElement('div');
div.className = 'gallery-item';
div.innerHTML = `<img src="${item.src}" alt="${item.alt}" loading="lazy" width="${item.width}" height="${item.height}">`;
return div;
}
showSpinner() { /* show loading indicator */ }
hideSpinner() { /* hide loading indicator */ }
showError() { /* show retry button */ }
}
```
---
## 11. Comparison Slider
Two overlapping images with a draggable divider.
```html
<div class="compare-slider" role="img" aria-label="Before and after comparison">
<div class="compare-before">
<img src="before.webp" alt="Before renovation" width="800" height="600">
<span class="compare-label">Before</span>
</div>
<div class="compare-after">
<img src="after.webp" alt="After renovation" width="800" height="600">
<span class="compare-label">After</span>
</div>
<div class="compare-handle" role="slider" aria-label="Comparison slider" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" tabindex="0">
<div class="compare-handle-line"></div>
<div class="compare-handle-grip">
<svg width="24" height="24"><path d="M8 5l-5 7 5 7M16 5l5 7-5 7" stroke="white" stroke-width="2" fill="none"/></svg>
</div>
</div>
</div>
```
```css
.compare-slider {
position: relative;
overflow: hidden;
cursor: col-resize;
border-radius: 12px;
}
.compare-before, .compare-after {
position: absolute;
inset: 0;
}
.compare-after { z-index: 1; }
.compare-before { z-index: 2; clip-path: inset(0 50% 0 0); }
.compare-before img, .compare-after img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.compare-label {
position: absolute;
top: 16px;
padding: 4px 12px;
background: rgba(0, 0, 0, 0.6);
color: white;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
}
.compare-before .compare-label { left: 16px; }
.compare-after .compare-label { right: 16px; }
.compare-handle {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
z-index: 3;
width: 4px;
transform: translateX(-50%);
}
.compare-handle-line {
width: 2px;
height: 100%;
background: white;
margin: 0 auto;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
}
.compare-handle-grip {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.compare-handle:focus-visible .compare-handle-grip {
outline: 3px solid #2563eb;
}
```
```javascript
class CompareSlider {
constructor(el) {
this.el = el;
this.before = el.querySelector('.compare-before');
this.handle = el.querySelector('.compare-handle');
this.position = 50;
// Pointer events
this.el.addEventListener('pointerdown', (e) => this.onStart(e));
// Keyboard
this.handle.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); this.setPosition(this.position - 2); }
if (e.key === 'ArrowRight') { e.preventDefault(); this.setPosition(this.position + 2); }
});
}
onStart(e) {
e.preventDefault();
this.el.setPointerCapture(e.pointerId);
this.onMove(e);
const onMove = (e) => this.onMove(e);
const onEnd = () => {
this.el.removeEventListener('pointermove', onMove);
this.el.removeEventListener('pointerup', onEnd);
};
this.el.addEventListener('pointermove', onMove);
this.el.addEventListener('pointerup', onEnd);
}
onMove(e) {
const rect = this.el.getBoundingClientRect();
const x = e.clientX - rect.left;
const pct = (x / rect.width) * 100;
this.setPosition(pct);
}
setPosition(pct) {
this.position = Math.max(0, Math.min(100, pct));
this.before.style.clipPath = `inset(0 ${100 - this.position}% 0 0)`;
this.handle.style.left = `${this.position}%`;
this.handle.setAttribute('aria-valuenow', Math.round(this.position));
}
}
document.querySelectorAll('.compare-slider').forEach(el => new CompareSlider(el));
```
---
## 12. Gallery Filter Animation
Animated filtering with layout transitions.
```css
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.filter-btn {
padding: 8px 16px;
border: 1px solid #d1d5db;
border-radius: 999px;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.filter-btn.active {
background: #111827;
color: white;
border-color: #111827;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.gallery-grid-item {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.gallery-grid-item.hidden {
opacity: 0;
transform: scale(0.9);
position: absolute;
pointer-events: none;
}
```
```javascript
class FilterGallery {
constructor(container) {
this.buttons = container.querySelectorAll('.filter-btn');
this.items = container.querySelectorAll('.gallery-grid-item');
this.buttons.forEach(btn => {
btn.addEventListener('click', () => this.filter(btn.dataset.category, btn));
});
}
filter(category, activeBtn) {
// Update buttons
this.buttons.forEach(b => b.classList.remove('active'));
activeBtn.classList.add('active');
// Filter items
this.items.forEach(item => {
const show = category === 'all' || item.dataset.category === category;
item.classList.toggle('hidden', !show);
});
}
}
```
---
## 13. Carousel Accessibility Checklist
| Requirement | Implementation |
|-------------|---------------|
| Container role | `role="region"` + `aria-roledescription="carousel"` + `aria-label` |
| Each slide | `role="group"` + `aria-roledescription="slide"` + `aria-label="Slide N of M"` |
| Hidden slides | `aria-hidden="true"` + `tabindex="-1"` on focusable children |
| Dots | `role="tablist"` wrapper, each dot `role="tab"` + `aria-selected` + `aria-label` |
| Arrows | `aria-label="Previous slide"` / `"Next slide"` + disabled state |
| Keyboard nav | Arrow keys, Enter/Space on controls, Tab through visible content |
| Autoplay | Pause button, pause on hover/focus, respect `prefers-reduced-motion` |
| Live region | `aria-live="polite"` to announce slide changes |
| Focus management | Never auto-advance away from focused content |
| Touch | Swipe support with scroll-snap, no swipe-trapping entire page |
references/media-formats-performance.md
# Media Formats and Performance
Choosing a format, and everything that keeps media from wrecking Core Web Vitals.
## Image Formats
### Format Comparison
| Format | Best For | Transparency | Animation | Compression | Browser Support |
|--------|----------|-------------|-----------|-------------|-----------------|
| AVIF | Photos, illustrations | Yes | Yes | Best (30-50% smaller than WebP) | Chrome, Firefox, Safari 16.4+ |
| WebP | Photos, illustrations | Yes | Yes | Great (25-35% smaller than JPEG) | All modern browsers |
| JPEG | Photos (legacy fallback) | No | No | Good | Universal |
| PNG | Screenshots, transparency needed | Yes | No (APNG exists) | Lossless, large files | Universal |
| SVG | Icons, logos, illustrations | Yes | Yes (SMIL/CSS) | Vector, tiny at any size | Universal |
| GIF | Simple animations (legacy) | 1-bit only | Yes | Poor, large files | Universal |
| JPEG XL | Future — photos, lossless | Yes | Yes | Best theoretical | Chrome (behind flag), Safari |
### Quality Settings
| Format | Quality Range | Recommended | Notes |
|--------|--------------|-------------|-------|
| AVIF | 1-100 | 50-65 | Lower numbers are fine, excellent at low quality |
| WebP | 1-100 | 75-85 | Good balance at 80 |
| JPEG | 1-100 | 75-85 | Below 70 shows artifacts on gradients |
| PNG | Lossless | N/A | Use pngquant for lossy compression (60-80%) |
### Format Selection Rule
```
If vector (icon/logo/illustration): SVG
If photo/complex image:
Serve AVIF (primary) → WebP (fallback) → JPEG (legacy)
If needs transparency + raster:
WebP or AVIF (prefer over PNG for smaller size)
If simple animation:
WebP animated or AVIF animated (avoid GIF)
If complex animation:
Use <video> with MP4/WebM instead
```
---
## Performance Optimization
### 15.1 Image CDN & Transformation
Services: Cloudinary, imgix, Vercel Image Optimization, Cloudflare Images.
On-the-fly capabilities:
- Resize: `?w=800&h=600`
- Format conversion: `?f=avif` (auto-negotiate with `f_auto`)
- Quality: `?q=80`
- Crop: `?fit=crop&gravity=face` (face detection)
- Blur: `?blur=500` (for LQIP)
- DPR: `?dpr=2`
### 15.2 Compression Guidelines
| Content Type | Target File Size | Strategy |
|-------------|-----------------|----------|
| Hero image (1920px) | 100-200KB | AVIF q50 or WebP q80 |
| Card thumbnail (400px) | 20-40KB | WebP q75 |
| Avatar (128px) | 5-15KB | WebP q80 |
| Product image (800px) | 40-80KB | WebP q85 (preserve detail) |
| Icon/logo | 1-5KB | SVG (vector) |
| Background video (720p) | 2-5MB | H.265, 30fps, low bitrate |
### 15.3 Core Web Vitals
**LCP (Largest Contentful Paint)**:
- Hero image is usually the LCP element.
- Preload LCP image: `<link rel="preload" as="image" href="hero.webp">`.
- Use `fetchpriority="high"` on the LCP `<img>`.
- Never lazy-load the LCP image.
- Serve from CDN with proper caching headers.
**CLS (Cumulative Layout Shift)**:
- Always specify `width` and `height` attributes on `<img>`.
- Use CSS `aspect-ratio` on image containers.
- Reserve space with placeholder (skeleton, blur-up, dominant color).
- Avoid inserting images above existing content dynamically.
```css
/* Prevent CLS with aspect-ratio container */
.image-container {
aspect-ratio: 16 / 9;
overflow: hidden;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
```
---
references/media-player-patterns.md
# Media Player Patterns — Video, Audio, Upload & Avatar Components
Production-ready specifications for video/audio player UIs, file upload patterns, and avatar component implementations.
---
## 1. Custom Video Player — Complete Spec
### Layout Structure
```
+----------------------------------------------------------------------+
| |
| VIDEO FRAME |
| |
| [ ▶ PLAY ] (centered overlay) |
| |
+----------------------------------------------------------------------+
| ▶ | ◄◄ | ►► | ===●============= | 2:34 / 5:12 | 🔊━━ | CC | ⚙ | ⛶ |
+----------------------------------------------------------------------+
```
### Controls Bar Specification
| Element | Width | Height | Position | Behavior |
|---------|-------|--------|----------|----------|
| Play/Pause button | 44px | 44px | Left | Toggle ▶/❚❚ icon |
| Skip back (10s) | 36px | 36px | After play | Jump -10s, icon: ↺ |
| Skip forward (10s) | 36px | 36px | After skip back | Jump +10s, icon: ↻ |
| Progress bar | Flex | 4px (8px hover) | Center, fills space | Seekable, shows buffer |
| Time display | Auto | 44px | After progress | "2:34 / 5:12" |
| Volume button | 36px | 36px | Right group | Click: toggle mute |
| Volume slider | 80px | 4px | After volume icon | Horizontal, 0-100% |
| Captions (CC) | 36px | 36px | Right group | Toggle subtitles |
| Settings (gear) | 36px | 36px | Right group | Speed, quality menu |
| Fullscreen | 36px | 36px | Far right | Toggle fullscreen |
### Progress Bar Detail
```css
.video-progress {
position: relative;
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
cursor: pointer;
transition: height 0.15s ease;
}
.video-progress:hover {
height: 8px;
}
/* Buffered range */
.video-progress-buffer {
position: absolute;
height: 100%;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
/* Played range */
.video-progress-played {
position: absolute;
height: 100%;
background: #ef4444; /* YouTube red or brand color */
border-radius: 2px;
}
/* Scrubber thumb */
.video-progress-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) scale(0);
width: 14px;
height: 14px;
background: #ef4444;
border-radius: 50%;
transition: transform 0.15s ease;
}
.video-progress:hover .video-progress-thumb {
transform: translate(-50%, -50%) scale(1);
}
/* Preview thumbnail on hover */
.video-progress-preview {
position: absolute;
bottom: 100%;
transform: translateX(-50%);
margin-bottom: 8px;
width: 160px;
aspect-ratio: 16/9;
background: #000;
border-radius: 4px;
overflow: hidden;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
}
.video-progress:hover .video-progress-preview {
opacity: 1;
}
```
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| Space / K | Play/Pause |
| J | Rewind 10 seconds |
| L | Forward 10 seconds |
| Left Arrow | Rewind 5 seconds |
| Right Arrow | Forward 5 seconds |
| Up Arrow | Volume up 5% |
| Down Arrow | Volume down 5% |
| M | Toggle mute |
| F | Toggle fullscreen |
| C | Toggle captions |
| < (Shift+,) | Decrease speed |
| > (Shift+.) | Increase speed |
| 0-9 | Jump to 0%-90% of video |
| Home | Jump to beginning |
| End | Jump to end |
| Escape | Exit fullscreen |
### Controls Auto-Hide
```javascript
class VideoControls {
constructor(player) {
this.player = player;
this.controls = player.querySelector('.video-controls');
this.hideTimeout = null;
this.isPlaying = false;
// Show controls on mouse movement
player.addEventListener('mousemove', () => this.showControls());
player.addEventListener('mouseleave', () => this.scheduleHide());
// Always show when paused
player.querySelector('video').addEventListener('pause', () => {
this.isPlaying = false;
this.showControls();
});
player.querySelector('video').addEventListener('play', () => {
this.isPlaying = true;
this.scheduleHide();
});
// Show on focus within controls
this.controls.addEventListener('focusin', () => this.showControls());
this.controls.addEventListener('focusout', () => {
if (this.isPlaying) this.scheduleHide();
});
}
showControls() {
clearTimeout(this.hideTimeout);
this.controls.classList.remove('hidden');
this.player.style.cursor = 'default';
if (this.isPlaying) this.scheduleHide();
}
scheduleHide() {
clearTimeout(this.hideTimeout);
this.hideTimeout = setTimeout(() => {
this.controls.classList.add('hidden');
this.player.style.cursor = 'none';
}, 3000);
}
}
```
```css
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px 16px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
display: flex;
align-items: center;
gap: 8px;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.video-controls.hidden {
opacity: 0;
visibility: hidden;
}
```
---
## 2. Picture-in-Picture (Scroll-Triggered)
```javascript
class ScrollPiP {
constructor(videoElement) {
this.video = videoElement;
this.pip = document.createElement('div');
this.pip.className = 'pip-container';
this.pip.hidden = true;
document.body.appendChild(this.pip);
this.setupObserver();
this.setupClose();
}
setupObserver() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
// Only PiP if video is playing
if (!this.video.paused) {
if (!entry.isIntersecting) {
this.enterPiP();
} else {
this.exitPiP();
}
}
});
},
{ threshold: 0.5 }
);
this.observer.observe(this.video);
}
enterPiP() {
// Move video to PiP container
this.pip.appendChild(this.video);
this.pip.hidden = false;
}
exitPiP() {
// Move video back to original container
this.originalParent.appendChild(this.video);
this.pip.hidden = true;
}
setupClose() {
const closeBtn = document.createElement('button');
closeBtn.className = 'pip-close';
closeBtn.setAttribute('aria-label', 'Close mini player');
closeBtn.innerHTML = '×';
closeBtn.addEventListener('click', () => {
this.video.pause();
this.exitPiP();
});
this.pip.appendChild(closeBtn);
}
}
```
```css
.pip-container {
position: fixed;
bottom: 24px;
right: 24px;
width: 360px;
aspect-ratio: 16/9;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
z-index: 9000;
transition: transform 0.3s ease;
}
.pip-container[hidden] { display: none; }
.pip-container video {
width: 100%;
height: 100%;
object-fit: cover;
}
.pip-close {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
/* Responsive: smaller on mobile */
@media (max-width: 640px) {
.pip-container {
width: 200px;
bottom: 16px;
right: 16px;
}
}
```
---
## 3. Audio Player — Full Implementation
### Waveform Player
```html
<div class="audio-player" role="region" aria-label="Audio player">
<button class="audio-play" aria-label="Play">
<svg class="icon-play" width="20" height="20"><polygon points="5,3 17,10 5,17" fill="currentColor"/></svg>
<svg class="icon-pause" width="20" height="20" hidden><rect x="4" y="3" width="4" height="14" fill="currentColor"/><rect x="12" y="3" width="4" height="14" fill="currentColor"/></svg>
</button>
<div class="audio-waveform" role="slider" aria-label="Audio position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
<canvas class="waveform-canvas" width="600" height="48"></canvas>
<div class="waveform-progress"></div>
</div>
<span class="audio-time" aria-live="off">
<span class="audio-current">0:00</span> / <span class="audio-duration">3:42</span>
</span>
<div class="audio-speed">
<button class="speed-btn" aria-label="Playback speed: 1x">1x</button>
</div>
</div>
```
```css
.audio-player {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f3f4f6;
border-radius: 12px;
max-width: 600px;
}
.audio-play {
flex: 0 0 40px;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: #111827;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.audio-play:hover { background: #374151; }
.audio-waveform {
flex: 1;
position: relative;
height: 48px;
cursor: pointer;
border-radius: 4px;
}
.waveform-canvas {
width: 100%;
height: 100%;
display: block;
}
.waveform-progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0%;
background: rgba(37, 99, 235, 0.2);
pointer-events: none;
border-radius: 4px;
}
.audio-time {
flex: 0 0 auto;
font-size: 13px;
color: #6b7280;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.speed-btn {
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
```
### Waveform Rendering
```javascript
function renderWaveform(canvas, peaks, playedColor = '#2563eb', unplayedColor = '#d1d5db') {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const width = canvas.offsetWidth * dpr;
const height = canvas.offsetHeight * dpr;
canvas.width = width;
canvas.height = height;
ctx.scale(dpr, dpr);
const displayWidth = canvas.offsetWidth;
const displayHeight = canvas.offsetHeight;
const barWidth = 3;
const barGap = 2;
const totalBarWidth = barWidth + barGap;
const barCount = Math.floor(displayWidth / totalBarWidth);
// Resample peaks to match bar count
const step = peaks.length / barCount;
ctx.clearRect(0, 0, displayWidth, displayHeight);
for (let i = 0; i < barCount; i++) {
const peakIndex = Math.floor(i * step);
const amplitude = peaks[peakIndex] || 0;
const barHeight = Math.max(2, amplitude * displayHeight * 0.8);
const x = i * totalBarWidth;
const y = (displayHeight - barHeight) / 2;
ctx.fillStyle = unplayedColor;
ctx.beginPath();
ctx.roundRect(x, y, barWidth, barHeight, 1);
ctx.fill();
}
}
// Redraw with progress overlay
function updateWaveformProgress(canvas, peaks, progress, playedColor, unplayedColor) {
renderWaveform(canvas, peaks, playedColor, unplayedColor);
const ctx = canvas.getContext('2d');
const displayWidth = canvas.offsetWidth;
const displayHeight = canvas.offsetHeight;
const barWidth = 3;
const barGap = 2;
const totalBarWidth = barWidth + barGap;
const barCount = Math.floor(displayWidth / totalBarWidth);
const step = peaks.length / barCount;
const playedBars = Math.floor(barCount * progress);
for (let i = 0; i < playedBars; i++) {
const peakIndex = Math.floor(i * step);
const amplitude = peaks[peakIndex] || 0;
const barHeight = Math.max(2, amplitude * displayHeight * 0.8);
const x = i * totalBarWidth;
const y = (displayHeight - barHeight) / 2;
ctx.fillStyle = playedColor;
ctx.beginPath();
ctx.roundRect(x, y, barWidth, barHeight, 1);
ctx.fill();
}
}
```
---
## 4. Podcast Player
```html
<div class="podcast-player" role="region" aria-label="Podcast player: Episode Title">
<!-- Album art -->
<div class="podcast-art">
<img src="episode-art.webp" alt="Episode artwork" width="300" height="300">
</div>
<!-- Info -->
<div class="podcast-info">
<h3 class="podcast-title">Episode 42: Design Systems at Scale</h3>
<p class="podcast-show">The Design Podcast</p>
</div>
<!-- Progress -->
<div class="podcast-progress">
<input type="range" min="0" max="100" value="35" class="podcast-seek"
aria-label="Seek position" aria-valuetext="12 minutes 34 seconds of 36 minutes">
<div class="podcast-times">
<span class="podcast-elapsed">12:34</span>
<span class="podcast-remaining">-23:26</span>
</div>
</div>
<!-- Controls -->
<div class="podcast-controls">
<button class="podcast-skip-back" aria-label="Skip back 15 seconds">
<svg><!-- -15 icon --></svg>
</button>
<button class="podcast-play" aria-label="Play">
<svg><!-- play/pause icon --></svg>
</button>
<button class="podcast-skip-forward" aria-label="Skip forward 30 seconds">
<svg><!-- +30 icon --></svg>
</button>
</div>
<!-- Secondary controls -->
<div class="podcast-secondary">
<button class="podcast-speed" aria-label="Playback speed: 1x">1x</button>
<button class="podcast-sleep" aria-label="Sleep timer">🌙</button>
<button class="podcast-chapters" aria-label="Chapters">📑</button>
<button class="podcast-transcript" aria-label="Show transcript">📝</button>
</div>
</div>
```
### Playback Speed Cycle
```javascript
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3];
class SpeedControl {
constructor(button, audioElement) {
this.btn = button;
this.audio = audioElement;
this.index = 2; // default: 1x
this.btn.addEventListener('click', () => this.cycle());
}
cycle() {
this.index = (this.index + 1) % speeds.length;
const speed = speeds[this.index];
this.audio.playbackRate = speed;
this.btn.textContent = speed === 1 ? '1x' : `${speed}x`;
this.btn.setAttribute('aria-label', `Playback speed: ${speed}x`);
}
}
```
---
## 5. Voice Message Player (Chat/Messaging)
```html
<div class="voice-message" role="region" aria-label="Voice message from Jane, 0:23">
<div class="voice-avatar">
<img src="jane-avatar.webp" alt="" width="36" height="36">
</div>
<button class="voice-play" aria-label="Play voice message">
<svg class="icon-play" width="16" height="16"><polygon points="4,2 14,8 4,14" fill="currentColor"/></svg>
</button>
<div class="voice-waveform" role="slider" aria-label="Voice message position"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
<canvas width="200" height="28"></canvas>
</div>
<span class="voice-duration">0:23</span>
<button class="voice-speed" aria-label="Speed: 1x">1x</button>
</div>
```
```css
.voice-message {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #e5efff;
border-radius: 18px;
max-width: 320px;
}
.voice-avatar {
flex: 0 0 36px;
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
}
.voice-avatar img { width: 100%; height: 100%; object-fit: cover; }
.voice-play {
flex: 0 0 28px;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: #2563eb;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.voice-waveform {
flex: 1;
height: 28px;
cursor: pointer;
}
.voice-duration {
font-size: 12px;
color: #6b7280;
font-variant-numeric: tabular-nums;
}
.voice-speed {
font-size: 11px;
padding: 2px 6px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: white;
cursor: pointer;
font-weight: 600;
}
```
---
## 6. Upload Patterns — Complete Implementation
### Drag-and-Drop Upload Zone
```html
<div class="upload-zone" role="button" tabindex="0" aria-label="Upload images, drag and drop or click to browse">
<input type="file" class="upload-input" accept="image/jpeg,image/png,image/webp,image/avif"
multiple hidden aria-hidden="true">
<div class="upload-idle">
<svg class="upload-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/>
</svg>
<p class="upload-text"><strong>Drag and drop</strong> images here</p>
<p class="upload-subtext">or click to browse</p>
<p class="upload-formats">JPG, PNG, WebP, AVIF up to 10 MB each</p>
</div>
<div class="upload-dragover" hidden>
<p>Drop files to upload</p>
</div>
</div>
<!-- Preview area -->
<div class="upload-previews" role="list" aria-label="Uploaded files"></div>
```
```css
.upload-zone {
border: 2px dashed #d1d5db;
border-radius: 16px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
position: relative;
}
.upload-zone:hover {
border-color: #9ca3af;
background: #f9fafb;
}
.upload-zone.dragover {
border-color: #2563eb;
background: #eff6ff;
border-style: solid;
}
.upload-zone:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.upload-icon {
color: #9ca3af;
margin-bottom: 12px;
}
.upload-text {
font-size: 16px;
color: #374151;
margin-bottom: 4px;
}
.upload-subtext {
font-size: 14px;
color: #6b7280;
}
.upload-formats {
font-size: 12px;
color: #9ca3af;
margin-top: 8px;
}
/* Preview grid */
.upload-previews {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
margin-top: 16px;
}
.upload-preview-item {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background: #f3f4f6;
}
.upload-preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-preview-item .remove-btn {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: white;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
/* Upload progress overlay */
.upload-preview-item .progress-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.upload-preview-item .progress-ring {
width: 40px;
height: 40px;
}
```
### JavaScript
```javascript
class FileUploader {
constructor(container, options = {}) {
this.zone = container.querySelector('.upload-zone');
this.input = container.querySelector('.upload-input');
this.previews = container.querySelector('.upload-previews');
this.maxSize = options.maxSize || 10 * 1024 * 1024; // 10MB
this.maxFiles = options.maxFiles || 20;
this.acceptedTypes = options.acceptedTypes || ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
this.files = [];
this.onUpload = options.onUpload || (() => {});
this.bindEvents();
}
bindEvents() {
// Click to open file picker
this.zone.addEventListener('click', () => this.input.click());
this.zone.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.input.click();
}
});
// File input change
this.input.addEventListener('change', (e) => this.handleFiles(e.target.files));
// Drag and drop
this.zone.addEventListener('dragenter', (e) => this.onDragEnter(e));
this.zone.addEventListener('dragover', (e) => this.onDragOver(e));
this.zone.addEventListener('dragleave', (e) => this.onDragLeave(e));
this.zone.addEventListener('drop', (e) => this.onDrop(e));
// Paste
document.addEventListener('paste', (e) => this.onPaste(e));
}
onDragEnter(e) {
e.preventDefault();
this.zone.classList.add('dragover');
}
onDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
onDragLeave(e) {
// Only remove if leaving the zone entirely
if (!this.zone.contains(e.relatedTarget)) {
this.zone.classList.remove('dragover');
}
}
onDrop(e) {
e.preventDefault();
this.zone.classList.remove('dragover');
this.handleFiles(e.dataTransfer.files);
}
onPaste(e) {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles = [];
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
this.handleFiles(imageFiles);
}
}
handleFiles(fileList) {
const files = Array.from(fileList);
files.forEach(file => {
// Validate type
if (!this.acceptedTypes.includes(file.type)) {
this.showError(file, `Unsupported format: ${file.type}`);
return;
}
// Validate size
if (file.size > this.maxSize) {
this.showError(file, `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB (max ${this.maxSize / 1024 / 1024}MB)`);
return;
}
// Validate count
if (this.files.length >= this.maxFiles) {
this.showError(file, `Maximum ${this.maxFiles} files`);
return;
}
this.files.push(file);
this.addPreview(file);
this.onUpload(file);
});
}
addPreview(file) {
const item = document.createElement('div');
item.className = 'upload-preview-item';
item.setAttribute('role', 'listitem');
const img = document.createElement('img');
img.alt = file.name;
const url = URL.createObjectURL(file);
img.src = url;
img.onload = () => URL.revokeObjectURL(url);
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-btn';
removeBtn.setAttribute('aria-label', `Remove ${file.name}`);
removeBtn.textContent = '\u00d7';
removeBtn.addEventListener('click', () => {
this.files = this.files.filter(f => f !== file);
item.remove();
});
item.appendChild(img);
item.appendChild(removeBtn);
this.previews.appendChild(item);
}
showError(file, message) {
console.error(`Upload error for ${file.name}: ${message}`);
// Show toast notification or inline error
}
}
// Initialize
new FileUploader(document.querySelector('.upload-container'), {
maxSize: 10 * 1024 * 1024,
maxFiles: 20,
onUpload: (file) => {
// Send to server via fetch/XMLHttpRequest with progress tracking
}
});
```
---
## 7. Upload Progress with XHR
```javascript
function uploadFile(file, url, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
onProgress(percent);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')));
xhr.open('POST', url);
xhr.send(formData);
});
}
// Usage
uploadFile(file, '/api/upload', (percent) => {
progressBar.style.width = `${percent}%`;
progressText.textContent = `${percent}%`;
});
```
---
## 8. Avatar Component — React
```jsx
import React from 'react';
const SIZES = {
xs: 24, sm: 32, md: 40, lg: 48, xl: 64, '2xl': 80, '3xl': 128,
};
const COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',
'#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F',
'#FF8A80', '#80CBC4', '#81D4FA', '#C5E1A5',
];
function getInitials(name) {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function getColor(identifier) {
let hash = 0;
for (let i = 0; i < identifier.length; i++) {
hash = identifier.charCodeAt(i) + ((hash << 5) - hash);
}
return COLORS[Math.abs(hash) % COLORS.length];
}
/**
* Avatar component with image, initials fallback, and status indicator.
*
* @param {Object} props
* @param {string} [props.src] - Image URL
* @param {string} [props.name] - User name (for initials fallback)
* @param {string} [props.userId] - Unique ID (for color generation)
* @param {'xs'|'sm'|'md'|'lg'|'xl'|'2xl'|'3xl'} [props.size='md']
* @param {'circle'|'rounded'} [props.shape='circle']
* @param {'online'|'away'|'busy'|'offline'} [props.status]
* @param {string} [props.alt]
*/
function Avatar({
src,
name,
userId,
size = 'md',
shape = 'circle',
status,
alt,
...rest
}) {
const px = SIZES[size] || SIZES.md;
const borderRadius = shape === 'circle' ? '50%' : '20%';
const [imgError, setImgError] = React.useState(false);
const statusColors = {
online: '#22C55E',
away: '#EAB308',
busy: '#EF4444',
offline: '#9CA3AF',
};
const statusSize = Math.max(8, Math.round(px * 0.28));
const fontSize = Math.round(px * 0.4);
const containerStyle = {
position: 'relative',
display: 'inline-flex',
width: px,
height: px,
flexShrink: 0,
};
const avatarStyle = {
width: px,
height: px,
borderRadius,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize,
fontWeight: 600,
color: 'white',
backgroundColor: src && !imgError ? '#e5e7eb' : getColor(userId || name || 'default'),
userSelect: 'none',
};
const statusStyle = {
position: 'absolute',
bottom: 0,
right: 0,
width: statusSize,
height: statusSize,
borderRadius: '50%',
backgroundColor: statusColors[status],
border: '2px solid white',
boxSizing: 'content-box',
};
return (
<div style={containerStyle} {...rest}>
<div style={avatarStyle} role="img" aria-label={alt || name || 'User avatar'}>
{src && !imgError ? (
<img
src={src}
alt=""
onError={() => setImgError(true)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : name ? (
getInitials(name)
) : (
<svg width={px * 0.5} height={px * 0.5} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z"/>
</svg>
)}
</div>
{status && <div style={statusStyle} aria-label={status} />}
</div>
);
}
/**
* Avatar group with overlap and +N overflow.
*
* @param {Object} props
* @param {Array} props.users - Array of { src, name, userId }
* @param {number} [props.max=4] - Max visible avatars
* @param {'xs'|'sm'|'md'|'lg'} [props.size='md']
*/
function AvatarGroup({ users, max = 4, size = 'md' }) {
const px = SIZES[size] || SIZES.md;
const overlap = Math.round(px * 0.25);
const visible = users.slice(0, max);
const overflow = users.length - max;
return (
<div
style={{ display: 'flex', flexDirection: 'row-reverse', alignItems: 'center' }}
role="group"
aria-label={`${users.length} members`}
>
{overflow > 0 && (
<div
style={{
width: px,
height: px,
borderRadius: '50%',
backgroundColor: '#e5e7eb',
border: '2px solid white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: Math.round(px * 0.32),
fontWeight: 600,
color: '#374151',
marginLeft: -overlap,
zIndex: 0,
}}
aria-label={`${overflow} more members`}
>
+{overflow}
</div>
)}
{visible.reverse().map((user, i) => (
<div
key={user.userId || i}
style={{ marginLeft: i === visible.length - 1 ? 0 : -overlap, zIndex: i + 1 }}
>
<Avatar
src={user.src}
name={user.name}
userId={user.userId}
size={size}
style={{ border: '2px solid white', boxSizing: 'content-box' }}
/>
</div>
))}
</div>
);
}
export { Avatar, AvatarGroup };
```
---
## 9. Avatar Component — SwiftUI
```swift
import SwiftUI
struct AvatarView: View {
let imageURL: URL?
let name: String
let size: CGFloat
var status: OnlineStatus?
var shape: AvatarShape = .circle
enum AvatarShape {
case circle, roundedSquare
}
enum OnlineStatus {
case online, away, busy, offline
var color: Color {
switch self {
case .online: return .green
case .away: return .yellow
case .busy: return .red
case .offline: return .gray
}
}
}
private var initials: String {
let parts = name.split(separator: " ")
if parts.count >= 2 {
return "\(parts.first!.prefix(1))\(parts.last!.prefix(1))".uppercased()
}
return String(name.prefix(1)).uppercased()
}
private var backgroundColor: Color {
let colors: [Color] = [.red, .blue, .green, .orange, .purple, .teal, .pink, .indigo]
let hash = name.unicodeScalars.reduce(0) { $0 + Int($1.value) }
return colors[abs(hash) % colors.count].opacity(0.7)
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if let url = imageURL {
AsyncImage(url: url) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
initialsView
}
} else {
initialsView
}
}
.frame(width: size, height: size)
.clipShape(avatarShape)
if let status = status {
Circle()
.fill(status.color)
.frame(width: size * 0.28, height: size * 0.28)
.overlay(
Circle().stroke(.white, lineWidth: 2)
)
.offset(x: 2, y: 2)
}
}
.accessibilityLabel(name)
}
private var initialsView: some View {
ZStack {
backgroundColor
Text(initials)
.font(.system(size: size * 0.38, weight: .semibold))
.foregroundStyle(.white)
}
}
@ViewBuilder
private var avatarShape: some Shape {
switch shape {
case .circle:
Circle()
case .roundedSquare:
RoundedRectangle(cornerRadius: size * 0.2, style: .continuous)
}
}
}
// Avatar Group
struct AvatarGroupView: View {
let users: [(name: String, imageURL: URL?)]
var maxVisible: Int = 4
var size: CGFloat = 40
var body: some View {
HStack(spacing: -(size * 0.25)) {
ForEach(Array(users.prefix(maxVisible).enumerated()), id: \.offset) { index, user in
AvatarView(imageURL: user.imageURL, name: user.name, size: size)
.overlay(Circle().stroke(.white, lineWidth: 2))
.zIndex(Double(maxVisible - index))
}
if users.count > maxVisible {
ZStack {
Circle().fill(.gray.opacity(0.2))
Text("+\(users.count - maxVisible)")
.font(.system(size: size * 0.3, weight: .semibold))
.foregroundStyle(.secondary)
}
.frame(width: size, height: size)
.overlay(Circle().stroke(.white, lineWidth: 2))
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(users.count) members")
}
}
```
---
## 10. Media Accessibility Checklist
| Media Type | Required | WCAG Level |
|-----------|----------|------------|
| All images | Alt text (or `alt=""` for decorative) | A (1.1.1) |
| Pre-recorded video | Captions | A (1.2.2) |
| Pre-recorded audio | Transcript | A (1.2.1) |
| Pre-recorded video | Audio description | AA (1.2.5) |
| Live video | Captions | AA (1.2.4) |
| Autoplay media | Pause/stop mechanism | A (1.4.2) |
| Animation | Respect `prefers-reduced-motion` | AAA (2.3.3) |
| Custom player | Keyboard-accessible controls | A (2.1.1) |
| Carousel | Pause, stop, hide mechanism | A (2.2.2) |
| Media controls | Minimum 44x44px touch target | AA (2.5.8) |
| Color-only info | Not conveyed by color alone | A (1.4.1) |
| Focus | Visible focus indicator on controls | AA (2.4.7) |
references/media-supplementary.md
# Supplementary Patterns
Entries that had no home in the other reference files when this skill was
converted to a router. Kept here rather than dropped.
### 2.13 Full-Bleed / Edge-to-Edge
Image spanning the full viewport width, breaking out of content container.
```css
.full-bleed {
width: 100vw;
margin-left: calc(50% - 50vw);
}
```
### 8.2 Shapes
- **Circle**: Default for user avatars. `border-radius: 50%`. Requires 1:1 container.
- **Rounded square**: Used by Slack, Discord. `border-radius: 20-25%`. Good for workspace/team avatars.
- **Squircle**: iOS-style superellipse. Use SVG clip-path for true squircle (CSS border-radius is not a real squircle).
- **Square**: Rare. Used for app icons or organization logos only.
### 8.5 Avatar Groups / Stacks
Multiple avatars overlapping horizontally.
- Overlap: 25-33% of avatar width (e.g., 32px avatars with -8px margin-left).
- Stack order: first avatar on top (highest z-index) or last on top (both conventions exist).
- Overflow indicator: "+5" circle at the end when more than shown limit.
- Max visible: 3-5 avatars before overflow.
- Ring: 2px white border on each avatar for visual separation.
```css
.avatar-group {
display: flex;
flex-direction: row-reverse; /* last item on top */
}
.avatar-group > * {
margin-left: -8px;
border: 2px solid white;
border-radius: 50%;
}
.avatar-group > *:last-child { margin-left: 0; }
```
---
## 9. Video Patterns
### 9.2 Click-to-Play
Video with visible thumbnail and play button overlay. User initiates playback.
- Large centered play button: 64-80px circle, semi-transparent background.
- Duration badge: bottom-right corner of thumbnail.
- On click: hide overlay, start video, show controls.
- Preferred for: content videos, tutorials, product demos.
### 12.2 Rotate
- Free rotation: drag to rotate with degree readout.
- Quick rotate: 90-degree CW/CCW buttons.
- Straighten slider: -45 to +45 degrees with grid overlay.
- Auto-straighten: detect horizon line.
### 12.3 Filters / Presets
- Thumbnail previews of each filter applied to current image.
- Horizontal scrollable strip of filter options.
- Filter intensity slider (0-100%) after selecting.
- Common filters: Original, B&W, Sepia, Vivid, Warm, Cool, Fade, Dramatic.
### 12.4 Adjustments
Individual parameter sliders:
| Adjustment | Range | Default |
|-----------|-------|---------|
| Brightness | -100 to +100 | 0 |
| Contrast | -100 to +100 | 0 |
| Saturation | -100 to +100 | 0 |
| Temperature | -100 (cool) to +100 (warm) | 0 |
| Highlights | -100 to +100 | 0 |
| Shadows | -100 to +100 | 0 |
| Sharpness | 0 to +100 | 0 |
| Vignette | 0 to +100 | 0 |
### 12.5 Zoom / Pan
- Pinch-to-zoom on touch devices.
- Scroll wheel zoom on desktop.
- Zoom slider or +/- buttons.
- Fit-to-screen / 100% / fill toggle.
- Pan by drag when zoomed in. Change cursor to grab/grabbing.
- Mini-map overview showing current viewport position on the full image.
---
## 13. Upload Patterns
### 16.4 Mask-Image
```css
/* Gradient fade */
.fade-bottom {
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
}
/* SVG shape mask */
.custom-shape {
mask-image: url('mask.svg');
mask-size: cover;
}
```
### 16.6 Mix-Blend-Mode
```css
/* Duotone effect */
.duotone {
position: relative;
background-color: #0066ff; /* brand color */
}
.duotone img {
mix-blend-mode: luminosity;
filter: grayscale(100%) contrast(1.2);
}
```
---
## 17. Dark Mode for Media
### 17.1 Image Brightness/Contrast Adjustments
Reduce eye strain by dimming images in dark mode.
```css
@media (prefers-color-scheme: dark) {
img:not([src$=".svg"]) {
filter: brightness(0.85) contrast(1.05);
}
/* Exception: avatars keep full brightness */
.avatar img { filter: none; }
}
```
references/responsive-image-recipes.md
# Responsive Image Recipes — srcset, picture, LQIP & Lazy Loading
Production-ready code patterns for responsive images, format fallbacks, lazy loading, and placeholder techniques.
---
## 1. Width-Based srcset (Most Common Pattern)
The browser selects the best image based on viewport width and device pixel ratio.
```html
<img
srcset="
photo-320.webp 320w,
photo-480.webp 480w,
photo-640.webp 640w,
photo-768.webp 768w,
photo-1024.webp 1024w,
photo-1200.webp 1200w,
photo-1920.webp 1920w"
sizes="
(max-width: 480px) 100vw,
(max-width: 768px) 100vw,
(max-width: 1024px) 50vw,
33.33vw"
src="photo-1024.webp"
alt="Descriptive alt text"
loading="lazy"
decoding="async"
width="1920"
height="1280"
>
```
### How `sizes` Works
The `sizes` attribute tells the browser how wide the image will display at each breakpoint, **before** downloading. The browser then picks the smallest `srcset` candidate that satisfies the display width at the device's pixel ratio.
| Viewport | `sizes` Value | Display Width | On 2x Device | Image Selected |
|----------|--------------|---------------|---------------|----------------|
| 375px | 100vw | 375px | 750px | photo-768.webp |
| 768px | 100vw | 768px | 1536px | photo-1920.webp |
| 1024px | 50vw | 512px | 1024px | photo-1024.webp |
| 1440px | 33.33vw | 480px | 960px | photo-1024.webp |
### Common `sizes` Recipes
```html
<!-- Full-width hero -->
sizes="100vw"
<!-- Full-width with max-width container (1200px) -->
sizes="(max-width: 1200px) 100vw, 1200px"
<!-- Two-column layout on desktop, full on mobile -->
sizes="(max-width: 768px) 100vw, 50vw"
<!-- Three-column grid with gap -->
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, calc(33.33vw - 32px)"
<!-- Sidebar layout (content takes 66%) -->
sizes="(max-width: 768px) 100vw, 66vw"
<!-- Fixed-width card image (always 300px display) -->
sizes="300px"
```
---
## 2. DPR-Based srcset (Fixed-Size Images)
For images that are always the same display size (logos, avatars, icons).
```html
<!-- Logo: always 200px wide -->
<img
srcset="logo.png 1x, logo@2x.png 2x, logo@3x.png 3x"
src="logo.png"
alt="Acme Corp"
width="200"
height="60"
>
<!-- Avatar: always 48px -->
<img
srcset="avatar-48.webp 1x, avatar-96.webp 2x, avatar-144.webp 3x"
src="avatar-48.webp"
alt="Jane Smith"
width="48"
height="48"
class="avatar"
>
```
---
## 3. Art Direction with `<picture>`
Serve different crops or compositions at different breakpoints.
### Hero Image — Different Crops
```html
<picture>
<!-- Mobile: tight vertical crop, subject centered -->
<source
media="(max-width: 639px)"
srcset="hero-mobile-640.avif 640w, hero-mobile-960.avif 960w"
sizes="100vw"
type="image/avif"
>
<source
media="(max-width: 639px)"
srcset="hero-mobile-640.webp 640w, hero-mobile-960.webp 960w"
sizes="100vw"
type="image/webp"
>
<!-- Tablet: medium crop -->
<source
media="(max-width: 1023px)"
srcset="hero-tablet-1024.avif 1024w, hero-tablet-1536.avif 1536w"
sizes="100vw"
type="image/avif"
>
<source
media="(max-width: 1023px)"
srcset="hero-tablet-1024.webp 1024w, hero-tablet-1536.webp 1536w"
sizes="100vw"
type="image/webp"
>
<!-- Desktop: full wide composition -->
<source
srcset="hero-desktop-1920.avif 1920w, hero-desktop-2560.avif 2560w"
sizes="100vw"
type="image/avif"
>
<source
srcset="hero-desktop-1920.webp 1920w, hero-desktop-2560.webp 2560w"
sizes="100vw"
type="image/webp"
>
<!-- Ultimate fallback: JPEG, no srcset -->
<img
src="hero-desktop-1920.jpg"
alt="Team collaborating in a bright modern office with floor-to-ceiling windows"
width="1920"
height="800"
fetchpriority="high"
>
</picture>
```
### Format Fallback Only (Same Crop)
```html
<picture>
<source srcset="photo.avif" type="image/avif">
<source srcset="photo.webp" type="image/webp">
<img src="photo.jpg" alt="Sunset over the ocean" width="800" height="533" loading="lazy">
</picture>
```
### Dark Mode Image Variant
```html
<picture>
<source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="logo-light.svg" media="(prefers-color-scheme: light)">
<img src="logo-light.svg" alt="Company logo" width="200" height="60">
</picture>
```
---
## 4. Native Lazy Loading
### Basic Usage
```html
<!-- Below the fold: lazy load -->
<img src="photo.webp" loading="lazy" width="800" height="600" alt="...">
<!-- Above the fold (LCP candidate): eager + high priority -->
<img src="hero.webp" loading="eager" fetchpriority="high" width="1920" height="800" alt="...">
<!-- Preload the LCP image in <head> -->
<link rel="preload" as="image" href="hero.webp" type="image/webp">
<!-- Preload with srcset -->
<link
rel="preload"
as="image"
href="hero-1920.webp"
imagesrcset="hero-640.webp 640w, hero-1024.webp 1024w, hero-1920.webp 1920w"
imagesizes="100vw"
>
```
### Rules for Lazy Loading
1. Never lazy-load the LCP image (typically the hero or first visible image).
2. Always pair `loading="lazy"` with explicit `width` and `height` to prevent CLS.
3. Add `decoding="async"` for non-critical images.
4. The browser's internal threshold varies (~1250-2500px below viewport in Chrome).
5. Use `fetchpriority="high"` on the single most important image, `fetchpriority="low"` on background/decorative images.
---
## 5. Intersection Observer Lazy Loading
For cases where native `loading="lazy"` is insufficient (e.g., background images, video, iframes).
```html
<img
class="lazy"
data-src="photo-full.webp"
data-srcset="photo-480.webp 480w, photo-768.webp 768w, photo-1200.webp 1200w"
data-sizes="(max-width: 768px) 100vw, 50vw"
src="placeholder-blur.webp"
alt="Mountain landscape"
width="1200"
height="800"
>
```
```javascript
// Lazy loading with Intersection Observer
function initLazyLoading() {
const images = document.querySelectorAll('img.lazy');
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// Swap data attributes to real attributes
if (img.dataset.srcset) img.srcset = img.dataset.srcset;
if (img.dataset.sizes) img.sizes = img.dataset.sizes;
img.src = img.dataset.src;
// Fade in when loaded
img.addEventListener('load', () => {
img.classList.add('loaded');
});
img.classList.remove('lazy');
observer.unobserve(img);
}
});
}, {
rootMargin: '200px 0px', // start loading 200px before entering viewport
threshold: 0.01
});
images.forEach(img => observer.observe(img));
} else {
// Fallback: load all images immediately
images.forEach(img => {
img.src = img.dataset.src;
if (img.dataset.srcset) img.srcset = img.dataset.srcset;
if (img.dataset.sizes) img.sizes = img.dataset.sizes;
});
}
}
document.addEventListener('DOMContentLoaded', initLazyLoading);
```
```css
img.lazy {
opacity: 0;
transition: opacity 0.4s ease;
}
img.lazy.loaded, img.loaded {
opacity: 1;
}
```
---
## 6. LQIP (Low Quality Image Placeholder) — Blur-Up
### HTML Structure
```html
<div class="image-wrapper" style="aspect-ratio: 3/2;">
<!-- Tiny placeholder (inline base64 or tiny URL) -->
<img
class="lqip"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..."
alt=""
aria-hidden="true"
>
<!-- Full resolution image -->
<img
class="full-image lazy"
data-src="photo-1200.webp"
data-srcset="photo-480.webp 480w, photo-768.webp 768w, photo-1200.webp 1200w"
data-sizes="(max-width: 768px) 100vw, 50vw"
alt="Golden hour landscape with rolling hills"
width="1200"
height="800"
>
</div>
```
### CSS
```css
.image-wrapper {
position: relative;
overflow: hidden;
background-color: #e5e7eb; /* fallback gray */
}
.lqip {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(20px);
transform: scale(1.1); /* prevent blur edges from showing */
transition: opacity 0.6s ease;
z-index: 1;
}
.full-image {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.6s ease;
z-index: 2;
}
.full-image.loaded {
opacity: 1;
}
.full-image.loaded + .lqip,
.full-image.loaded ~ .lqip {
/* When using adjacent sibling, reverse the DOM order */
}
/* Alternative: hide LQIP when full image loads */
.image-wrapper.revealed .lqip {
opacity: 0;
pointer-events: none;
}
```
### JavaScript
```javascript
function initBlurUp() {
const wrappers = document.querySelectorAll('.image-wrapper');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const wrapper = entry.target;
const fullImage = wrapper.querySelector('.full-image');
if (fullImage.dataset.srcset) fullImage.srcset = fullImage.dataset.srcset;
if (fullImage.dataset.sizes) fullImage.sizes = fullImage.dataset.sizes;
fullImage.addEventListener('load', () => {
fullImage.classList.add('loaded');
wrapper.classList.add('revealed');
});
fullImage.src = fullImage.dataset.src;
observer.unobserve(wrapper);
}
});
}, { rootMargin: '300px' });
wrappers.forEach(w => observer.observe(w));
}
initBlurUp();
```
### Generating LQIP Server-Side
```bash
# Using sharp (Node.js)
sharp input.jpg
.resize(32) # tiny width
.jpeg({ quality: 20 }) # heavy compression
.toBuffer() # get base64 for inline use
# Using ImageMagick
convert input.jpg -resize 32x -quality 20 lqip.jpg
# Using sqip (SVG-based LQIP — produces artistic SVG placeholder)
npx sqip -i input.jpg -o placeholder.svg -n 16
```
---
## 7. BlurHash Implementation
### Server: Generate BlurHash
```javascript
// Node.js with sharp + blurhash
import { encode } from 'blurhash';
import sharp from 'sharp';
async function generateBlurHash(imagePath) {
const { data, info } = await sharp(imagePath)
.raw()
.ensureAlpha()
.resize(32, 32, { fit: 'inside' })
.toBuffer({ resolveWithObject: true });
const hash = encode(
new Uint8ClampedArray(data),
info.width,
info.height,
4, // componentX (4 is good default)
3 // componentY
);
return hash; // e.g., "LKN]Rv%2Tw=w]~RBVZRi"
}
```
### Client: Decode and Display
```javascript
import { decode } from 'blurhash';
function blurHashToCanvas(hash, width = 32, height = 32) {
const pixels = decode(hash, width, height);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(width, height);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
return canvas;
}
// Usage
const canvas = blurHashToCanvas('LKN]Rv%2Tw=w]~RBVZRi', 32, 32);
canvas.style.width = '100%';
canvas.style.height = '100%';
document.querySelector('.placeholder').appendChild(canvas);
```
### React Component
```jsx
import { Blurhash } from 'react-blurhash';
function ImageWithBlurHash({ src, hash, alt, width, height }) {
const [loaded, setLoaded] = useState(false);
return (
<div style={{ position: 'relative', aspectRatio: `${width}/${height}` }}>
{!loaded && (
<Blurhash
hash={hash}
width="100%"
height="100%"
resolutionX={32}
resolutionY={32}
punch={1}
style={{ position: 'absolute', inset: 0 }}
/>
)}
<img
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => setLoaded(true)}
style={{
opacity: loaded ? 1 : 0,
transition: 'opacity 0.4s ease',
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</div>
);
}
```
---
## 8. ThumbHash Implementation
ThumbHash is a successor to BlurHash with better detail preservation and transparency support.
```javascript
// Server: generate ThumbHash
import { rgbaToThumbHash } from 'thumbhash';
import sharp from 'sharp';
async function generateThumbHash(imagePath) {
const { data, info } = await sharp(imagePath)
.resize(100, 100, { fit: 'inside' })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const hash = rgbaToThumbHash(info.width, info.height, data);
return Buffer.from(hash).toString('base64'); // ~28 bytes
}
// Client: decode ThumbHash to data URL
import { thumbHashToDataURL } from 'thumbhash';
function decodeThumbHash(base64Hash) {
const hash = Uint8Array.from(atob(base64Hash), c => c.charCodeAt(0));
return thumbHashToDataURL(hash); // returns data:image/png;base64,...
}
```
---
## 9. Dominant Color Placeholder
### Extract Dominant Color Server-Side
```javascript
// Node.js with sharp
import sharp from 'sharp';
async function getDominantColor(imagePath) {
const { dominant } = await sharp(imagePath).stats();
return `rgb(${dominant.r}, ${dominant.g}, ${dominant.b})`;
}
// Returns e.g., "rgb(58, 123, 213)"
```
### Usage in HTML
```html
<!-- Server renders the color inline -->
<div class="image-container" style="background-color: rgb(58, 123, 213); aspect-ratio: 16/9;">
<img
src="photo.webp"
alt="Blue sky over mountains"
loading="lazy"
width="1200"
height="675"
style="opacity: 0; transition: opacity 0.3s ease;"
onload="this.style.opacity=1"
>
</div>
```
---
## 10. Image CDN URL Patterns
### Cloudinary
```html
<!-- Auto format, auto quality, resize to 800px width -->
<img src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_800/sample.jpg" alt="...">
<!-- With srcset -->
<img
srcset="
https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_400/sample.jpg 400w,
https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_800/sample.jpg 800w,
https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_1200/sample.jpg 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_800/sample.jpg"
alt="..."
>
<!-- LQIP: tiny blurred version -->
<img src="https://res.cloudinary.com/demo/image/upload/f_auto,q_10,w_32,e_blur:1000/sample.jpg" alt="">
```
### imgix
```html
<img
srcset="
https://example.imgix.net/photo.jpg?auto=format&w=400 400w,
https://example.imgix.net/photo.jpg?auto=format&w=800 800w,
https://example.imgix.net/photo.jpg?auto=format&w=1200 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
src="https://example.imgix.net/photo.jpg?auto=format&w=800"
alt="..."
>
```
### Vercel/Next.js Image
```jsx
import Image from 'next/image';
<Image
src="/photo.jpg"
alt="Description"
width={1200}
height={800}
sizes="(max-width: 768px) 100vw, 50vw"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
priority={false} // true for LCP image
/>
```
---
## 11. CLS Prevention Checklist
1. Always set `width` and `height` attributes on `<img>` tags.
2. Use CSS `aspect-ratio` on containers wrapping images.
3. Use a placeholder strategy (dominant color, BlurHash, skeleton).
4. Never insert images dynamically above existing visible content.
5. For responsive images, `sizes` should accurately reflect displayed width.
6. For ads/embeds, reserve explicit space with min-height.
7. Font-display: swap does not cause image CLS but be aware of text-shift interaction.
```css
/* Universal CLS prevention for images */
img {
max-width: 100%;
height: auto;
}
/* Container-based approach */
.responsive-image-container {
position: relative;
overflow: hidden;
aspect-ratio: var(--img-ratio, 16/9);
}
.responsive-image-container img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
```
SKILL.md
---
name: image-media-patterns
description: "Image, video, and media UI patterns: hero and product imagery, galleries, carousels, video and audio players, avatars, thumbnails, aspect ratios, cropping, lazy loading, responsive images, and media accessibility. Use when placing media on a page or building a gallery or player."
---
# Image & Media Patterns — Complete Visual Media System
## Mental model
Media is where performance and craft collide. An unoptimised hero image costs
more than every font, script and stylesheet on the page combined, and a
correctly-sized one is invisible.
- **Reserve the space.** Width and height attributes or `aspect-ratio` on every
image. Layout shift is the single most common Core Web Vitals failure and the
easiest to prevent.
- **The hero is never lazy.** `loading="lazy"` on the largest contentful paint
element delays the thing the score is measuring. Lazy-load everything below
the fold and nothing above it.
- **Aspect ratio is a design decision.** 16:9 for video, 4:3 for editorial, 1:1
for avatars and grids, 3:2 for photography. Mixing ratios inside one grid
reads as an accident.
- **Autoplay needs muting, a pause control, and a reduced-motion escape.** All
three, not two.
- **Alt text describes function, not appearance.** A decorative image takes
`alt=""`; a linked image describes the destination.
## Constants
```
16:9 video, embeds 4:3 editorial, product photography
1:1 avatars, grid thumbnails 3:2 photography
21:9 cinematic banners golden 1.618:1 hero art
```
## Index
| Need | Reference |
|---|---|
| Masonry, justified, uniform, mosaic grid | `gallery-carousel-patterns.md` |
| Lightbox, filter animation, infinite scroll | `gallery-carousel-patterns.md` |
| Scroll-snap or autoplay carousel, filmstrip | `gallery-carousel-patterns.md` |
| Before/after comparison slider | `gallery-carousel-patterns.md` |
| Custom video player, picture-in-picture | `media-player-patterns.md` |
| Audio player, podcast player, voice message | `media-player-patterns.md` |
| Avatars, upload UI, upload progress | `media-player-patterns.md` |
| `srcset` by width or DPR, `<picture>` art direction | `responsive-image-recipes.md` |
| Native and IntersectionObserver lazy loading | `responsive-image-recipes.md` |
| LQIP, BlurHash, ThumbHash, dominant colour | `responsive-image-recipes.md` |
| Choosing AVIF vs WebP vs JPEG; Core Web Vitals | `media-formats-performance.md` |
## Reference architecture
| File | Covers | Lines |
|---|---|---|
| `references/media-player-patterns.md` | players, avatars, upload | 1283 |
| `references/gallery-carousel-patterns.md` | 13 gallery and carousel patterns | 1244 |
| `references/responsive-image-recipes.md` | srcset, lazy loading, placeholders | 655 |
| `references/media-formats-performance.md` | format choice, CWV | 97 |
## What every reference file contains
1. When the pattern applies, and the simpler thing to try first
2. Complete HTML/CSS/TSX with the loading strategy included
3. Accessibility: alt text rules, controls, keyboard, reduced motion
4. The performance cost and how to measure it
5. The mobile form of the pattern
## Routing
For **galleries and carousels** — masonry (both CSS-columns and grid), justified,
uniform and mosaic grids, lightboxes, scroll-snap and autoplay carousels,
filmstrips, infinite scroll and comparison sliders: read
`references/gallery-carousel-patterns.md`.
For **players** — a complete custom video player spec, scroll-triggered
picture-in-picture, full audio player, podcast player, and voice-message UI:
read `references/media-player-patterns.md`.
For **shipping images well** — width- and DPR-based `srcset`, art direction with
`<picture>`, native and IntersectionObserver lazy loading, LQIP, BlurHash,
ThumbHash and dominant-color placeholders: read
`references/responsive-image-recipes.md`.
For **odds and ends** — the patterns that had no home in the files above when this skill was converted to a router: read `references/media-supplementary.md`.
## Cross-References
- **layout-block-intelligence** — Hero sections, feature blocks containing images
- **component-patterns-code** — React/SwiftUI/CSS implementations of image components
- **accessibility-inclusive-design** — WCAG media requirements, screen reader patterns
- **performance-states-patterns** — Loading skeletons, error states, empty states
- **responsive-block-patterns** — Container queries, breakpoint transformations
- **animation-recipe-library** — Entrance animations, Ken Burns, parallax recipes
- **form-design-encyclopedia** — File upload form patterns
- **platform-visual-standards** — iOS/Android/web image handling conventions
- **design-systems-architecture** — Token-driven image component APIs