Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/content/1.get-started/2.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions docs/content/2.usage/1.nuxt-img.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<NuxtImg
src="/logos/nuxt.png"
sizes="sm:100vw md:50vw lg:400px"
responsive-breakpoints="min-width"
/>
```

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.
Expand Down
10 changes: 8 additions & 2 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface ModuleOptions extends ImageProviders {
domains: string[]
alias: Record<string, string>
screens: CreateImageOptions['screens']
responsiveBreakpoints: CreateImageOptions['responsiveBreakpoints']
providers: { [name: string]: InputProvider | any }
densities: number[]
format: CreateImageOptions['format']
Expand Down Expand Up @@ -45,6 +46,7 @@ export default defineNuxtModule<ModuleOptions>({
providers: {},
alias: {},
densities: [1, 2],
responsiveBreakpoints: 'max-width',
}),
meta: {
name: '@nuxt/image',
Expand Down Expand Up @@ -113,6 +115,7 @@ export default defineNuxtModule<ModuleOptions>({
'densities',
'format',
'quality',
'responsiveBreakpoints',
])

const providers = await resolveProviders(nuxt, options)
Expand Down Expand Up @@ -235,16 +238,19 @@ function pick<O extends Record<any, any>, K extends keyof O>(obj: O, keys: K[]):
}

function generateImageOptions(providers: ImageModuleProvider[], imageOptions: Omit<CreateImageOptions, 'providers' | 'nuxt' | 'runtimeConfig'>): 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)},
}
`
}
1 change: 1 addition & 0 deletions src/runtime/components/NuxtImg.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))

Expand Down
1 change: 1 addition & 0 deletions src/runtime/components/NuxtPicture.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const sources = computed<Source[]>(() => {
...providerOptions.value,
sizes: props.sizes || $img.options.screens,
densities: props.densities,
responsiveBreakpoints: props.responsiveBreakpoints,
modifiers: { ...imageModifiers.value, format },
})

Expand Down
25 changes: 19 additions & 6 deletions src/runtime/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -165,7 +167,7 @@ function getSizes(ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageS
}
}

finaliseSizeVariants(sizeVariants)
finaliseSizeVariants(sizeVariants, responsiveBreakpoints)
}
else {
// 'densities path'
Expand Down Expand Up @@ -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`)
Expand All @@ -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 || ''
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/runtime/utils/props.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -22,6 +22,7 @@ export interface BaseImageProps<Provider extends keyof ConfiguredImageProviders>

sizes?: string | Record<string, any>
densities?: string
responsiveBreakpoints?: ResponsiveBreakpoints
preload?: boolean | { fetchPriority: 'auto' | 'high' | 'low' }

// <img> attributes
Expand Down
4 changes: 4 additions & 0 deletions src/types/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 extends keyof ConfiguredImageProviders = DefaultProvider> {
provider?: Provider
preset?: string
densities?: string
responsiveBreakpoints?: ResponsiveBreakpoints
modifiers?: Partial<Omit<ImageModifiers, 'format' | 'quality' | 'background' | 'fit'>>
& ('modifiers' extends keyof ConfiguredImageProviders[Provider] ? ConfiguredImageProviders[Provider]['modifiers'] : Record<string, unknown>)
sizes?: string | Record<string, any>
Expand Down Expand Up @@ -57,6 +60,7 @@ export interface CreateImageOptions {
presets: { [name: string]: ImageOptions }
provider: (string & {}) | keyof ImageProviders
screens: Record<string, number>
responsiveBreakpoints: ResponsiveBreakpoints
alias: Record<string, string>
domains: string[]
densities: number[]
Expand Down
84 changes: 83 additions & 1 deletion test/nuxt/image.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 = () => {}
Expand Down
51 changes: 50 additions & 1 deletion test/nuxt/picture.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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"')
})
})
2 changes: 1 addition & 1 deletion test/unit/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"`)
})
})

Expand Down
Loading