Skip to content

06 — Responsive Images Pattern

Dev Guide — Savoy Signature Hotels
PRD refs: 07_Modules_and_Templates.md, 13_Media_and_Image_Pipeline.md


Every image-bearing module in the Savoy platform uses a Desktop + Mobile dual-image pattern. This guide explains how to implement this pattern using the shared ResponsiveImage component, how images flow from Umbraco to the frontend, and how focal points work.


Umbraco Editor

Content Delivery API

Uploads Desktop image (e.g., 1920x1080)

Uploads Mobile image (e.g., 750x1000)

Sets Focal Point on each

Returns JSON with both image URLs + dimensions + focalPoint

Module Mapper

Maps to { imageDesktop, imageMobile } props

ResponsiveImage Component (packages/ui)

Renders picture with source for desktop + img for mobile

Cloudflare Images

Optimizes on-the-fly (WebP/AVIF, resize, cache)

Browser


Located at packages/ui/src/ResponsiveImage/ResponsiveImage.tsx:

import Image from 'next/image';
interface ImageSource {
url: string;
width: number;
height: number;
focalPoint?: { top: number; left: number };
}
interface ResponsiveImageProps {
desktop: ImageSource;
mobile: ImageSource;
alt: string;
priority?: boolean;
className?: string;
sizes?: string;
}
export function ResponsiveImage({
desktop,
mobile,
alt,
priority = false,
className,
sizes = '100vw',
}: ResponsiveImageProps) {
const desktopPosition = desktop.focalPoint
? `${desktop.focalPoint.left * 100}% ${desktop.focalPoint.top * 100}%`
: '50% 50%';
const mobilePosition = mobile.focalPoint
? `${mobile.focalPoint.left * 100}% ${mobile.focalPoint.top * 100}%`
: '50% 50%';
return (
<picture className={className}>
<source
media="(min-width: 1024px)"
srcSet={desktop.url}
width={desktop.width}
height={desktop.height}
/>
<Image
src={mobile.url}
width={mobile.width}
height={mobile.height}
alt={alt}
priority={priority}
sizes={sizes}
style={{
objectFit: 'cover',
objectPosition: mobilePosition,
width: '100%',
height: '100%',
}}
/>
</picture>
);
}

Every module with images follows this pattern in .types.ts:

export interface ImageSource {
url: string;
width: number;
height: number;
focalPoint?: { top: number; left: number };
}
export interface HeroSliderSlide {
imageDesktop: ImageSource;
imageMobile: ImageSource;
altText: string;
// ... other props
}

In the .mapper.ts, map from Umbraco API response:

function mapImage(umbracoImage: any): ImageSource {
return {
url: umbracoImage?.url ?? '',
width: umbracoImage?.width ?? 800,
height: umbracoImage?.height ?? 600,
focalPoint: umbracoImage?.focalPoint
? { top: umbracoImage.focalPoint.top, left: umbracoImage.focalPoint.left }
: undefined,
};
}
// Usage in mapper:
export function mapHeroSlider(element: UmbracoElement) {
return {
slides: element.properties.slides.map((slide: any) => ({
imageDesktop: mapImage(slide.imageDesktop),
imageMobile: mapImage(slide.imageMobile),
altText: slide.altText || '',
// ...
})),
};
}
import { ResponsiveImage } from '@savoy/ui';
// In module component:
<ResponsiveImage
desktop={slide.imageDesktop}
mobile={slide.imageMobile}
alt={slide.altText}
priority={index === 0} // Only first visible image gets priority
/>

Module TypeDesktopMobileAspect Ratio
Hero / Hero Slider1920x1080750x100016:9 / 3:4
Card Grid800x600400x3004:3
Image + Text960x640750x5003:2
Gallery1200x800600x4003:2
Room Detail1200x800750x10003:2 / 3:4
Featured Card960x540750x42216:9
TypeMax Size
Desktop images1MB
Mobile images500KB
SVGsNo size limit (sanitized on upload)
Videos10MB (background loops only)

Editors set a focal point in Umbraco’s Image Cropper. The API returns:

{
"focalPoint": {
"top": 0.3, // 30% from top
"left": 0.5 // 50% from left (centered)
}
}

The frontend translates this to CSS:

object-position: 50% 30%;

This ensures the most important part of the image stays visible regardless of container aspect ratio.

// In module SCSS, the container controls aspect ratio:
.hero-slider__slide {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
@media (max-width: 1023px) {
aspect-ratio: 3 / 4;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
// object-position is set inline by ResponsiveImage via focalPoint
}
}

RuleRationale
priority only on first visible image (LCP)Preloads the largest contentful paint image
All other images use loading="lazy" (default)Defers offscreen image loading
Use sizes prop when image isn’t full-widthHelps browser choose correct srcset size
Desktop images served only above 1024px<source media="(min-width: 1024px)">
Images processed by Cloudflare, not Next.jsCustom loader bypasses Node.js image optimization
src/lib/cloudflare-image-loader.ts
export default function cloudflareLoader({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}) {
const params = [
`width=${width}`,
`quality=${quality || 75}`,
'format=auto',
].join(',');
return `https://savoysignature.com/cdn-cgi/image/${params}/${src}`;
}
next.config.ts
const nextConfig = {
images: {
loader: 'custom',
loaderFile: './src/lib/cloudflare-image-loader.ts',
},
};

For Storybook, use placeholder images in apps/storybook/public/storybook/:

// Story mock data
const mockImage = {
imageDesktop: { url: '/storybook/hero-pool-desktop.jpg', width: 1920, height: 1080 },
imageMobile: { url: '/storybook/hero-pool-mobile.jpg', width: 750, height: 1000 },
altText: 'Infinity pool with ocean view at Savoy Palace',
};

ScenarioAlt Text
Informative imageDescriptive text: "Deluxe suite with balcony overlooking the ocean"
Decorative imageEmpty: alt=""
Image with text overlayDescribe the visual, not the overlaid text
CMS imagesaltText field is mandatory in Umbraco (enforced by content model)

Before merging a module with images:

  • Uses ResponsiveImage component (not raw &lt;img&gt;)
  • Both imageDesktop and imageMobile are mapped from CMS
  • alt text is meaningful (or empty for decorative)
  • First visible image has priority={true}
  • Focal point is mapped to objectPosition when available
  • Images render correctly at all breakpoints in Storybook
  • SCSS uses object-fit: cover with appropriate aspect ratio