diff --git a/docs/content/1.get-started/2.configuration.md b/docs/content/1.get-started/2.configuration.md
index 340167bd2..358c80510 100644
--- a/docs/content/1.get-started/2.configuration.md
+++ b/docs/content/1.get-started/2.configuration.md
@@ -55,6 +55,34 @@ export default defineNuxtConfig({
})
```
+## `responsiveBreakpoints`
+
+Default: `'max-width'`
+
+Controls whether responsive image media queries use `max-width` or `min-width` breakpoints.
+
+For a component with `sizes="sm:100vw md:50vw lg:400px"`:
+
+- with `max-width` (default), the generated attribute for `sizes` looks like:
+ ```html
+ sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 400px"
+ ```
+
+- with `min-width`, the order is reversed and the smallest breakpoint becomes the fallback:
+ ```html
+ sizes="(min-width: 1024px) 400px, (min-width: 768px) 50vw, 100vw"
+ ```
+
+You can also override this option at the component level by using the [`responsive-breakpoints` prop](/usage/nuxt-img#responsive-breakpoints).
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+ image: {
+ responsiveBreakpoints: 'min-width'
+ }
+})
+```
+
## `screens`
List of predefined screen sizes.
diff --git a/docs/content/2.usage/1.nuxt-img.md b/docs/content/2.usage/1.nuxt-img.md
index a252e11fb..d0a73738a 100644
--- a/docs/content/2.usage/1.nuxt-img.md
+++ b/docs/content/2.usage/1.nuxt-img.md
@@ -132,6 +132,26 @@ By default Nuxt generates *responsive-first* sizing.
/>
```
+### `responsive-breakpoints`
+
+Controls whether responsive image media queries use `max-width` (desktop-first) or `min-width` (mobile-first) breakpoints. Overrides the global [`responsiveBreakpoints`](/get-started/configuration#responsivebreakpoints) option.
+
+**Example:**
+
+```vue
+
+```
+
+This generates the following `sizes` attribute:
+
+```html
+sizes="(min-width: 1024px) 400px, (min-width: 768px) 50vw, 100vw"
+```
+
### `densities`
The `densities` prop serves high-resolution images for Retina/HiDPI screens.
diff --git a/src/module.ts b/src/module.ts
index 96ece6f38..4ca7cf631 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -16,6 +16,7 @@ export interface ModuleOptions extends ImageProviders {
domains: string[]
alias: Record
screens: CreateImageOptions['screens']
+ responsiveBreakpoints: CreateImageOptions['responsiveBreakpoints']
providers: { [name: string]: InputProvider | any }
densities: number[]
format: CreateImageOptions['format']
@@ -45,6 +46,7 @@ export default defineNuxtModule({
providers: {},
alias: {},
densities: [1, 2],
+ responsiveBreakpoints: 'max-width',
}),
meta: {
name: '@nuxt/image',
@@ -113,6 +115,7 @@ export default defineNuxtModule({
'densities',
'format',
'quality',
+ 'responsiveBreakpoints',
])
const providers = await resolveProviders(nuxt, options)
@@ -235,16 +238,19 @@ function pick, K extends keyof O>(obj: O, keys: K[]):
}
function generateImageOptions(providers: ImageModuleProvider[], imageOptions: Omit): string {
+ const { responsiveBreakpoints, ...serializableOptions } = imageOptions
return `
${providers.map(p => `import ${p.importName} from '${p.runtime}'`).join('\n')}
export const imageOptions = {
- ...${JSON.stringify(imageOptions, null, 2)},
+ ...${JSON.stringify(serializableOptions, null, 2)},
/** @type {${JSON.stringify(imageOptions.provider)}} */
provider: ${JSON.stringify(imageOptions.provider)},
providers: {
${providers.map(p => ` ['${p.name}']: { setup: ${p.importName}, defaults: ${JSON.stringify(p.runtimeOptions)} }`).join(',\n')}
- }
+ },
+ /** @type {import('@nuxt/image').ResponsiveBreakpoints} */
+ responsiveBreakpoints: ${JSON.stringify(responsiveBreakpoints)},
}
`
}
diff --git a/src/runtime/components/NuxtImg.vue b/src/runtime/components/NuxtImg.vue
index 53ecad0eb..5f6a96f91 100644
--- a/src/runtime/components/NuxtImg.vue
+++ b/src/runtime/components/NuxtImg.vue
@@ -51,6 +51,7 @@ const sizes = computed(() => $img.getSizes(props.src!, {
...providerOptions.value,
sizes: props.sizes,
densities: props.densities,
+ responsiveBreakpoints: props.responsiveBreakpoints,
modifiers: imageModifiers.value,
}))
diff --git a/src/runtime/components/NuxtPicture.vue b/src/runtime/components/NuxtPicture.vue
index 04653486b..fc0a18a89 100644
--- a/src/runtime/components/NuxtPicture.vue
+++ b/src/runtime/components/NuxtPicture.vue
@@ -113,6 +113,7 @@ const sources = computed(() => {
...providerOptions.value,
sizes: props.sizes || $img.options.screens,
densities: props.densities,
+ responsiveBreakpoints: props.responsiveBreakpoints,
modifiers: { ...imageModifiers.value, format },
})
diff --git a/src/runtime/image.ts b/src/runtime/image.ts
index 4bd0ba6db..cd5f50b4f 100644
--- a/src/runtime/image.ts
+++ b/src/runtime/image.ts
@@ -3,7 +3,7 @@ import { hasProtocol, parseURL, joinURL, withLeadingSlash } from 'ufo'
import { imageMeta } from './utils/meta'
import { checkDensities, parseDensities, parseSize, parseSizes } from './utils'
import { prerenderStaticImages } from './utils/prerender'
-import type { ImageOptions, ImageSizesOptions, CreateImageOptions, ResolvedImage, ImageCTX, $Img, ImageSizes, ImageSizesVariant, ConfiguredImageProviders } from '@nuxt/image'
+import type { ImageOptions, ImageSizesOptions, CreateImageOptions, ResolvedImage, ImageCTX, $Img, ImageSizes, ImageSizesVariant, ConfiguredImageProviders, ResponsiveBreakpoints } from '@nuxt/image'
export function createImage(globalOptions: CreateImageOptions) {
const ctx: ImageCTX = {
@@ -141,6 +141,8 @@ function getSizes(ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageS
const sizeVariants = []
const srcsetVariants = []
+ const responsiveBreakpoints = merged.responsiveBreakpoints || ctx.options.responsiveBreakpoints
+
if (Object.keys(sizes).length >= 1) {
// 'sizes path'
for (const key in sizes) {
@@ -153,7 +155,7 @@ function getSizes(ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageS
sizeVariants.push({
size: variant.size,
screenMaxWidth: variant.screenMaxWidth,
- media: `(max-width: ${variant.screenMaxWidth}px)`,
+ media: `(${responsiveBreakpoints}: ${variant.screenMaxWidth}px)`,
})
// add srcset variants for all densities (for current 'size' processed)
@@ -165,7 +167,7 @@ function getSizes(ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageS
}
}
- finaliseSizeVariants(sizeVariants)
+ finaliseSizeVariants(sizeVariants, responsiveBreakpoints)
}
else {
// 'densities path'
@@ -242,7 +244,7 @@ function getVariantSrc(ctx: ImageCTX, input: string, opts: ImageSizesOptions, va
opts)
}
-function finaliseSizeVariants(sizeVariants: any[]) {
+function finaliseSizeVariants(sizeVariants: any[], responsiveBreakpoints: ResponsiveBreakpoints) {
sizeVariants.sort((v1, v2) => v1.screenMaxWidth - v2.screenMaxWidth)
// de-duplicate size variants (by key `media`)
@@ -255,8 +257,19 @@ function finaliseSizeVariants(sizeVariants: any[]) {
previousMedia = sizeVariant.media
}
- for (let i = 0; i < sizeVariants.length; i++) {
- sizeVariants[i].media = sizeVariants[i + 1]?.media || ''
+ if (responsiveBreakpoints === 'min-width') {
+ // Reverse to descending order (largest breakpoint first)
+ sizeVariants.reverse()
+ // Last variant (smallest screen) becomes the fallback (no media query)
+ if (sizeVariants.length > 0) {
+ sizeVariants[sizeVariants.length - 1].media = ''
+ }
+ }
+ else {
+ // max-width: shift media values so each variant uses the next breakpoint's media
+ for (let i = 0; i < sizeVariants.length; i++) {
+ sizeVariants[i].media = sizeVariants[i + 1]?.media || ''
+ }
}
}
diff --git a/src/runtime/utils/props.ts b/src/runtime/utils/props.ts
index dee657590..708bf05a0 100644
--- a/src/runtime/utils/props.ts
+++ b/src/runtime/utils/props.ts
@@ -1,6 +1,6 @@
import { computed } from 'vue'
-import type { ConfiguredImageProviders, ImageModifiers } from '@nuxt/image'
+import type { ConfiguredImageProviders, ImageModifiers, ResponsiveBreakpoints } from '@nuxt/image'
import { parseSize } from '.'
import { useImage } from '#imports'
@@ -22,6 +22,7 @@ export interface BaseImageProps
sizes?: string | Record
densities?: string
+ responsiveBreakpoints?: ResponsiveBreakpoints
preload?: boolean | { fetchPriority: 'auto' | 'high' | 'low' }
//
attributes
diff --git a/src/types/image.ts b/src/types/image.ts
index ae0f06ffc..9ec50561f 100644
--- a/src/types/image.ts
+++ b/src/types/image.ts
@@ -19,10 +19,13 @@ export interface ResolvedImageModifiers extends ImageModifiers {
type DefaultProvider = ProviderDefaults extends Record<'provider', unknown> ? ProviderDefaults['provider'] : never
+export type ResponsiveBreakpoints = 'max-width' | 'min-width'
+
export interface ImageOptions {
provider?: Provider
preset?: string
densities?: string
+ responsiveBreakpoints?: ResponsiveBreakpoints
modifiers?: Partial>
& ('modifiers' extends keyof ConfiguredImageProviders[Provider] ? ConfiguredImageProviders[Provider]['modifiers'] : Record)
sizes?: string | Record
@@ -57,6 +60,7 @@ export interface CreateImageOptions {
presets: { [name: string]: ImageOptions }
provider: (string & {}) | keyof ImageProviders
screens: Record
+ responsiveBreakpoints: ResponsiveBreakpoints
alias: Record
domains: string[]
densities: number[]
diff --git a/test/nuxt/image.test.ts b/test/nuxt/image.test.ts
index 862fd95e2..d27de27fd 100644
--- a/test/nuxt/image.test.ts
+++ b/test/nuxt/image.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, it, expect, vi } from 'vitest'
+import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import type { ComponentMountingOptions, VueWrapper } from '@vue/test-utils'
import { imageOptions } from '#build/image-options.mjs'
@@ -178,6 +178,88 @@ describe('Renders simple image', () => {
})
})
+describe('Renders image with min-width responsive breakpoints', () => {
+ it('uses min-width media queries via prop', () => {
+ const img = mountImage({
+ width: 200,
+ height: 200,
+ sizes: '200,500:500,900:900',
+ responsiveBreakpoints: 'min-width',
+ src: '/image.png',
+ })
+ const sizes = img.find('img').element.getAttribute('sizes')
+ expect(sizes).toMatchInlineSnapshot('"(min-width: 900px) 900px, (min-width: 500px) 500px, 200px"')
+ })
+
+ it('uses min-width with named breakpoints', () => {
+ const img = mountImage({
+ src: '/image.png',
+ width: 1000,
+ height: 2000,
+ sizes: 'sm:100vw md:300px lg:350px',
+ responsiveBreakpoints: 'min-width',
+ })
+ const sizes = img.find('img').element.getAttribute('sizes')
+ expect(sizes).toMatchInlineSnapshot('"(min-width: 1024px) 350px, (min-width: 768px) 300px, 100vw"')
+ })
+
+ it('with single sizes entry and min-width', () => {
+ const img = mountImage({
+ src: '/image.png',
+ width: 300,
+ height: 400,
+ sizes: 'sm:150',
+ responsiveBreakpoints: 'min-width',
+ })
+ const sizes = img.find('img').element.getAttribute('sizes')
+ expect(sizes).toMatchInlineSnapshot('"150px"')
+ })
+})
+
+describe('Module-level responsiveBreakpoints config', () => {
+ const nuxtApp = useNuxtApp()
+ const src = '/image.png'
+
+ beforeEach(() => {
+ delete nuxtApp._img
+ delete nuxtApp.$img
+ })
+
+ afterEach(() => {
+ delete nuxtApp._img
+ delete nuxtApp.$img
+ })
+
+ it('module config responsiveBreakpoints=min-width applies globally', () => {
+ setImageContext({ responsiveBreakpoints: 'min-width' })
+ const img = mount(NuxtImg, {
+ propsData: {
+ src,
+ width: 200,
+ height: 200,
+ sizes: '200,500:500,900:900',
+ },
+ })
+ const sizes = img.find('img').element.getAttribute('sizes')
+ expect(sizes).toMatchInlineSnapshot('"(min-width: 900px) 900px, (min-width: 500px) 500px, 200px"')
+ })
+
+ it('component prop overrides module config', () => {
+ setImageContext({ responsiveBreakpoints: 'min-width' })
+ const img = mount(NuxtImg, {
+ propsData: {
+ src,
+ width: 200,
+ height: 200,
+ sizes: '200,500:500,900:900',
+ responsiveBreakpoints: 'max-width',
+ },
+ })
+ const sizes = img.find('img').element.getAttribute('sizes')
+ expect(sizes).toMatchInlineSnapshot('"(max-width: 500px) 200px, (max-width: 900px) 500px, 900px"')
+ })
+})
+
const getImageLoad = (cb = () => {}) => {
let resolve = () => {}
let reject = () => {}
diff --git a/test/nuxt/picture.test.ts b/test/nuxt/picture.test.ts
index 86c21406e..79009d18d 100644
--- a/test/nuxt/picture.test.ts
+++ b/test/nuxt/picture.test.ts
@@ -1,6 +1,6 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
-import { beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { NuxtPicture } from '#components'
import { useNuxtApp, useRuntimeConfig, nextTick } from '#imports'
import { imageOptions } from '#build/image-options.mjs'
@@ -321,3 +321,52 @@ describe('Renders image, applies module config', () => {
`)
})
})
+
+describe('NuxtPicture with min-width responsive breakpoints', () => {
+ const nuxtApp = useNuxtApp()
+ const config = useRuntimeConfig()
+ const src = '/image.png'
+
+ beforeEach(() => {
+ delete nuxtApp._img
+ delete nuxtApp.$img
+ })
+
+ afterEach(() => {
+ delete nuxtApp._img
+ delete nuxtApp.$img
+ })
+
+ it('uses min-width media queries via prop', () => {
+ const picture = mount(NuxtPicture, {
+ propsData: {
+ width: 200,
+ height: 200,
+ sizes: '200,500:500,900:900',
+ responsiveBreakpoints: 'min-width',
+ src,
+ },
+ })
+ const sizes = picture.find('source').element.getAttribute('sizes')
+ expect(sizes).toMatchInlineSnapshot('"(min-width: 900px) 900px, (min-width: 500px) 500px, 200px"')
+ })
+
+ it('module config responsiveBreakpoints=min-width applies globally', () => {
+ nuxtApp._img = createImage({
+ runtimeConfig: {} as any,
+ ...imageOptions,
+ nuxt: { baseURL: config.app.baseURL },
+ responsiveBreakpoints: 'min-width',
+ })
+ const picture = mount(NuxtPicture, {
+ propsData: {
+ width: 200,
+ height: 200,
+ sizes: '200,500:500,900:900',
+ src,
+ },
+ })
+ const sizes = picture.find('source').element.getAttribute('sizes')
+ expect(sizes).toMatchInlineSnapshot('"(min-width: 900px) 900px, (min-width: 500px) 500px, 200px"')
+ })
+})
diff --git a/test/unit/bundle.test.ts b/test/unit/bundle.test.ts
index c1fe86f7c..c998c7923 100644
--- a/test/unit/bundle.test.ts
+++ b/test/unit/bundle.test.ts
@@ -22,7 +22,7 @@ describe.skipIf(process.env.ECOSYSTEM_CI || isWindows)('nuxt image bundle size',
}),
])
- expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"12.8k"`)
+ expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"13.1k"`)
})
})