06 — Responsive Images Pattern
Dev Guide — Savoy Signature Hotels
PRD refs:07_Modules_and_Templates.md,13_Media_and_Image_Pipeline.md
1. Purpose
Section titled “1. Purpose”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.
2. Architecture
Section titled “2. Architecture”3. The ResponsiveImage Component
Section titled “3. The ResponsiveImage Component”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> );}4. Usage in Modules
Section titled “4. Usage in Modules”4.1 Types Pattern
Section titled “4.1 Types Pattern”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}4.2 Mapper Pattern
Section titled “4.2 Mapper Pattern”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 || '', // ... })), };}4.3 Component Usage
Section titled “4.3 Component Usage”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/>5. Image Sizing Guidelines
Section titled “5. Image Sizing Guidelines”5.1 Recommended Dimensions by Module Type
Section titled “5.1 Recommended Dimensions by Module Type”| Module Type | Desktop | Mobile | Aspect Ratio |
|---|---|---|---|
| Hero / Hero Slider | 1920x1080 | 750x1000 | 16:9 / 3:4 |
| Card Grid | 800x600 | 400x300 | 4:3 |
| Image + Text | 960x640 | 750x500 | 3:2 |
| Gallery | 1200x800 | 600x400 | 3:2 |
| Room Detail | 1200x800 | 750x1000 | 3:2 / 3:4 |
| Featured Card | 960x540 | 750x422 | 16:9 |
5.2 Upload Constraints (Umbraco)
Section titled “5.2 Upload Constraints (Umbraco)”| Type | Max Size |
|---|---|
| Desktop images | 1MB |
| Mobile images | 500KB |
| SVGs | No size limit (sanitized on upload) |
| Videos | 10MB (background loops only) |
6. Focal Points
Section titled “6. Focal Points”6.1 How They Work
Section titled “6.1 How They Work”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.
6.2 Implementation
Section titled “6.2 Implementation”// 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 }}7. Performance Rules
Section titled “7. Performance Rules”| Rule | Rationale |
|---|---|
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-width | Helps browser choose correct srcset size |
| Desktop images served only above 1024px | <source media="(min-width: 1024px)"> |
| Images processed by Cloudflare, not Next.js | Custom loader bypasses Node.js image optimization |
7.1 Cloudflare Image Loader
Section titled “7.1 Cloudflare Image Loader”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}`;}const nextConfig = { images: { loader: 'custom', loaderFile: './src/lib/cloudflare-image-loader.ts', },};8. Storybook Mock Images
Section titled “8. Storybook Mock Images”For Storybook, use placeholder images in apps/storybook/public/storybook/:
// Story mock dataconst 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',};9. Alt Text Rules
Section titled “9. Alt Text Rules”| Scenario | Alt Text |
|---|---|
| Informative image | Descriptive text: "Deluxe suite with balcony overlooking the ocean" |
| Decorative image | Empty: alt="" |
| Image with text overlay | Describe the visual, not the overlaid text |
| CMS images | altText field is mandatory in Umbraco (enforced by content model) |
10. Checklist
Section titled “10. Checklist”Before merging a module with images:
- Uses
ResponsiveImagecomponent (not raw<img>) - Both
imageDesktopandimageMobileare mapped from CMS -
alttext is meaningful (or empty for decorative) - First visible image has
priority={true} - Focal point is mapped to
objectPositionwhen available - Images render correctly at all breakpoints in Storybook
- SCSS uses
object-fit: coverwith appropriate aspect ratio