Skip to content
Merged
34 changes: 34 additions & 0 deletions .changeset/khaki-cloths-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'astro': minor
---

Adds a new `priority` attribute for Astro's image components.

This change introduces a new `priority` option for the `<Image />` and `<Picture />` components, which automatically sets the `loading`, `decoding`, and `fetchpriority` attributes to their optimal values for above-the-fold images which should be loaded immediately.

It is a boolean prop, and you can use the shorthand syntax by simply adding `priority` as a prop to the `<Image />` or `<Picture />` component. When set, it will apply the following attributes:

- `loading="eager"`
- `decoding="sync"`
- `fetchpriority="high"`

The individual attributes can still be set manually if you need to customize your images further.

By default, the Astro [`<Image />` component](https://docs.astro.build/en/guides/images/#display-optimized-images-with-the-image--component) generates `<img>` tags that lazy-load their content by setting `loading="lazy"` and `decoding="async"`. This improves performance by deferring the loading of images that are not immediately visible in the viewport, and gives the best scores in performance audits like Lighthouse.

The new `priority` attribute will override those defaults and automatically add the best settings for your high-priority assets.

This option was previously available for experimental responsive images, but now it is a standard feature for all images.

## Usage

```astro
<Image
src="/path/to/image.jpg"
alt="An example image"
priority
/>
```

> [!Note]
> You should only use the `priority` option for images that are critical to the initial rendering of the page, and ideally only one image per page. This is often an image identified as the [LCP element](https://web.dev/articles/lcp) when running Lighthouse tests. Using it for too many images will lead to performance issues, as it forces the browser to load those images immediately, potentially blocking the rendering of other content.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent note!

91 changes: 91 additions & 0 deletions .changeset/mean-gifts-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
'astro': minor
'@astrojs/vercel': patch
---

The responsive images feature introduced behind a flag in [v5.0.0](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md#500) is no longer experimental and is available for general use.

The new responsive images feature in Astro automatically generates optimized images for different screen sizes and resolutions, and applies the correct attributes to ensure that images are displayed correctly on all devices.

Enable the `image.responsiveStyles` option in your Astro config. Then, set a `layout` attribute on any <Image /> or <Picture /> component, or configure a default `image.layout`, for instantly responsive images with automatically generated `srcset` and `sizes` attributes based on the image's dimensions and the layout type.

Displaying images correctly on the web can be challenging, and is one of the most common performance issues seen in sites. This new feature simplifies the most challenging part of the process: serving your site visitor an image optimized for their viewing experience, and for your website's performance.

For full details, see the updated [Image guide](https://docs.astro.build/en/guides/images/#responsive-image-behavior).

## Migration from Experimental Responsive Images

The `experimental.responsiveImages` flag has been removed, and all experimental image configuration options have been renamed to their final names.

If you were using the experimental responsive images feature, you'll need to update your configuration:

### Remove the experimental flag

```diff
export default defineConfig({
experimental: {
- responsiveImages: true,
},
});
```

### Update image configuration options

During the experimental phase, default styles were applied automatically to responsive images. Now, you need to explicitly set the `responsiveStyles` option to `true` if you want these styles applied.

```diff
export default defineConfig({
image: {
+ responsiveStyles: true,
},
});
```

The experimental image configuration options have been renamed:

**Before:**
```js
export default defineConfig({
image: {
experimentalLayout: 'constrained',
experimentalObjectFit: 'cover',
experimentalObjectPosition: 'center',
experimentalBreakpoints: [640, 750, 828, 1080, 1280],
experimentalDefaultStyles: true,
},
experimental: {
responsiveImages: true,
},
});
```

**After:**
```js
export default defineConfig({
image: {
layout: 'constrained',
objectFit: 'cover',
objectPosition: 'center',
breakpoints: [640, 750, 828, 1080, 1280],
responsiveStyles: true, // This is now *false* by default
},
});
```

### Component usage remains the same

The `layout`, `fit`, and `position` props on `<Image>` and `<Picture>` components work exactly the same as before:

```astro
<Image
src={myImage}
alt="A responsive image"
layout="constrained"
fit="cover"
position="center"
/>
```

If you weren't using the experimental responsive images feature, no changes are required.

Please see the [Image guide](https://docs.astro.build/en/guides/images/#responsive-image-behavior) for more information on using responsive images in Astro.
4 changes: 1 addition & 3 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,7 @@ declare module 'astro:assets' {
getImage: (
options: import('./dist/assets/types.js').UnresolvedImageTransform,
) => Promise<import('./dist/assets/types.js').GetImageResult>;
imageConfig: import('./dist/types/public/config.js').AstroConfig['image'] & {
experimentalResponsiveImages: boolean;
};
imageConfig: import('./dist/types/public/config.js').AstroConfig['image'];
getConfiguredImageService: typeof import('./dist/assets/index.js').getConfiguredImageService;
inferRemoteSize: typeof import('./dist/assets/utils/index.js').inferRemoteSize;
Image: typeof import('./components/Image.astro').default;
Expand Down
11 changes: 5 additions & 6 deletions packages/astro/components/Image.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,13 @@ if (typeof props.height === 'string') {
props.height = parseInt(props.height);
}

const layout = props.layout ?? imageConfig.experimentalLayout ?? 'none';
const useResponsive = imageConfig.experimentalResponsiveImages && layout !== 'none';
const layout = props.layout ?? imageConfig.layout ?? 'none';

if (useResponsive) {
if (layout !== 'none') {
// Apply defaults from imageConfig if not provided
props.layout ??= imageConfig.experimentalLayout;
props.fit ??= imageConfig.experimentalObjectFit ?? 'cover';
props.position ??= imageConfig.experimentalObjectPosition ?? 'center';
props.layout ??= imageConfig.layout;
props.fit ??= imageConfig.objectFit ?? 'cover';
props.position ??= imageConfig.objectPosition ?? 'center';
}

const image = await getImage(props as UnresolvedImageTransform);
Expand Down
10 changes: 5 additions & 5 deletions packages/astro/components/Picture.astro
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ if (scopedStyleClass) {
}
}

const layout = props.layout ?? imageConfig.experimentalLayout ?? 'none';
const useResponsive = imageConfig.experimentalResponsiveImages && layout !== 'none';
const layout = props.layout ?? imageConfig.layout ?? 'none';
const useResponsive = layout !== 'none';

if (useResponsive) {
// Apply defaults from imageConfig if not provided
props.layout ??= imageConfig.experimentalLayout;
props.fit ??= imageConfig.experimentalObjectFit ?? 'cover';
props.position ??= imageConfig.experimentalObjectPosition ?? 'center';
props.layout ??= imageConfig.layout;
props.fit ??= imageConfig.objectFit ?? 'cover';
props.position ??= imageConfig.objectPosition ?? 'center';
}

for (const key in props) {
Expand Down
1 change: 1 addition & 0 deletions packages/astro/components/ResponsivePicture.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { default as Picture, type Props as PictureProps } from './Picture.astro'
type Props = PictureProps;

const { class: className, ...props } = Astro.props;
import './image.css';
---

{/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */}
Expand Down
56 changes: 25 additions & 31 deletions packages/astro/src/assets/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,9 @@ export async function getConfiguredImageService(): Promise<ImageService> {
return globalThis.astroAsset.imageService;
}

type ImageConfig = AstroConfig['image'] & {
experimentalResponsiveImages: boolean;
};

export async function getImage(
options: UnresolvedImageTransform,
imageConfig: ImageConfig,
imageConfig: AstroConfig['image'],
): Promise<GetImageResult> {
if (!options || typeof options !== 'object') {
throw new AstroError({
Expand Down Expand Up @@ -126,43 +122,41 @@ export async function getImage(
}
resolvedOptions.src = clonedSrc;

const layout = options.layout ?? imageConfig.experimentalLayout;
const layout = options.layout ?? imageConfig.layout ?? 'none';

if (imageConfig.experimentalResponsiveImages && layout) {
if (resolvedOptions.priority) {
resolvedOptions.loading ??= 'eager';
resolvedOptions.decoding ??= 'sync';
resolvedOptions.fetchpriority ??= 'high';
delete resolvedOptions.priority;
} else {
resolvedOptions.loading ??= 'lazy';
resolvedOptions.decoding ??= 'async';
resolvedOptions.fetchpriority ??= 'auto';
}

if (layout !== 'none') {
resolvedOptions.widths ||= getWidths({
width: resolvedOptions.width,
layout,
originalWidth,
breakpoints: imageConfig.experimentalBreakpoints?.length
? imageConfig.experimentalBreakpoints
breakpoints: imageConfig.breakpoints?.length
? imageConfig.breakpoints
: isLocalService(service)
? LIMITED_RESOLUTIONS
: DEFAULT_RESOLUTIONS,
});
resolvedOptions.sizes ||= getSizesAttribute({ width: resolvedOptions.width, layout });

if (resolvedOptions.priority) {
resolvedOptions.loading ??= 'eager';
resolvedOptions.decoding ??= 'sync';
resolvedOptions.fetchpriority ??= 'high';
} else {
resolvedOptions.loading ??= 'lazy';
resolvedOptions.decoding ??= 'async';
resolvedOptions.fetchpriority ??= 'auto';
}
delete resolvedOptions.priority;
// The densities option is incompatible with the `layout` option
delete resolvedOptions.densities;

if (layout !== 'none') {
resolvedOptions.style = addCSSVarsToStyle(
{
fit: cssFitValues.includes(resolvedOptions.fit ?? '') && resolvedOptions.fit,
pos: resolvedOptions.position,
},
resolvedOptions.style,
);
resolvedOptions['data-astro-image'] = layout;
}
resolvedOptions.style = addCSSVarsToStyle(
{
fit: cssFitValues.includes(resolvedOptions.fit ?? '') && resolvedOptions.fit,
pos: resolvedOptions.position,
},
resolvedOptions.style,
);
resolvedOptions['data-astro-image'] = layout;
}

const validatedOptions = service.validateOptions
Expand Down
8 changes: 4 additions & 4 deletions packages/astro/src/assets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,9 @@ type ImageSharedProps<T> = T & {
} & (
| {
/**
* The layout type for responsive images. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
* The layout type for responsive images.
*
* Allowed values are `constrained`, `fixed`, `full-width` or `none`. Defaults to value of `image.experimentalLayout`.
* Allowed values are `constrained`, `fixed`, `full-width` or `none`. Defaults to value of `image.layout`.
*
* - `constrained` - The image will scale to fit the container, maintaining its aspect ratio, but will not exceed the specified dimensions.
* - `fixed` - The image will maintain its original dimensions.
Expand All @@ -177,7 +177,7 @@ type ImageSharedProps<T> = T & {
layout?: ImageLayout;

/**
* Defines how the image should be cropped if the aspect ratio is changed. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
* Defines how the image should be cropped if the aspect ratio is changed. Requires `layout` to be set.
*
* Default is `cover`. Allowed values are `fill`, `contain`, `cover`, `none` or `scale-down`. These behave like the equivalent CSS `object-fit` values. Other values may be passed if supported by the image service.
*
Expand All @@ -190,7 +190,7 @@ type ImageSharedProps<T> = T & {
fit?: ImageFit;

/**
* Defines the position of the image when cropping. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
* Defines the position of the image when cropping. Requires `layout` to be set.
*
* The value is a string that specifies the position of the image, which matches the CSS `object-position` property. Other values may be passed if supported by the image service.
*
Expand Down
7 changes: 2 additions & 5 deletions packages/astro/src/assets/vite-plugin-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,7 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
referencedImages: new Set(),
};

const imageComponentPrefix =
settings.config.experimental.responsiveImages && settings.config.image.experimentalDefaultStyles
? 'Responsive'
: '';
const imageComponentPrefix = settings.config.image.responsiveStyles ? 'Responsive' : '';
return [
// Expose the components and different utilities from `astro:assets`
{
Expand All @@ -139,7 +136,7 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
export { default as Font } from "astro/components/Font.astro";
export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js";

export const imageConfig = ${JSON.stringify({ ...settings.config.image, experimentalResponsiveImages: settings.config.experimental.responsiveImages })};
export const imageConfig = ${JSON.stringify(settings.config.image)};
// This is used by the @astrojs/node integration to locate images.
// It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel)
// new URL("dist/...") is interpreted by the bundler as a signal to include that directory
Expand Down
19 changes: 6 additions & 13 deletions packages/astro/src/core/config/schemas/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const ASTRO_CONFIG_DEFAULTS = {
image: {
endpoint: { entrypoint: undefined, route: '/_image' },
service: { entrypoint: 'astro/assets/services/sharp', config: {} },
experimentalDefaultStyles: true,
responsiveStyles: false,
},
devToolbar: {
enabled: true,
Expand Down Expand Up @@ -98,7 +98,6 @@ export const ASTRO_CONFIG_DEFAULTS = {
experimental: {
clientPrerender: false,
contentIntellisense: false,
responsiveImages: false,
headingIdCompat: false,
preserveScriptOrder: false,
csp: false,
Expand Down Expand Up @@ -273,13 +272,11 @@ export const AstroConfigSchema = z.object({
}),
)
.default([]),
experimentalLayout: z.enum(['constrained', 'fixed', 'full-width', 'none']).optional(),
experimentalObjectFit: z.string().optional(),
experimentalObjectPosition: z.string().optional(),
experimentalBreakpoints: z.array(z.number()).optional(),
experimentalDefaultStyles: z
.boolean()
.default(ASTRO_CONFIG_DEFAULTS.image.experimentalDefaultStyles),
layout: z.enum(['constrained', 'fixed', 'full-width', 'none']).optional(),
objectFit: z.string().optional(),
objectPosition: z.string().optional(),
breakpoints: z.array(z.number()).optional(),
responsiveStyles: z.boolean().default(ASTRO_CONFIG_DEFAULTS.image.responsiveStyles),
})
.default(ASTRO_CONFIG_DEFAULTS.image),
devToolbar: z
Expand Down Expand Up @@ -466,10 +463,6 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense),
responsiveImages: z
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.responsiveImages),
headingIdCompat: z
.boolean()
.optional()
Expand Down
15 changes: 0 additions & 15 deletions packages/astro/src/core/config/schemas/refined.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,21 +172,6 @@ export const AstroConfigRefinedSchema = z.custom<AstroConfig>().superRefine((con
}
}

if (
!config.experimental.responsiveImages &&
(config.image.experimentalLayout ||
config.image.experimentalObjectFit ||
config.image.experimentalObjectPosition ||
config.image.experimentalBreakpoints)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'The `experimentalLayout`, `experimentalObjectFit`, `experimentalObjectPosition` and `experimentalBreakpoints` options are only available when `experimental.responsiveImages` is enabled.',
path: ['experimental', 'responsiveImages'],
});
}

if (config.experimental.fonts && config.experimental.fonts.length > 0) {
for (let i = 0; i < config.experimental.fonts.length; i++) {
const { cssVariable } = config.experimental.fonts[i];
Expand Down
Loading
Loading