Retina-ready graphics markup for websites
Screens with pixel density 2x and above have long become the standard — MacBook Pro, iPhone, flagship Android devices. A website marked up without retina in mind looks blurry on these devices: logos "swim," icons lose sharpness, product screenshots look low-quality. Retina-ready markup eliminates this problem systematically without sacrificing performance.
What makes graphics "retina-ready"
A regular raster PNG 100×100 px on a display with devicePixelRatio: 2 stretches to 200 physical pixels. The browser interpolates — hence the blurriness. There are several solutions, each applied in its own context:
| Method | When to apply |
|---|---|
srcset + sizes for <img> |
Content images |
CSS image-set() |
Background images |
| SVG | Icons, logos, illustrations |
<picture> + <source> |
When art direction is needed |
| CSS sprites 2x | Legacy projects with IE11 |
SVG as the first choice
For interface elements, SVG is the only correct approach. Vector graphics scale losslessly at any DPI. Icons via <use xlink:href> or componentization via React:
// Icon.tsx — system icon via SVG sprite
import { FC } from 'react';
interface IconProps {
name: string;
size?: number;
className?: string;
}
const Icon: FC<IconProps> = ({ name, size = 24, className }) => (
<svg width={size} height={size} className={className} aria-hidden="true">
<use href={`/sprites/icons.svg#${name}`} />
</svg>
);
srcset for raster images
When SVG is not available — photos, screenshots, complex illustrations — srcset is used:
<img
src="hero-800.jpg"
srcset="hero-800.jpg 1x, hero-1600.jpg 2x, hero-2400.jpg 3x"
width="800"
height="450"
alt="Main application screen"
loading="lazy"
decoding="async"
/>
For responsive cases with different frames:
<picture>
<source
media="(min-width: 1024px)"
srcset="hero-desktop-1x.webp 1x, hero-desktop-2x.webp 2x"
type="image/webp"
/>
<source
media="(min-width: 1024px)"
srcset="hero-desktop-1x.jpg 1x, hero-desktop-2x.jpg 2x"
/>
<source
srcset="hero-mobile-1x.webp 1x, hero-mobile-2x.webp 2x"
type="image/webp"
/>
<img
src="hero-mobile-1x.jpg"
srcset="hero-mobile-1x.jpg 1x, hero-mobile-2x.jpg 2x"
width="390"
height="260"
alt="Main screen"
/>
</picture>
Background images via CSS image-set
.hero-banner {
background-image: image-set(
url('/img/banner-1x.webp') 1x,
url('/img/banner-2x.webp') 2x
);
background-size: cover;
}
/* Fallback for older browsers */
@supports not (background-image: image-set(url('') 1x)) {
.hero-banner {
background-image: url('/img/banner-2x.webp');
}
}
Image preparation toolchain
Sharp for automatic size generation
In production pipeline, images are not prepared manually. The builder generates all variants from the source:
// scripts/optimize-images.mjs
import sharp from 'sharp';
import { glob } from 'glob';
import path from 'path';
const SOURCE_DIR = 'src/assets/images';
const OUTPUT_DIR = 'public/img';
const files = await glob(`${SOURCE_DIR}/**/*.{png,jpg}`);
for (const file of files) {
const name = path.basename(file, path.extname(file));
const outDir = path.dirname(file).replace(SOURCE_DIR, OUTPUT_DIR);
// 1x WebP
await sharp(file).resize({ width: 800 }).webp({ quality: 82 })
.toFile(`${outDir}/${name}-1x.webp`);
// 2x WebP
await sharp(file).resize({ width: 1600 }).webp({ quality: 78 })
.toFile(`${outDir}/${name}-2x.webp`);
// 1x JPEG fallback
await sharp(file).resize({ width: 800 }).jpeg({ quality: 85, mozjpeg: true })
.toFile(`${outDir}/${name}-1x.jpg`);
// 2x JPEG fallback
await sharp(file).resize({ width: 1600 }).jpeg({ quality: 80, mozjpeg: true })
.toFile(`${outDir}/${name}-2x.jpg`);
}
Vite plugin for automatic srcset
For Next.js, there is built-in optimization via next/image. For Vite projects — vite-imagetools:
// vite.config.ts
import { defineConfig } from 'vite';
import { imagetools } from 'vite-imagetools';
export default defineConfig({
plugins: [
imagetools({
defaultDirectives: new URLSearchParams({
format: 'webp;jpg',
quality: '82',
}),
}),
],
});
Usage in a component:
import heroSrcset from './hero.jpg?w=800;1600&format=webp&as=srcset';
<img srcSet={heroSrcset} src="/hero-fallback.jpg" alt="Hero" />
SVG sprite: building and optimization
SVGO removes unnecessary attributes and shortens paths. The sprite is built from separate files:
// scripts/build-sprite.mjs
import { optimize } from 'svgo';
import { glob } from 'glob';
import fs from 'fs/promises';
const icons = await glob('src/assets/icons/*.svg');
let symbols = '';
for (const file of icons) {
const id = path.basename(file, '.svg');
const raw = await fs.readFile(file, 'utf8');
const { data } = optimize(raw, {
plugins: ['preset-default', { name: 'removeViewBox', active: false }],
});
// Convert <svg> to <symbol>
const symbol = data
.replace(/<svg([^>]*)>/, `<symbol id="${id}"$1>`)
.replace('</svg>', '</symbol>');
symbols += symbol;
}
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols}</svg>`;
await fs.writeFile('public/sprites/icons.svg', sprite);
Testing on retina displays
Tools:
- Chrome DevTools → Sensors → Device pixel ratio: set to 2 or 3
- Firefox: via
about:config→layout.css.devPixelsPerPx - Real devices: iPhone 13+, MacBook Pro M1+, Samsung Galaxy S22+
Checklist before submission:
- All icons are SVG or have a 2x version
- Logo in SVG format
- Content images use
srcsetwith 2x - Background images use
image-set() - CSS does not contain
background-imagewith a single 1x file without fallback - Fonts are system or web fonts, not raster
Execution timeline
Basic retina adaptation of existing markup (replacing raster icons with SVG, adding srcset to images): 1–2 days. Full retina-ready markup of a new project from scratch is included in the overall markup timeline without significant increase in duration — provided that source files are provided in vector or 2x resolution.







