WordPress Responsive Images Srcset — Picture Component with WebP
A reusable PHP component that generates responsive <picture> elements with WebP + JPEG fallback, srcset with width descriptors, sizes attribute, and lazy loading — production-ready.
This WordPress responsive images srcset component — a reusable PHP component that generates responsive <picture> elements with WebP + JPEG fallback, srcset with width descriptors, sizes attribute, and lazy loading — one call replaces the entire markup.
Use this when your theme uses ACF Image fields, when you need WebP support with automatic fallback, or when you want to stop hand-writing <picture> tags with mismatched srcset attributes.
WordPress Responsive Images Srcset — The Code
This WordPress responsive images srcset component starts with the simplest working version, then adds WebP and lazy loading.
We’ll build this in 5 small steps. Each step is a working piece of code on its own. Start with vanilla HTML so you understand the structure — then wire it to WordPress, add srcset, add sizes, and wrap everything in a reusable function. Each step adds one new concept.
Step 1: The bare HTML structure. Before we touch PHP, you need to understand what we’re generating. This is a static <picture> tag — WebP source with a JPEG fallback. The browser picks the first format it understands. Every step from here builds on this skeleton.
<!-- WordPress responsive images srcset — snippet by Mosharaf Hossain -->
<picture>
<source srcset="hero-image.webp" type="image/webp" />
<img
src="hero-image.jpg"
alt="Hero banner"
width="1200"
height="675" />
</picture>
Step 2: Wire it to WordPress. Replace the hardcoded paths with a real WordPress attachment. wp_get_attachment_image_src() returns the URL, width, and height. The WebP URL comes from swapping the file extension — this assumes your build process or server generates WebP copies of every uploaded image.
// WordPress responsive images srcset
<?php
// ACF field type: Image — return format: Image ID.
$image_id = get_sub_field( 'section_image' ); // ACF field type: Image
if ( ! $image_id ) return;
// ACF field type: Image — returns [url, width, height, is_intermediate].
$fallback = wp_get_attachment_image_src( $image_id, 'full' );
$alt_text = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
// Swap the file extension to serve WebP — assumes WebP copies exist.
$webp_src = preg_replace( '/.(jpe?g|png)$/i', '.webp', $fallback[0] );
?>
<picture>
<source srcset="<?php echo esc_url( $webp_src ); ?>" type="image/webp" />
<img
src="<?php echo esc_url( $fallback[0] ); ?>"
alt="<?php echo esc_attr( $alt_text ); ?>"
width="<?php echo esc_attr( $fallback[1] ); ?>"
height="<?php echo esc_attr( $fallback[2] ); ?>" />
</picture>
Step 3: Add srcset with width descriptors. WordPress’s wp_get_attachment_image_srcset() builds a srcset string from every registered image size — “thumbnail, medium, large, full-width” etc. The browser then picks the best resolution for the user’s screen. Without this, mobile users download the full-size image.
// WordPress responsive images srcset
<?php
// ACF field type: Image — returns attachment ID.
$image_id = get_sub_field( 'section_image' ); // ACF field type: Image
if ( ! $image_id ) return;
// WordPress generates a full srcset from registered image sizes.
// Returns: "img-300x200.jpg 300w, img-600x400.jpg 600w, img-1200x800.jpg 1200w"
$srcset = wp_get_attachment_image_srcset( $image_id, 'large' );
// Build an identical srcset pointing at .webp versions.
$webp_srcset = preg_replace( '/.(jpe?g|png)(s+d+w)/i', '.webp$2', $srcset );
$webp_srcset = preg_replace( '/.(jpe?g|png)$/i', '.webp', $webp_srcset );
$fallback = wp_get_attachment_image_src( $image_id, 'large' );
$alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
?>
<picture>
<source
srcset="<?php echo esc_attr( $webp_srcset ); ?>"
type="image/webp" />
<img
src="<?php echo esc_url( $fallback[0] ); ?>"
srcset="<?php echo esc_attr( $srcset ); ?>"
alt="<?php echo esc_attr( $alt ); ?>"
width="<?php echo esc_attr( $fallback[1] ); ?>"
height="<?php echo esc_attr( $fallback[2] ); ?>" />
</picture>
Step 4: Add the sizes attribute. Without sizes, the browser assumes the image fills the entire viewport. It can’t reliably pick the smallest adequate source. The sizes string tells it “at 768px screens this image is 100vw wide, at desktop it’s 50vw.” Now the browser loads the right file 90+% of the time.
// WordPress responsive images srcset
<?php
// ACF field type: Image — returns attachment ID.
$image_id = get_sub_field( 'section_image' ); // ACF field type: Image
if ( ! $image_id ) return;
$srcset = wp_get_attachment_image_srcset( $image_id, 'large' );
$fallback = wp_get_attachment_image_src( $image_id, 'large' );
$alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
// Without a sizes attribute the browser assumes the image fills the
// entire viewport. Tell it how wide this image actually renders at
// each breakpoint so it fetches the right resolution.
$sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px';
// Build WebP srcset by replacing the extension in every URL.
$webp_srcset = preg_replace( '/.(jpe?g|png)(s+d+w)/i', '.webp$2', $srcset );
$webp_srcset = preg_replace( '/.(jpe?g|png)$/i', '.webp', $webp_srcset );
?>
<picture>
<source
srcset="<?php echo esc_attr( $webp_srcset ); ?>"
sizes="<?php echo esc_attr( $sizes ); ?>"
type="image/webp" />
<img
src="<?php echo esc_url( $fallback[0] ); ?>"
srcset="<?php echo esc_attr( $srcset ); ?>"
sizes="<?php echo esc_attr( $sizes ); ?>"
alt="<?php echo esc_attr( $alt ); ?>"
width="<?php echo esc_attr( $fallback[1] ); ?>"
height="<?php echo esc_attr( $fallback[2] ); ?>" />
</picture>
Step 5: The full production component. Wrap everything in a function with sensible defaults — loading="lazy", configurable sizes, overridable attributes. This is the version you ship. One call per image, zero hand-written markup.
// WordPress responsive images srcset
<?php
/**
* Generate a responsive <picture> element with WebP + JPEG fallback.
*
* Place this in inc/responsive-picture.php and require it from functions.php.
*
* @param int $image_id WordPress attachment ID.
* @param string $size Registered image size slug (default 'large').
* @param string $sizes The sizes attribute string.
* @param array $attrs Override: class, loading, fetchpriority, width, height.
* @return string The <picture> element, or empty string if no image.
*/
function theme_responsive_picture(
int $image_id,
string $size = 'large',
string $sizes = '(max-width: 768px) 100vw, 50vw',
array $attrs = []
): string {
$defaults = [
'class' => '',
'loading' => 'lazy',
'fetchpriority' => 'auto',
'width' => '',
'height' => '',
];
$attrs = wp_parse_args( $attrs, $defaults );
$img_src = wp_get_attachment_image_src( $image_id, $size );
if ( ! $img_src ) return '';
$srcset = wp_get_attachment_image_srcset( $image_id, $size );
$alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
$w = $attrs['width'] ?: $img_src[1];
$h = $attrs['height'] ?: $img_src[2];
// Build WebP sources by swapping file extensions.
$webp_srcset = preg_replace( '/.(jpe?g|png)(s+d+w)/i', '.webp$2', $srcset );
ob_start(); ?>
<picture>
<source
srcset="<?php echo esc_attr( $webp_srcset ); ?>"
sizes="<?php echo esc_attr( $sizes ); ?>"
type="image/webp" />
<img
src="<?php echo esc_url( $img_src[0] ); ?>"
srcset="<?php echo esc_attr( $srcset ); ?>"
sizes="<?php echo esc_attr( $sizes ); ?>"
alt="<?php echo esc_attr( $alt ); ?>"
width="<?php echo esc_attr( $w ); ?>"
height="<?php echo esc_attr( $h ); ?>"
class="<?php echo esc_attr( $attrs['class'] ); ?>"
loading="<?php echo esc_attr( $attrs['loading'] ); ?>"
fetchpriority="<?php echo esc_attr( $attrs['fetchpriority'] ); ?>"
/>
</picture>
<?php
return ob_get_clean();
}
// ── Usage ────────────────────────────────────────
// echo theme_responsive_picture( 42 );
// echo theme_responsive_picture( 42, 'medium_large',
// '(max-width: 640px) 100vw, 50vw',
// [ 'loading' => 'eager', 'class' => 'hero__image' ]
// );
Where to put the code
Save the Step 5 function in inc/responsive-picture.php and require it from functions.php:
require_once get_template_directory() . '/inc/responsive-picture.php';
Then call it wherever you render an image:
$image_id = get_sub_field( 'hero_image' ); // ACF field type: Imagenecho theme_responsive_picture( $image_id, 'full', '100vw', ['loading' => 'eager'] );
How WordPress responsive images srcset Works — Why Each Layer Exists
Here is how this WordPress responsive images srcset works step by step: Five layers, each solving a real production problem.
Five layers, each solving a real problem. Let’s walk through why each layer exists and what it fixes.
The <picture> element — WebP with JPEG fallback
The <picture> element with a WebP <source> and JPEG <img> fallback is the standard pattern for serving modern image formats. Safari added WebP support in 2020, and every major browser has followed. The type="image/webp" attribute ensures the browser only selects the WebP source if it actually supports the format — everyone else falls through to the JPEG. PNG is also supported as a fallback format in the extension swap regex.
Dynamic attachment data — wp_get_attachment_image_src()
wp_get_attachment_image_src( $id, $size ) returns an array: [url, width, height, is_intermediate]. The $size parameter selects a registered image size — "large", "medium", "full", or any custom size. The WebP URL is built by swapping the file extension with a regex — no extra database call, no extra filesystem access. This is the lightest possible approach.
srcset with width descriptors — resolution switching
wp_get_attachment_image_srcset() builds a comma-separated list of URLs with width descriptors that the browser parses. For a 1200×800 upload it returns something like: img-300x200.jpg 300w, img-768x512.jpg 768w, img-1200x800.jpg 1200w. The browser uses this list — combined with the sizes attribute — to pick the optimal resolution. Without srcset you are sending every user the same file.
Building the WebP srcset requires two regex passes: one for the width-descriptor pairs (300w suffixes) and one for the lone fallback URL. WordPress’s srcset function doesn’t know about WebP, so we mirror the structure ourselves.
The sizes attribute — browser image selection
The sizes attribute answers the question “how wide is this image on screen right now?” Without it, the browser defaults to 100vw — it loads the largest available source because it assumes the image fills the viewport. If your image is actually 400px wide in a sidebar, you are serving a 1200px image to every visitor. The sizes string describes your layout: (max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px means “full width on mobile, half width on tablet, 800px max on desktop.”
Production component — one call, zero markup
The production function wraps everything in one call with four parameters:
$image_id— the WordPress attachment ID. Works with ACF Image fields (return format: Image ID), post thumbnails viaget_post_thumbnail_id(), or any integer ID.$size— which registered image size to use as the fallbacksrc. Defaults to"large". The srcset includes ALL registered sizes regardless — this only controls the fallback.$sizes— the layout-aware sizes string. Defaults to a reasonable two-column pattern but you should customize this per template location.$attrs— override any attribute on the<img>tag: class, loading, fetchpriority, explicit width/height. Useswp_parse_args()so you only pass what you need to change.
Aspect ratio is preserved through explicit width and height attributes — combined with height: auto in your CSS, this prevents layout shift (CLS) as the image loads.
WordPress responsive images srcset — Setup & Integration
To integrate this WordPress responsive images srcset into your project: The component is a single PHP function. Here is how to wire it into your theme and where WebP images come from.
The component is a single function. Setting it up means placing it in the right file and understanding where WebP images come from.
Create the inc/ folder for utility functions
Create inc/ in your theme root if it doesn’t already exist. This folder holds utility functions — not templates, not blocks, not config. The naming convention is descriptive: responsive-picture.php tells you exactly what it does.
Save the function and require it
Copy the Step 5 function into inc/responsive-picture.php. In functions.php, add:
require_once get_template_directory() . '/inc/responsive-picture.php';
This loads the function on every page. Because the file only contains function declarations (no side effects), the cost is negligible — PHP’s opcode cache handles it.
Understand where WebP files come from
The WebP URL swap assumes WebP copies of your images exist. This can come from:
- A build step: Convert uploads to WebP with a tool like
cwebpduring deployment - A CDN: Services like BunnyCDN or Cloudflare Polish auto-convert and serve WebP when the browser supports it
- A WordPress plugin: WebP Express, Imagify, or ShortPixel generate WebP copies on upload and keep them in sync
The component itself doesn’t generate WebP — it assumes the files exist and builds URLs that point at them. Choose a generation strategy that fits your stack.
Replace existing <img> tags with the function call
In any template file where you render an image, replace your <img> tag with the function call:
<?phpn$id = get_sub_field( 'card_image' ); // ACF field type: Imagenecho theme_responsive_picture( $id, 'medium', '(max-width: 768px) 100vw, 400px' );n?>
For post thumbnails:
<?phpnecho theme_responsive_picture(n get_post_thumbnail_id(),n 'large',n '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px',n [ 'class' => 'hero__image', 'loading' => 'eager' ]n);n?>
Verify it works — check DevTools Network tab
Add the function call to one template and load the page. Right-click → Inspect → Network tab. You should see the WebP source loading in Chrome/Firefox (look for type: webp in the response headers). Safari and older browsers will load the JPEG fallback — both work.
If you see a broken image, check that WebP files actually exist at the swapped URLs. The regex replaces .jpg with .webp in the filename — if no WebP file exists at that path, the browser shows a broken image. Fix your WebP generation pipeline.
Making This WordPress responsive images srcset Your Own
Customise this WordPress responsive images srcset to match your specific needs: The component is ready. Here is how to tune it for hero images, AVIF support, CDN integration, and different layout contexts.
Customize sizes per layout context
The default $sizes parameter is a reasonable starting point but should be customized per template location. A hero image is different from a card thumbnail:
// Hero — always full widthnecho theme_responsive_picture( $hero_id, 'full', '100vw',n [ 'loading' => 'eager', 'fetchpriority' => 'high' ] );nn// Two-column grid cardnecho theme_responsive_picture( $card_id, 'medium',n '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 400px' );nn// Three-column grid cardnecho theme_responsive_picture( $card_id, 'medium',n '(max-width: 768px) 100vw, (max-width: 1200px) 33vw, 300px' );
Add AVIF support for even smaller files
To support AVIF as an additional format — smaller files than WebP with broader color support — add a second <source> before the WebP one. Edit the function:
// AVIF source — placed first so the browser tries it before WebP.n$avif_srcset = preg_replace( '/.(jpe?g|png)(s+d+w)/i', '.avif$2', $srcset );nnecho '<source srcset="' . esc_attr( $avif_srcset ) . '" sizes="' . esc_attr( $sizes ) . '" type="image/avif" />';
The browser reads <source> tags in order and picks the first format it supports. AVIF → WebP → JPEG is the ideal chain in 2026.
Prioritize the hero image with fetchpriority
For critical above-the-fold images, combine loading="eager" with fetchpriority="high" to tell the browser to prioritize this image over everything else on the page:
echo theme_responsive_picture( $hero_id, 'full', '100vw', [n 'loading' => 'eager',n 'fetchpriority' => 'high',n 'class' => 'hero__image',n] );
Use this on exactly one image per page — the main visual above the fold. Any more and you are competing with yourself for bandwidth.
Simplify for CDN-managed format negotiation
If your CDN handles format negotiation automatically (serving WebP/AVIF based on the Accept header), you can simplify the component by removing the <source> tags and keeping only the <img> with srcset. This reduces HTML payload by roughly 40%:
// CDN handles WebP/AVIF conversion — no <picture> needed.necho theme_responsive_picture_cdn( $id, 'large', '100vw' );n// Outputs: <img src="..." srcset="..." sizes="..." ... />
Keep the full <picture> component for self-hosted images or when your CDN can’t guarantee format support.
3 things that will silently break your images:
1. ACF Image field set to return “Image URL” instead of “Image ID.” The component expects an integer attachment ID. If your ACF field returns a URL string, wp_get_attachment_image_src() receives a string and returns false. The component returns an empty string. Fix: in ACF, set Return Format → Image ID.
2. WebP files don’t exist at the expected path. The regex simply swaps .jpg → .webp. If no conversion step is generating WebP files, every <source> tag points to a 404. The browser falls back to JPEG — it works, but you are not actually serving WebP. Verify with DevTools → Network → filter by webp.
3. Missing sizes on a component with srcset. Without sizes, the browser uses 100vw as the default. On a desktop monitor at 2560px wide, the browser loads the largest image in your srcset — often 2560px or 2048px — for a card that is actually 400px wide. Always set sizes.
Performance notes:
- Largest Contentful Paint (LCP): Use
fetchpriority="high"+loading="eager"on the hero image. This signals the browser to preload it before the parser discovers it. - Cumulative Layout Shift (CLS): The explicit
widthandheightattributes reserve space before the image loads. Combine withheight: autoin CSS to maintain aspect ratio. - Bytes saved: WebP averages 25–35% smaller than JPEG at equivalent quality. On a page with 10 images, that’s 1–2 MB saved per page view.
- srcset SSR:
wp_get_attachment_image_srcset()is server-side rendered — the browser receives HTML, not JavaScript. No hydration, no layout recalculation, no dependency on client-side JS for image selection. - Object cache:
wp_get_attachment_image_src()andwp_get_attachment_image_srcset()are not cached internally. If you are rendering the same image multiple times on one page (e.g., in a related-posts loop), wrap the component call in a static variable or usewp_cache_get()on the rendered output.

No comments yet. Be the first.