Free SKILL.md scraped from GitHub. Clone the repo or copy the file directly into your Claude Code skills directory.
npx versuz@latest install ultroncore-claude-skill-vault-skills-ui-ux-image-optimizationgit clone https://github.com/UltronCore/claude-skill-vault.gitcp claude-skill-vault/SKILL.MD ~/.claude/skills/ultroncore-claude-skill-vault-skills-ui-ux-image-optimization/SKILL.md---
name: image-optimization
description: >
Image optimization strategies: next/image, WebP/AVIF conversion, CDN serving, responsive images, and lazy loading. Triggers on: next/image, Image optimization, WebP, AVIF, blur placeholder, srcSet, image CDN, sharp, LCP image.
---
# Image Optimization
## When to Use
Use when LCP is slow, images are too large, or you're setting up a CDN pipeline. Critical for Core Web Vitals.
---
## Core Rules
- LCP image must have `priority` — never lazy-load the above-the-fold image
- Always set `sizes` to match your CSS layout — default `100vw` causes over-fetching
- Use `fill` + wrapper div for unknown dimensions (e.g. CMS images)
- WebP is the safe default; AVIF gives ~20% better compression but slower encoding
- Blur placeholder (`placeholder="blur"`) requires `blurDataURL` for external images
- Never use `<img>` for content images in Next.js — use `<Image />`
---
## next/image — Core Props
```tsx
import Image from 'next/image';
// Fixed size (known dimensions)
<Image
src="/hero.jpg"
alt="Hero banner showing product features" // meaningful alt text always
width={1200}
height={600}
priority // LCP image — preload, no lazy load
quality={85} // default 75; 80-85 is usually the sweet spot
/>
// Responsive (full width, proportional height)
<Image
src="/banner.jpg"
alt="Banner"
width={1200}
height={600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
// ^ Tells browser: mobile=full width, tablet=half, desktop=third
style={{ width: '100%', height: 'auto' }}
/>
// Fill container (parent must have position: relative and defined size)
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
<Image
src="/cover.jpg"
alt="Cover"
fill
sizes="(max-width: 768px) 100vw, 50vw"
style={{ objectFit: 'cover' }} // or 'contain'
/>
</div>
// Lazy load (default — no changes needed)
<Image
src="/below-fold.jpg"
alt="Below fold image"
width={800}
height={400}
// loading="lazy" is the default — omit priority for lazy
/>
// Blur placeholder
<Image
src="/photo.jpg"
alt="Photo"
width={800}
height={600}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
// blurDataURL for local images: auto-generated by Next.js at build time
// blurDataURL for external images: generate manually (see below)
/>
```
---
## sizes Attribute Guide
The `sizes` attribute tells the browser which rendered width to use when picking from `srcSet`. Wrong values = over-fetching.
```tsx
// Layout examples with correct sizes
// Full-width hero
sizes="100vw"
// Sidebar layout: 2/3 content, 1/3 sidebar
sizes="(max-width: 768px) 100vw, 66vw"
// 3-column grid
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
// Fixed max-width container (e.g. container at max-w-4xl = 896px)
sizes="(max-width: 896px) 100vw, 896px"
// Card in a 4-column grid with 16px gap
sizes="(max-width: 640px) calc(100vw - 32px), (max-width: 1024px) calc(50vw - 24px), calc(25vw - 28px)"
```
---
## External Image Domains
```javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
pathname: '/my-account/**',
},
{
protocol: 'https',
hostname: '**.supabase.co',
pathname: '/storage/v1/object/public/**',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
],
// Custom loader for image CDN (bypasses next/image optimization)
// loader: 'custom',
// loaderFile: './lib/image-loader.js',
},
};
```
---
## Blur Placeholder Generation
```typescript
// Generate blurDataURL for external images
// Install: npm install sharp
import sharp from 'sharp';
async function getBlurDataUrl(imageUrl: string): Promise<string> {
const response = await fetch(imageUrl);
const buffer = Buffer.from(await response.arrayBuffer());
const resized = await sharp(buffer)
.resize(10, 10, { fit: 'inside' }) // tiny 10x10 pixel image
.toBuffer();
const base64 = resized.toString('base64');
const mimeType = 'image/jpeg';
return `data:${mimeType};base64,${base64}`;
}
// For static data in getStaticProps / generateStaticParams
export async function generateStaticParams() {
const posts = await getPosts();
return Promise.all(
posts.map(async (post) => ({
...post,
blurDataURL: post.coverImage ? await getBlurDataUrl(post.coverImage) : undefined,
}))
);
}
```
---
## Image CDN Integration
### Cloudflare Images
```typescript
// lib/image-loader.ts (custom Next.js image loader)
export default function cloudflareLoader({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}) {
// Cloudflare Images URL format
const url = new URL(src, 'https://imagedelivery.net');
url.pathname = `/account-hash/${src.replace(/^\//, '')}/w=${width},q=${quality ?? 75}`;
return url.toString();
}
// Usage in component
<Image
src="image-id-from-cloudflare"
width={800}
height={600}
alt="..."
/>
```
### Imgix
```typescript
// lib/imgix-loader.ts
import ImgixClient from '@imgix/js-core';
const client = new ImgixClient({
domain: 'my-account.imgix.net',
secureURLToken: process.env.IMGIX_TOKEN,
});
export default function imgixLoader({ src, width, quality }: ImageLoaderProps) {
return client.buildURL(src, {
w: width,
q: quality ?? 75,
auto: 'compress,format', // auto WebP/AVIF
fit: 'max',
});
}
```
### Supabase Storage
```typescript
// Supabase Storage has built-in image transformation
import { supabase } from '@/lib/supabase';
function getOptimizedImageUrl(path: string, width: number, height: number) {
const { data } = supabase.storage
.from('images')
.getPublicUrl(path, {
transform: {
width,
height,
resize: 'cover',
format: 'webp',
quality: 80,
},
});
return data.publicUrl;
}
```
---
## WebP / AVIF Conversion with sharp CLI
```bash
# Install sharp CLI
npm install -g sharp-cli
# Convert single file
sharp -i input.jpg -o output.webp --webp-quality 80
sharp -i input.jpg -o output.avif --avif-quality 60
# Batch convert directory
for f in public/images/*.jpg; do
sharp -i "$f" -o "${f%.jpg}.webp" --webp-quality 80
done
# Resize + convert
sharp -i hero.jpg -o hero-800.webp --resize 800 --webp-quality 80
```
```typescript
// sharp in Node.js / Bun scripts
import sharp from 'sharp';
import { readdir } from 'fs/promises';
import path from 'path';
const INPUT_DIR = './public/images/raw';
const OUTPUT_DIR = './public/images/optimized';
const files = await readdir(INPUT_DIR);
await Promise.all(
files
.filter((f) => /\.(jpg|jpeg|png)$/i.test(f))
.map(async (file) => {
const name = path.parse(file).name;
const input = path.join(INPUT_DIR, file);
await sharp(input)
.resize({ width: 1200, withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(path.join(OUTPUT_DIR, `${name}.webp`));
await sharp(input)
.resize({ width: 1200, withoutEnlargement: true })
.avif({ quality: 60 })
.toFile(path.join(OUTPUT_DIR, `${name}.avif`));
console.log(`Processed: ${file}`);
})
);
```
---
## Lazy Loading — Native vs Intersection Observer
```tsx
// Next.js Image — lazy by default (no code needed)
<Image src="/..." alt="..." width={800} height={600} />
// loading="lazy" is implicit when priority is not set
// Native HTML img lazy loading (non-Next.js contexts)
<img src="/image.jpg" loading="lazy" decoding="async" alt="..." />
// Intersection Observer (for custom lazy loading or background images)
import { useRef, useEffect, useState } from 'react';
function useLazyLoad() {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect(); // load once, then stop observing
}
},
{ rootMargin: '200px' } // start loading 200px before visible
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return { ref, isVisible };
}
// Usage
function LazySection() {
const { ref, isVisible } = useLazyLoad();
return (
<div ref={ref}>
{isVisible && <HeavyComponent />}
</div>
);
}
```
---
## LCP Optimization Checklist
```tsx
// ✓ 1. Mark LCP image with priority
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />
// ✓ 2. Correct sizes for your layout
<Image ... sizes="(max-width: 768px) 100vw, 50vw" />
// ✓ 3. Serve from CDN (not same-origin)
// Use Cloudflare, Vercel Edge Network, or Imgix
// ✓ 4. Preload in HTML <head> (for images not using next/image)
// Next.js does this automatically when priority is set
// ✓ 5. Use modern format
// next/image serves WebP/AVIF automatically
// For <img>: use <picture> with source fallbacks
// ✓ 6. Don't CLS — always set width/height or aspect-ratio
<Image width={1200} height={600} ... /> // explicit
<div style={{ aspectRatio: '16/9' }}> ... </div> // CSS aspect-ratio
```
---
## Quick Reference
| Task | Solution |
|---|---|
| LCP image | `priority` prop |
| Responsive image | `sizes` + `style={{ width: '100%', height: 'auto' }}` |
| Fill container | `fill` + parent `position: relative` |
| External domain | `remotePatterns` in next.config.js |
| Blur placeholder | `placeholder="blur"` + `blurDataURL` |
| WebP conversion | `sharp` package or CLI |
| Image CDN | Custom loader in next.config.js |
| Lazy load | Default behavior (no `priority`) |
| Intersection Observer | Custom hook for non-image lazy loading |
| Supabase images | Storage transform API (width/height/format) |