| name | optimizing-images |
| description | Optimizes images and generates responsive markup. Use when the user asks about image formats (WebP, AVIF), srcset, responsive images, Next.js Image, or reducing image file sizes. |
Image Optimization Assistant
When to use this skill
- User asks about image optimization
- User mentions WebP, AVIF, or modern formats
- User wants responsive images or srcset
- User asks about Next.js Image or picture element
- User wants to reduce page weight from images
Workflow
Instructions
Step 1: Audit Current Images
Find large images:
find public -type f \( -name "*.jpg" -o -name "*.png" -o -name "*.gif" \) -size +100k -exec ls -lh {} \;
Check image dimensions:
find public -type f \( -name "*.jpg" -o -name "*.png" \) -exec identify -format "%f: %wx%h (%b)\n" {} \;
Detect unoptimized in HTML:
grep -rn --include="*.tsx" --include="*.html" '<img' src/ | grep -v 'loading='
Step 2: Format Selection
| Format | Use Case | Browser Support |
|---|
| WebP | Photos, general use | 97%+ |
| AVIF | Best compression, photos | 92%+ |
| PNG | Transparency, icons | 100% |
| SVG | Icons, logos, illustrations | 100% |
| JPEG | Legacy fallback | 100% |
Compression comparison (typical):
| Original | WebP | AVIF |
|---|
| 500KB JPEG | ~300KB (-40%) | ~200KB (-60%) |
| 200KB PNG | ~80KB (-60%) | ~50KB (-75%) |
Step 3: Convert Images
Using Sharp (Node.js):
npm install sharp
import sharp from "sharp";
import { glob } from "glob";
import path from "path";
const QUALITY = { webp: 80, avif: 65 };
const SIZES = [640, 750, 828, 1080, 1200, 1920];
async function optimizeImage(inputPath: string): Promise<void> {
const dir = path.dirname(inputPath);
const name = path.basename(inputPath, path.extname(inputPath));
for (const width of SIZES) {
const image = sharp(inputPath).resize(width);
await image
.webp({ quality: QUALITY.webp })
.toFile(path.join(dir, `${name}-${width}.webp`));
await image
.avif({ quality: QUALITY.avif })
.toFile(path.join(dir, `${name}-${width}.avif`));
}
console.log(`Optimized: ${inputPath}`);
}
async function main() {
const images = await glob("public/images/**/*.{jpg,jpeg,png}");
await Promise.all(images.map(optimizeImage));
}
main();
Using CLI tools:
npx @aspect/image-optimize --format webp --quality 80 public/images/*.jpg
cwebp -q 80 input.jpg -o output.webp
npx avif --input public/images/*.jpg --quality 65
Step 4: Responsive Image Markup
HTML picture element:
<picture>
<source
type="image/avif"
srcset="
/images/hero-640.avif 640w,
/images/hero-1080.avif 1080w,
/images/hero-1920.avif 1920w
"
sizes="(max-width: 768px) 100vw, 50vw"
/>
<source
type="image/webp"
srcset="
/images/hero-640.webp 640w,
/images/hero-1080.webp 1080w,
/images/hero-1920.webp 1920w
"
sizes="(max-width: 768px) 100vw, 50vw"
/>
<img
src="/images/hero-1080.jpg"
alt="Hero image description"
width="1920"
height="1080"
loading="lazy"
decoding="async"
/>
</picture>
Sizes attribute guide:
| Layout | Sizes Value |
|---|
| Full width | 100vw |
| Half width on desktop | (min-width: 768px) 50vw, 100vw |
| Fixed width | 300px |
| Grid (3 columns) | (min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw |
Step 5: Framework Integration
Next.js Image:
import Image from 'next/image';
<Image
src="/images/hero.jpg"
alt="Hero description"
width={1920}
height={1080}
priority // For above-the-fold images
/>
<div className="relative h-64">
<Image
src="/images/hero.jpg"
alt="Hero description"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
import heroImage from '@/public/images/hero.jpg';
<Image
src={heroImage}
alt="Hero description"
placeholder="blur"
priority
/>
next.config.js for external images:
module.exports = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.example.com",
},
],
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};
Nuxt Image:
<template>
<NuxtImg
src="/images/hero.jpg"
alt="Hero description"
width="1920"
height="1080"
format="webp"
loading="lazy"
sizes="sm:100vw md:50vw lg:33vw"
/>
<!-- With picture for art direction -->
<NuxtPicture
src="/images/hero.jpg"
alt="Hero description"
sizes="sm:100vw md:50vw"
:imgAttrs="{ class: 'rounded-lg' }"
/>
</template>
export default defineNuxtConfig({
modules: ["@nuxt/image"],
image: {
format: ["avif", "webp"],
screens: {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
},
},
});
Step 6: Lazy Loading
Native lazy loading:
<img src="/image.jpg" alt="Description" loading="lazy" decoding="async" />
Intersection Observer (custom):
import { useEffect, useRef, useState } from "react";
export function useLazyImage(src: string) {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: "50px" },
);
if (imgRef.current) observer.observe(imgRef.current);
return () => observer.disconnect();
}, []);
return { imgRef, isInView, isLoaded, setIsLoaded };
}
Above-the-fold images (don't lazy load):
<Image src="/hero.jpg" alt="Hero" priority />
<img src="/hero.jpg" alt="Hero" fetchpriority="high" />
Step 7: Responsive Breakpoints
Calculate optimal breakpoints:
const VIEWPORT_WIDTHS = [320, 375, 414, 768, 1024, 1280, 1440, 1920];
const DEVICE_PIXEL_RATIOS = [1, 2, 3];
function calculateBreakpoints(
imageWidth: number,
containerRatio: number = 1,
): number[] {
const breakpoints = new Set<number>();
for (const viewport of VIEWPORT_WIDTHS) {
for (const dpr of DEVICE_PIXEL_RATIOS) {
const width = Math.round(viewport * containerRatio * dpr);
if (width <= imageWidth) {
breakpoints.add(width);
}
}
}
return Array.from(breakpoints).sort((a, b) => a - b);
}
console.log(calculateBreakpoints(1920, 0.5));
Step 8: Build Pipeline Integration
Vite plugin:
import { imagetools } from "vite-imagetools";
export default {
plugins: [
imagetools({
defaultDirectives: new URLSearchParams({
format: "webp;avif;jpg",
w: "640;1280;1920",
quality: "80",
}),
}),
],
};
import heroSrcset from "./hero.jpg?w=640;1280;1920&format=webp&as=srcset";
import heroAvif from "./hero.jpg?w=1280&format=avif";
GitHub Action for optimization:
name: Optimize Images
on:
pull_request:
paths:
- "public/images/**"
jobs:
optimize:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Optimize images
uses: calibreapp/image-actions@main
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
jpegQuality: "80"
webpQuality: "80"
Optimization Checklist
| Check | Target |
|---|
| Format | WebP/AVIF with JPEG fallback |
| Dimensions | No larger than 2x display size |
| File size | < 200KB for hero, < 100KB for cards |
| Lazy loading | All images below fold |
| Explicit dimensions | width/height on all images |
| Alt text | Descriptive on all images |
Validation
Before completing:
npx @unlighthouse/cli --site http://localhost:3000
npx lighthouse http://localhost:3000 --only-categories=performance
Error Handling
- Sharp installation fails: Install build tools; use prebuilt binaries.
- AVIF encoding slow: Use lower effort setting or limit to key images.
- CDN not serving modern formats: Check Accept header handling and cache config.
- Layout shift on load: Always include width/height or aspect-ratio.
Resources