M07 — Slider Categories: Development Prompt
Read the following context files first:
- docs/PRD/07_Modules_and_Templates.md
- docs/dev-frontend-guides/03_MODULE_DEVELOPMENT_LIFECYCLE.md
- docs/dev-frontend-guides/05_BEM_SASS_THEMING.md
- docs/dev-frontend-guides/01_FIGMA_TO_CODE_WORKFLOW.md
- docs/dev-frontend-guides/04_STORYBOOK_FIRST_DEVELOPMENT.md
- docs/dev-frontend-guides/06_RESPONSIVE_IMAGES_PATTERN.md
- packages/modules/src/m04-page-hero/ (reference implementation)
- packages/modules/src/registry.ts
- packages/themes/src/_base.css
- packages/themes/src/savoy-palace.css (reference for module token pattern)
- packages/ui/src/ (scan for available UI components, especially ResponsiveImage and CtaLink)
1. Module Overview
Section titled “1. Module Overview”| Field | Value |
|---|---|
| Module ID | M07 |
| Module Name | Slider Categories |
| Folder | m07-slider-categories |
| PascalCase | SliderCategories |
| camelCase (Registry Key / Umbraco Alias) | sliderCategories |
| BEM Block | .slider-categories |
| Component Type | Interactive (Client Component — carousel with SwiperJS, category filtering, custom cursor) |
Description: A content showcase module combining a vertical category navigation panel (left) with a horizontal card carousel (right/center). Editors define categories, each containing a set of cards with image, title, description, and CTA link. Selecting a category filters the visible cards in the slider. Cards follow an alternating tall/short image pattern (odd/even), creating a staggered visual rhythm. Features a custom circular cursor on hover, a progress bar indicating scroll position, and SwiperJS-powered navigation with touch/swipe support.
Figma Links:
User Story / Task: TBD (Zoho Project)
2. Pre-Flight Checklist
Section titled “2. Pre-Flight Checklist”- Module ID assigned: M07
- Module name defined: Slider Categories
- Figma desktop link available (default + 3 theme variations)
- Figma mobile link available
- Interactive Component decision made (carousel + category filtering + custom cursor)
- Image variants identified: Each card needs imageDesktop + imageMobile
- Accessibility requirements noted: keyboard nav for categories + slider, ARIA roles, focus management
- User Story / task link available (Zoho Project)
- SVG thumbnail created for Block List picker (
wwwroot/assets/thumbnails/m07-slider-categories.svg) - Umbraco Element Type has an Icon assigned (e.g.,
icon-thumbnails)
3. Design Analysis — Variant Inventory
Section titled “3. Design Analysis — Variant Inventory”3.1 Module Structure
Section titled “3.1 Module Structure”The module has three distinct visual zones:
- Header — Centered label (eyebrow) + title above the body content
- Body — Two-column layout: category navigation (left) + card carousel (right)
- Controls — Navigation arrows + progress bar below the carousel
3.2 Card Odd/Even Image Pattern
Section titled “3.2 Card Odd/Even Image Pattern”Within each category’s card set, cards alternate between two image heights:
| Card Index | Image Type | Image Dimensions (Figma) | Aspect Ratio |
|---|---|---|---|
| Odd (1st, 3rd, 5th…) | Portrait/tall | 257 × 308px | ~1:1.2 |
| Even (2nd, 4th, 6th…) | Square/short | 257 × 257px | 1:1 |
Critical visual detail: All cards are bottom-aligned (align-items: flex-end on the slider row), creating a staggered visual rhythm where taller cards extend above shorter ones. The text content below each image aligns at the same baseline.
3.3 Theme Variations
Section titled “3.3 Theme Variations”The primary visual differentiation across themes is:
| Theme | Image Border Radius | Notes |
|---|---|---|
| Savoy Signature | 0 | Square corners |
| Savoy Palace | 0 | Square corners |
| Royal Savoy | 0 | Square corners |
| Saccharum | 0 | Square corners |
| The Reserve | 0 | Square corners |
| Calheta Beach | ~24px | Rounded corners |
| Gardens | 0 | Square corners |
| Hotel Next | 100px 0 0 0 | Large top-left corner only |
Each theme also has a decorative watermark/symbol in the header area. Colors are driven by existing theme tokens. The image container border-radius is controlled by --radius-container-image (already defined per theme).
3.4 Background
Section titled “3.4 Background”The module uses a light/white background (var(--color-bg) or var(--color-surface)). No background variant toggle — single variant.
4. Content Structure & Props
Section titled “4. Content Structure & Props”import type { HtmlHeading } from '@savoy/cms-client';import type { CtaLink } from '@savoy/cms-client';
/** A single card within a category */export interface SliderCategoryCard { /** Desktop image source */ imageDesktop: { url: string; width: number; height: number; focalPoint?: { top: number; left: number }; }; /** Mobile image source */ imageMobile: { url: string; width: number; height: number; focalPoint?: { top: number; left: number }; }; /** Descriptive alt text for the image */ imageAlt: string; /** Card title */ title?: HtmlHeading; /** Card description text */ description?: string; /** Card CTA link */ cta?: CtaLink;}
/** A category grouping cards */export interface SliderCategory { /** Category name displayed in navigation */ name: string; /** Cards belonging to this category */ cards: SliderCategoryCard[];}
/** Module props */export interface SliderCategoriesProps { /** Optional uppercase label/eyebrow above the title */ label?: HtmlHeading; /** Main section heading */ title?: HtmlHeading; /** Array of categories, each with a name and cards */ categories: SliderCategory[]; /** Site key — injected by ModuleRenderer */ siteKey: string; /** Locale — injected by ModuleRenderer */ locale: string; /** Module ID — injected by page renderer */ moduleId?: string;}| Field | Required | Default | Notes |
|---|---|---|---|
label | No | undefined | Uppercase eyebrow text (HtmlHeading from CMS) |
title | No | undefined | Main heading (HtmlHeading from CMS) |
categories | Yes | — | At least 1 category with cards |
categories[].name | Yes | — | Category label for navigation |
categories[].cards | Yes | — | Array of cards (minimum 1) |
cards[].imageDesktop | Yes | — | Desktop image with url, width, height |
cards[].imageMobile | Yes | — | Mobile image with url, width, height |
cards[].imageAlt | Yes | — | Descriptive alt text |
cards[].title | No | undefined | Card heading (HtmlHeading) |
cards[].description | No | undefined | Card body text |
cards[].cta | No | undefined | Card CTA with label + href |
siteKey | Yes | — | Injected by ModuleRenderer |
locale | Yes | — | Injected by ModuleRenderer |
moduleId | No | undefined | Injected by page renderer |
5. Layout Specification
Section titled “5. Layout Specification”5.1 Desktop Layout (1440px Viewport)
Section titled “5.1 Desktop Layout (1440px Viewport)”+------------------------------------------------------------------------+| 120px padding 120px || +------------------------------------------------------------------+ || | max-width: 1200px, centered | || | | || | +----- Header (centered, ~792px) -----+ | || | | LABEL (uppercase eyebrow) | | || | | TITLE (large heading) | | || | +--------------------------------------+ | || | | || | +-- Categories --+ gap +-------- Slider/Carousel ---------+ | || | | • Category 1 | | [Card][Card][Card][Card]→→→ | | || | | Category 2 | | tall short tall short | | || | | Category 3 | | | | || | | Category 4 | +-----------------------------------+ | || | +----------------+ | ← → ████████░░░░░░░░░░░ bar | | || | +-----------------------------------+ | || +------------------------------------------------------------------+ |+------------------------------------------------------------------------+| Property | Value | CSS Token / Implementation |
|---|---|---|
| Section vertical padding | 80px top and bottom | padding-block: var(--space-20) |
| Section horizontal padding | 120px | padding-inline: 120px |
| Inner container max-width | 1200px | max-width: var(--container-max, 1200px) |
| Inner container centering | Centered | margin-inline: auto |
| Header width | ~792px centered | max-width: 792px; margin-inline: auto; text-align: center |
| Header-to-body gap | 48–64px | gap: var(--space-12) to var(--space-16) |
| Body layout | Flex row | display: flex; flex-direction: row |
| Category nav width | ~282px fixed | width: 282px; flex-shrink: 0 |
| Category-to-slider gap | ~40px | gap: var(--space-10) |
| Slider area | Fills remaining space, overflow hidden | flex: 1; min-width: 0; overflow: hidden |
Header Internal Spacing:
| Element | Spacing | Token |
|---|---|---|
| Label to title gap | 16–24px | gap: var(--space-4) to var(--space-6) |
5.2 Category Navigation (Desktop)
Section titled “5.2 Category Navigation (Desktop)”| Property | Value | Implementation |
|---|---|---|
| Layout | Vertical list | display: flex; flex-direction: column |
| Gap between items | 0 | Items separated by borders/dividers or tight spacing |
| Active indicator | Bullet/dot or bold text + accent color | .slider-categories__category--active |
| Item padding | 12–16px vertical | padding-block: var(--space-3) to var(--space-4) |
| Font family | Body font | var(--font-body) |
| Font size | 16px | var(--text-base) |
| Font weight (inactive) | Normal (400) | var(--font-weight-normal) |
| Font weight (active) | Bold (700) | var(--font-weight-bold) |
| Text color (inactive) | Muted text | var(--color-text-muted) |
| Text color (active) | Primary/accent | var(--color-primary) or var(--color-accent) |
| Cursor | Pointer | cursor: pointer |
| Active indicator style | Small bullet/dot before text | &::before pseudo-element or dedicated element |
5.3 Card Specification (Desktop)
Section titled “5.3 Card Specification (Desktop)”| Property | Value | Implementation |
|---|---|---|
| Card width | 257px fixed | width: 257px; flex-shrink: 0 |
| Card gap (between cards) | 24px | Swiper spaceBetween: 24 |
| Odd card image height | 308px | height: 308px (portrait) |
| Even card image height | 257px | height: 257px (square) |
| Image width | Full card width (257px) | width: 100% |
| Image object-fit | cover | object-fit: cover |
| Image border-radius | Theme-driven | border-radius: var(--radius-container-image, 0) |
| Card vertical alignment | Bottom-aligned | align-self: flex-end on each card in the Swiper |
| Title font | Heading font, ~20px | font-family: var(--font-heading); font-size: var(--text-xl) |
| Title color | Heading color | var(--color-slider-categories-heading) |
| Description font | Body font, 14–16px | font-family: var(--font-body); font-size: var(--text-sm) |
| Description color | Body/muted text | var(--color-slider-categories-body) |
| CTA style | Underlined text link | text-decoration: underline — use @include cta-secondary(...) mixin |
| Image-to-title gap | 16px | gap: var(--space-4) |
| Title-to-description gap | 8px | gap: var(--space-2) |
| Description-to-CTA gap | 12px | gap: var(--space-3) |
5.4 Progress Bar
Section titled “5.4 Progress Bar”| Property | Value | Implementation |
|---|---|---|
| Position | Below slider, right-aligned with slider area | Aligned under the carousel |
| Height | 2–3px | height: 2px |
| Track background | Light border/muted | var(--color-border) or rgba(0,0,0,0.1) |
| Fill/thumb color | Primary/accent | var(--color-primary) or var(--color-accent) |
| Width calculation | (visibleCards / totalCards) * 100% min-width, position driven by Swiper progress event (0–1) | Animated with CSS transform: translateX(...) |
| Transition | Smooth follow | transition: transform 300ms ease |
5.5 Navigation Arrows (Desktop)
Section titled “5.5 Navigation Arrows (Desktop)”| Property | Value | Implementation |
|---|---|---|
| Position | Left of progress bar or inline with controls row | Flex row with arrows + progress bar |
| Size | ~40px circle or icon-only | width: 40px; height: 40px |
| Style | Minimal, outline or ghost | Border or transparent background with icon |
| Disabled state | Reduced opacity when at start/end | opacity: 0.3; cursor: default |
| Icons | Left arrow / Right arrow | SVG icons or ← → characters |
5.6 Custom Cursor
Section titled “5.6 Custom Cursor”| Property | Value | Implementation |
|---|---|---|
| Trigger area | On hover over the entire slider/carousel area | Applied to .slider-categories__slider on desktop only |
| Shape | Circle | Circular div following mouse position |
| Size | ~80px diameter | width: 80px; height: 80px; border-radius: 50% |
| Appearance | Semi-transparent or solid with text/icon | Background with “Drag” or arrow icon |
| Behavior | Follows mouse cursor position with slight delay | transform: translate(...) updated via mousemove event |
| Native cursor | Hidden when custom cursor visible | cursor: none on the slider area |
| Mobile | Hidden (no custom cursor on touch) | Only active on @media (hover: hover) or desktop viewport |
| Implementation | Absolute-positioned <div> inside slider container, pointer-events: none | Updated via requestAnimationFrame or mousemove handler |
| Entrance/exit | Fade in on mouseenter, fade out on mouseleave | opacity transition with var(--transition-fast) |
5.7 Mobile Layout (375px Viewport)
Section titled “5.7 Mobile Layout (375px Viewport)”+------------------------------------+| 24px padding 24px || +------------------------------+ || | LABEL (uppercase, centered) | || | TITLE (centered) | || | | || | [Cat 1] [Cat 2] [Cat 3] → | || | (horizontal scroll) | || | | || | [Card] [Card] [Card] → | || | (horizontal swipe) | || | | || | ← → ████░░░░░░░ bar | || +------------------------------+ |+------------------------------------+| Property | Mobile Value | Implementation |
|---|---|---|
| Section padding | 64px vertical, 24px horizontal | padding-block: var(--space-16); padding-inline: var(--space-6) |
| Layout | Single column, stacked | flex-direction: column |
| Header | Full width, centered | text-align: center |
| Category nav | Horizontal scrolling row | display: flex; flex-direction: row; overflow-x: auto; gap: var(--space-4) |
| Category items | Pill-shaped buttons or inline tabs | white-space: nowrap; padding: var(--space-2) var(--space-4) |
| Card width | 257px (fixed, same as desktop) | Cards remain fixed width, fewer visible |
| Custom cursor | Hidden | No custom cursor on touch devices |
| Controls | Arrows + progress bar below slider | Same as desktop but full-width |
| Swipe | Touch swipe left/right | SwiperJS handles touch natively |
5.8 Responsive Breakpoint Behaviour
Section titled “5.8 Responsive Breakpoint Behaviour”| Breakpoint | Layout Change |
|---|---|
| Base (< 768px) | Single column. Categories become horizontal scrolling pills. Slider is full-width swipeable. No custom cursor. |
| md (768px) | Transition to two-column. Category nav becomes vertical sidebar. Custom cursor activates. |
| lg (1024px) | Full desktop proportions. Category nav at full 282px width. All spacing at full desktop values. |
| xl (1280px) | No further changes; container max-width prevents further growth. |
| 2xl (1440px) | Lateral whitespace grows. Design matches Figma precisely at this width. |
6. Visual Design Specification
Section titled “6. Visual Design Specification”6.1 Typography
Section titled “6.1 Typography”| Element | Font Family | Size (Desktop) | Size (Mobile) | Weight | Line Height | Colour Token | Extra |
|---|---|---|---|---|---|---|---|
| Module Label | var(--font-body) | var(--text-base) (16px) | var(--text-sm) (14px) | var(--font-weight-bold) (700) | 20px | var(--color-slider-categories-label) | text-transform: uppercase; letter-spacing: 0.05em |
| Module Title | var(--font-heading) | var(--text-5xl) (48px) | var(--text-3xl) (30px) | var(--font-weight-medium) (500) | 1.1 | var(--color-slider-categories-heading) | — |
| Category Name (inactive) | var(--font-body) | var(--text-base) (16px) | var(--text-sm) (14px) | var(--font-weight-normal) (400) | 1.5 | var(--color-text-muted) | — |
| Category Name (active) | var(--font-body) | var(--text-base) (16px) | var(--text-sm) (14px) | var(--font-weight-bold) (700) | 1.5 | var(--color-primary) | Active indicator (bullet/dot) |
| Card Title | var(--font-heading) | var(--text-xl) (20px) | var(--text-lg) (18px) | var(--font-weight-medium) (500) | 1.3 | var(--color-slider-categories-card-title) | — |
| Card Description | var(--font-body) | var(--text-sm) (14px) | var(--text-sm) (14px) | var(--font-weight-normal) (400) | 1.5 | var(--color-slider-categories-card-body) | Max 2–3 lines recommended |
| Card CTA | var(--font-body) | var(--text-sm) (14px) | var(--text-sm) (14px) | var(--font-weight-bold) (700) | 1.5 | var(--color-slider-categories-card-cta) | text-decoration: underline; text-transform: uppercase |
6.2 Colours — Module-Specific Tokens
Section titled “6.2 Colours — Module-Specific Tokens”Every theme file must define these tokens. Values below are from the Savoy Signature/Palace Figma:
| Token | Default Value | Purpose |
|---|---|---|
--color-slider-categories-bg | var(--color-bg) | Module background |
--color-slider-categories-label | var(--color-accent) | Label/eyebrow text |
--color-slider-categories-heading | var(--color-primary) | Module title text |
--color-slider-categories-card-title | var(--color-primary) | Card title text |
--color-slider-categories-card-body | var(--color-text) | Card description text |
--color-slider-categories-card-cta | var(--color-accent) | Card CTA link text |
--color-slider-categories-progress-track | var(--color-border) | Progress bar track |
--color-slider-categories-progress-fill | var(--color-primary) | Progress bar fill |
--color-slider-categories-cursor-bg | var(--color-primary) | Custom cursor background |
--color-slider-categories-cursor-text | #ffffff | Custom cursor text/icon |
Note: If the Figma designs show these mapping to existing global tokens (e.g.,
--color-primary,--color-accent), the module tokens can usevar()fallbacks to the global tokens. This allows per-theme override when needed while defaulting to the global palette.
6.3 Card Image Specification
Section titled “6.3 Card Image Specification”| Property | Odd Cards (Portrait) | Even Cards (Square) |
|---|---|---|
| Figma dimensions | 257 × 308px | 257 × 257px |
| Aspect ratio | ~1:1.2 | 1:1 |
| Object fit | cover | cover |
| Border radius | var(--radius-container-image) | var(--radius-container-image) |
| Overflow | hidden | hidden |
| Recommended upload (desktop) | 514 × 616 (retina) | 514 × 514 (retina) |
| Recommended upload (mobile) | 514 × 616 | 514 × 514 |
Implementation: Use a CSS class modifier or :nth-child(odd) / :nth-child(even) on the Swiper slides to alternate image container heights. The image itself always fills 100% width and uses object-fit: cover.
7. Interaction Specification
Section titled “7. Interaction Specification”7.1 SwiperJS Configuration
Section titled “7.1 SwiperJS Configuration”// Recommended Swiper config{ slidesPerView: 'auto', // Cards have fixed 257px width spaceBetween: 24, // 24px gap between cards freeMode: true, // Free scrolling, no snap-to-slide grabCursor: false, // We use custom cursor instead mousewheel: false, // Don't hijack scroll navigation: { nextEl: '.slider-categories__nav-next', prevEl: '.slider-categories__nav-prev', }, on: { progress: (swiper, progress) => { // Update progress bar position (0 = start, 1 = end) updateProgressBar(progress); }, },}Key Swiper behaviors:
slidesPerView: 'auto'— each slide sets its own width (257px via CSS)freeMode: true— smooth drag scrolling without snappingspaceBetween: 24— gap between slides managed by Swiper- Navigation via custom prev/next buttons (not Swiper’s default UI)
- Progress event fires on every scroll position change — use to animate progress bar
7.2 Category Filtering
Section titled “7.2 Category Filtering”| Trigger | Action |
|---|---|
| Click category | Set active category, swap slider content to that category’s cards, reset Swiper to position 0 |
| Keyboard (Enter/Space on focused category) | Same as click |
| Default state | First category is active on mount |
| Animation | Crossfade or instant swap (respect prefers-reduced-motion) |
Implementation:
- Store
activeCategoryindex in React state - On category change: update state → Swiper re-renders with new cards → call
swiper.slideTo(0)orswiper.update() - Progress bar resets to 0
- Category nav scrolls to keep active item visible (mobile horizontal scroll)
7.3 Custom Cursor Behavior
Section titled “7.3 Custom Cursor Behavior”| Event | Action |
|---|---|
mouseenter on slider area | Show custom cursor, hide native cursor (cursor: none) |
mousemove on slider area | Update cursor position via transform: translate(clientX, clientY) |
mouseleave on slider area | Hide custom cursor, restore native cursor |
| Touch device | Custom cursor is completely hidden and never rendered |
Implementation notes:
- Use a
<div>withposition: absolute,pointer-events: none,z-indexabove cards - Update position in
mousemovehandler (or viarequestAnimationFramefor smoothness) - Apply
will-change: transformfor GPU acceleration - Only render on devices with hover capability: wrap in
@media (hover: hover)or checkmatchMedia - Respect
prefers-reduced-motion: disable smooth follow, snap to position instead
7.4 Progress Bar Behavior
Section titled “7.4 Progress Bar Behavior”| State | Bar Width | Bar Position |
|---|---|---|
| At start (progress = 0) | Thumb at far left | translateX(0) |
| Scrolled halfway | Thumb at center | translateX(50%) of track |
| At end (progress = 1) | Thumb at far right | translateX(100%) of track |
Implementation:
- Track: full width of the controls area, 2px height, muted background
- Thumb/fill: width =
(visibleSlides / totalSlides) * 100%, clamped to min ~10% - Position driven by Swiper’s
progressevent (0–1 float) transform: translateX(${progress * (100 - thumbWidth)}%)for smooth movement- CSS
transition: transform 300ms easefor smooth follow
7.5 Keyboard Navigation
Section titled “7.5 Keyboard Navigation”| Key | Context | Action |
|---|---|---|
Tab | Category list | Focus moves through categories |
Enter / Space | Focused category | Activate category (same as click) |
ArrowUp / ArrowDown | Category list (desktop vertical) | Move focus between categories |
ArrowLeft / ArrowRight | Category list (mobile horizontal) | Move focus between categories |
Tab | Slider area | Focus moves to next focusable card CTA |
ArrowLeft / ArrowRight | Slider focused | Navigate slides (optional, or use nav buttons) |
7.6 ARIA Attributes
Section titled “7.6 ARIA Attributes”<!-- Category navigation --><nav class="slider-categories__nav" role="tablist" aria-label="Category filter"> <button role="tab" aria-selected="true" aria-controls="panel-rooms" id="tab-rooms"> Rooms </button> <button role="tab" aria-selected="false" aria-controls="panel-dining" id="tab-dining"> Dining </button></nav>
<!-- Slider panel --><div role="tabpanel" id="panel-rooms" aria-labelledby="tab-rooms" aria-live="polite" class="slider-categories__slider"> <!-- Swiper slides here --></div>8. BEM Class Map
Section titled “8. BEM Class Map”.slider-categories Block: root <section> element.slider-categories__container Element: inner max-width wrapper.slider-categories__header Element: centered header area (label + title).slider-categories__label Element: uppercase eyebrow text.slider-categories__title Element: main heading.slider-categories__body Element: two-column body area (nav + slider).slider-categories__nav Element: category navigation container.slider-categories__category Element: individual category item/button.slider-categories__category--active Modifier: active/selected category.slider-categories__slider Element: Swiper carousel container.slider-categories__slide Element: individual Swiper slide wrapper.slider-categories__slide--odd Modifier: odd card (portrait image).slider-categories__slide--even Modifier: even card (square image).slider-categories__card Element: card content wrapper.slider-categories__card-image-wrap Element: image container (aspect ratio + overflow).slider-categories__card-image Element: <img> or ResponsiveImage.slider-categories__card-content Element: text content below image.slider-categories__card-title Element: card heading.slider-categories__card-description Element: card body text.slider-categories__card-cta Element: card CTA link.slider-categories__controls Element: controls row (arrows + progress).slider-categories__nav-prev Element: previous arrow button.slider-categories__nav-next Element: next arrow button.slider-categories__progress Element: progress bar track.slider-categories__progress-fill Element: progress bar fill/thumb.slider-categories__cursor Element: custom circular cursor (desktop only)9. Storybook Requirements
Section titled “9. Storybook Requirements”Story title: Modules/M07 — Slider Categories
Parameters
Section titled “Parameters”parameters: { layout: 'fullscreen', design: { type: 'figma', url: 'https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1079-49968&m=dev', }, docs: { description: { component: [ '## Slider Categories', '', 'A content showcase module combining category navigation with a horizontal card carousel.', 'Editors define categories, each containing a set of cards that are filtered on selection.', '', '### Key Features', '- Vertical category navigation (desktop) / horizontal scrolling pills (mobile)', '- SwiperJS-powered horizontal card carousel with free scroll mode', '- Alternating tall/short card images (odd/even pattern) for visual rhythm', '- Custom circular cursor on desktop hover over slider area', '- Progress bar indicating scroll position relative to total items', '- Category filtering swaps the visible card set with smooth transition', '- Full keyboard navigation and ARIA tablist/tabpanel pattern', '- Touch swipe support on mobile via SwiperJS', '- Theme-driven image border-radius (square, rounded, asymmetric per theme)', '', '### Links', '- [Figma Desktop](https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1079-49968&m=dev)', '- [Figma Mobile](https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1083-8719&m=dev)', '- [Figma Savoy Palace](https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1704-29637&m=dev)', '- [Figma Calheta Beach](https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1704-32241&m=dev)', '- [Figma Hotel Next](https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1992-10991&m=dev)', '- [User Story](TBD)', ].join('\\n'), }, },}Required Story Variants
Section titled “Required Story Variants”| Story Name | Content | Purpose |
|---|---|---|
Default | 3 categories, 5–6 cards each, all props filled | Primary design state |
MinimalContent | 1 category, 2 cards with minimal data | Minimum viable content |
EmptyState | 1 category, 0 cards | Tests graceful empty state |
LongContent | 4 categories with long names, 8+ cards with long text | Tests overflow and wrapping |
AllOptions | 5 categories, rich content, every optional prop filled | Confirms all options work |
SingleCategory | 1 category (no nav visible), 6 cards | Tests layout without category nav |
ManyCards | 2 categories, 12+ cards each | Tests progress bar with many items |
WithInteraction | Default + play interaction hints | Shows category switching behavior |
ReducedMotion | Default wrapped in prefers-reduced-motion context | Shows non-animated behavior |
Mock Data (Realistic Hotel Content)
Section titled “Mock Data (Realistic Hotel Content)”const mockCategories: SliderCategory[] = [ { name: 'Rooms & Suites', cards: [ { imageDesktop: { url: '/storybook/room-suite-1.jpg', width: 514, height: 616 }, imageMobile: { url: '/storybook/room-suite-1-mobile.jpg', width: 514, height: 616 }, imageAlt: 'Deluxe Ocean View Suite with private balcony', title: { text: 'Deluxe Ocean Suite', html: 'h3' }, description: 'Spacious suite featuring panoramic Atlantic views and a private terrace.', cta: { label: 'Discover', href: '/rooms/deluxe-ocean-suite' }, }, { imageDesktop: { url: '/storybook/room-suite-2.jpg', width: 514, height: 514 }, imageMobile: { url: '/storybook/room-suite-2-mobile.jpg', width: 514, height: 514 }, imageAlt: 'Premium Garden Room with tropical garden access', title: { text: 'Premium Garden Room', html: 'h3' }, description: 'Elegant room surrounded by lush subtropical gardens.', cta: { label: 'Discover', href: '/rooms/premium-garden' }, }, // ... more cards following odd/even pattern ], }, { name: 'Dining', cards: [ { imageDesktop: { url: '/storybook/dining-1.jpg', width: 514, height: 616 }, imageMobile: { url: '/storybook/dining-1-mobile.jpg', width: 514, height: 616 }, imageAlt: 'Galáxia fine dining restaurant interior', title: { text: 'Galáxia Restaurant', html: 'h3' }, description: 'Michelin-inspired cuisine celebrating Madeiran flavours.', cta: { label: 'Reserve', href: '/dining/galaxia' }, }, // ... more dining cards ], }, { name: 'Wellness & Spa', cards: [ { imageDesktop: { url: '/storybook/spa-1.jpg', width: 514, height: 616 }, imageMobile: { url: '/storybook/spa-1-mobile.jpg', width: 514, height: 616 }, imageAlt: 'Laurea Spa hydrotherapy pool', title: { text: 'Laurea Spa', html: 'h3' }, description: 'A sanctuary of wellbeing with ocean-view treatment rooms.', cta: { label: 'Explore', href: '/spa/laurea' }, }, // ... more spa cards ], },];
const defaultArgs: SliderCategoriesProps = { label: { text: 'Explore', html: 'span' }, title: { text: '<strong>Discover</strong> our world', html: 'h2' }, categories: mockCategories, siteKey: 'savoy-palace', locale: 'pt',};argTypes Configuration
Section titled “argTypes Configuration”argTypes: { siteKey: { table: { disable: true } }, locale: { table: { disable: true } }, moduleId: { table: { disable: true } },},10. File Structure
Section titled “10. File Structure”packages/modules/src/m07-slider-categories/ index.ts Server Component wrapper (named exports) SliderCategories.client.tsx Client Component ('use client' — Swiper, state, interactions) SliderCategories.scss BEM + SASS styles SliderCategories.types.ts Props interface + supporting types SliderCategories.mapper.ts Umbraco JSON → component props SliderCategories.stories.tsx Storybook stories (9 variants) SliderCategories.test.tsx Vitest tests (mapper + rendering)This is an INTERACTIVE module — index.ts is the Server Component wrapper, SliderCategories.client.tsx contains all client-side logic.
Registry Entry
Section titled “Registry Entry”Add to packages/modules/src/registry.ts:
import { SliderCategories } from './m07-slider-categories';import { mapSliderCategories } from './m07-slider-categories/SliderCategories.mapper';
// In the moduleRegistry object:sliderCategories: { component: SliderCategories, mapper: mapSliderCategories as ModuleRegistryEntry["mapper"], moduleId: "M07" },Export (index.ts)
Section titled “Export (index.ts)”export { default as SliderCategories } from './SliderCategories.client';export type { SliderCategoriesProps, SliderCategory, SliderCategoryCard } from './SliderCategories.types';Server Wrapper (index.ts)
Section titled “Server Wrapper (index.ts)”import type { SliderCategoriesProps } from './SliderCategories.types';import SliderCategoriesClient from './SliderCategories.client';
export function SliderCategories(props: SliderCategoriesProps) { return <SliderCategoriesClient {...props} />;}Note: The Server Component wrapper passes all serializable props through. No functions, Date objects, or non-serializable data cross the boundary.
11. CMS Integration
Section titled “11. CMS Integration”11.1 Element Type Schema — sliderCategories
Section titled “11.1 Element Type Schema — sliderCategories”Parent Element Type: sliderCategories
Icon: icon-thumbnails
Description: “Category-filtered card slider with alternating image heights”
| Property | Alias | Editor | Required | Tab | Varies by Culture |
|---|---|---|---|---|---|
| Label | label | Single Html Heading Block List | No | Content | Yes |
| Title | title | Single Html Heading Block List | No | Content | Yes |
| Categories | categories | Block List (allows sliderCategoryItem) | Yes | Content | Yes |
Child Element Type: sliderCategoryItem
Icon: icon-folder
Description: “A single category containing a name and a set of cards”
| Property | Alias | Editor | Required | Tab | Varies by Culture |
|---|---|---|---|---|---|
| Category Name | categoryName | Textstring | Yes | Content | Yes |
| Cards | cards | Block List (allows sliderCategoryCard) | Yes | Content | Yes |
Child Element Type: sliderCategoryCard
Icon: icon-picture
Description: “A card within a slider category with image, title, text, and CTA”
| Property | Alias | Editor | Required | Tab | Varies by Culture |
|---|---|---|---|---|---|
| Title | title | Single Html Heading Block List | No | Content | Yes |
| Description | description | Textarea | No | Content | Yes |
| CTA Link | ctaLink | URL Picker | No | Content | Yes |
| Image Desktop | imageDesktop | Media Picker (Image) | Yes | Images | No |
| Image Mobile | imageMobile | Media Picker (Image) | Yes | Images | No |
| Image Alt Text | imageAlt | Textstring | Yes | Images | Yes |
11.2 Mapper Specification
Section titled “11.2 Mapper Specification”import { mapHtmlHeading } from '@savoy/cms-client';import type { SliderCategoriesProps, SliderCategory, SliderCategoryCard } from './SliderCategories.types';
export function mapSliderCategories( element: Record<string, unknown>): Omit<SliderCategoriesProps, 'siteKey' | 'locale'> { const p = element as Record<string, any>;
return { label: mapHtmlHeading(p.label), title: mapHtmlHeading(p.title), categories: (p.categories?.items || []).map((catItem: any) => { const cat = catItem.content?.properties ?? catItem.properties ?? catItem; return { name: cat.categoryName || '', cards: (cat.cards?.items || []).map((cardItem: any) => { const card = cardItem.content?.properties ?? cardItem.properties ?? cardItem; const ctaLink = card.ctaLink?.[0]; return { imageDesktop: { url: card.imageDesktop?.url ?? '', width: card.imageDesktop?.width ?? 514, height: card.imageDesktop?.height ?? 616, focalPoint: card.imageDesktop?.focalPoint ?? undefined, }, imageMobile: { url: card.imageMobile?.url ?? '', width: card.imageMobile?.width ?? 514, height: card.imageMobile?.height ?? 616, focalPoint: card.imageMobile?.focalPoint ?? undefined, }, imageAlt: card.imageAlt || '', title: mapHtmlHeading(card.title), description: card.description || undefined, cta: ctaLink ? { label: ctaLink.name || '', href: ctaLink.url || '' } : undefined, } as SliderCategoryCard; }), } as SliderCategory; }), };}11.3 Block List Thumbnail
Section titled “11.3 Block List Thumbnail”Create SVG at: apps/cms/Savoy.Cms/wwwroot/assets/thumbnails/m07-slider-categories.svg
- Format: SVG,
viewBox="0 0 400 250", wireframe style - Content: Left side shows vertical category list (3 stacked text lines with bullet), right side shows 3 staggered card placeholders (alternating heights) with progress bar below
- Palette:
#381e63(primary),#977e54(heading gold),#7d6946(accent),#ede9e2(background tint),#dcd4c6(card outlines)
11.4 Migration Notes
Section titled “11.4 Migration Notes”- Three Element Types to create:
sliderCategories,sliderCategoryItem,sliderCategoryCard sliderCategoriesuses a Block List forcategoriesthat allows onlysliderCategoryItemsliderCategoryItemuses a Block List forcardsthat allows onlysliderCategoryCardsliderCategoryCarduses “Single Html Heading Block List” fortitlefield- Both
categoriesandcardsBlock Lists should NOT useuseSingleBlockMode(they hold multiple items) titleandlabelon the parentsliderCategoriesuseuseSingleBlockMode: true
12. Theme Token Additions
Section titled “12. Theme Token Additions”Each theme CSS file needs the following tokens. These can reference existing global tokens with the ability to override per-theme:
_base.css (or each theme file)
Section titled “_base.css (or each theme file)”/* Slider Categories module tokens */--color-slider-categories-bg: var(--color-bg);--color-slider-categories-label: var(--color-accent);--color-slider-categories-heading: var(--color-primary);--color-slider-categories-card-title: var(--color-primary);--color-slider-categories-card-body: var(--color-text);--color-slider-categories-card-cta: var(--color-accent);--color-slider-categories-progress-track: var(--color-border);--color-slider-categories-progress-fill: var(--color-primary);--color-slider-categories-cursor-bg: var(--color-primary);--color-slider-categories-cursor-text: #ffffff;--color-slider-categories-cat-active: var(--color-primary);--color-slider-categories-cat-inactive: var(--color-text-muted);Note: If specific theme designs show different colors, override in the individual theme files. The
--radius-container-imagetoken is already defined per theme and reused for card image border-radius.
13. SwiperJS Integration Notes
Section titled “13. SwiperJS Integration Notes”Installation
Section titled “Installation”pnpm add swiper --filter @savoy/modulesImport Pattern (Client Component)
Section titled “Import Pattern (Client Component)”'use client';
import { Swiper, SwiperSlide } from 'swiper/react';import { FreeMode, Navigation } from 'swiper/modules';import type { Swiper as SwiperType } from 'swiper';
// Import Swiper stylesimport 'swiper/css';import 'swiper/css/free-mode';import 'swiper/css/navigation';Key Configuration
Section titled “Key Configuration”| Property | Value | Reason |
|---|---|---|
modules | [FreeMode, Navigation] | Enable free scroll + custom nav buttons |
slidesPerView | 'auto' | Cards set their own width (257px) |
spaceBetween | 24 | 24px gap between cards |
freeMode | true | Smooth drag, no snap |
grabCursor | false | Custom cursor replaces this |
navigation | { prevEl, nextEl } | Custom nav buttons |
onProgress | (swiper, progress) => ... | Drive progress bar |
onSlideChange | — | Optional: update active state |
breakpoints | — | Can adjust spaceBetween per breakpoint if needed |
Swiper State Management
Section titled “Swiper State Management”const [swiperInstance, setSwiperInstance] = useState<SwiperType | null>(null);const [progress, setProgress] = useState(0);const [activeCategory, setActiveCategory] = useState(0);
// On category change:const handleCategoryChange = (index: number) => { setActiveCategory(index); // Swiper re-renders with new slides; reset position swiperInstance?.slideTo(0, 0); // instant reset setProgress(0);};
// On Swiper progress:const handleProgress = (_swiper: SwiperType, progressValue: number) => { setProgress(Math.max(0, Math.min(1, progressValue)));};14. Development Workflow
Section titled “14. Development Workflow”Phase A — Storybook
Section titled “Phase A — Storybook”- Install SwiperJS —
pnpm add swiper --filter @savoy/modules - Scaffold files — Create all 7 files in
packages/modules/src/m07-slider-categories/ - Define types — Write
SliderCategories.types.tsfirst (copy from Section 4) - Write stories — Write
SliderCategories.stories.tsxwith all 9 variants and realistic mock data (Section 9) - Implement component — Build
SliderCategories.client.tsx(Client Component) +SliderCategories.scss - Create server wrapper — Write
index.tsas Server Component wrapper - Add theme tokens — Add CSS custom properties from Section 12 to all theme files (or
_base.css) - Validate in Storybook — Check all stories across all 8 themes at 375px, 768px, 1024px, 1440px
Phase B — React / Next.js
Section titled “Phase B — React / Next.js”- Write mapper —
SliderCategories.mapper.ts(Section 11.2) - Write tests —
SliderCategories.test.tsxcovering mapper transformation and component rendering - Register — Add entry to
packages/modules/src/registry.ts - Export — Verify
index.tswith named exports
Phase C — Umbraco / CMS
Section titled “Phase C — Umbraco / CMS”- Define Element Type schemas — 3 Element Types per Section 11.1 (
sliderCategories,sliderCategoryItem,sliderCategoryCard) - Create SVG thumbnail —
apps/cms/Savoy.Cms/wwwroot/assets/thumbnails/m07-slider-categories.svg - Create Element Types — In Umbraco backoffice (migration or manual)
- Configure Block Lists — Nested Block Lists: parent allows
sliderCategoryItem, child allowssliderCategoryCard - Add to page Document Types — Allow
sliderCategoriesblock on target pages - Create test content — Verify Content Delivery API output with nested structure
- Validate mapper — Test mapper against real API JSON (nested Block List items)
- Test multi-site — Verify in savoy-signature + savoy-palace (minimum)
- Verify cache purge — Confirm webhook fires on publish
15. Validation Criteria
Section titled “15. Validation Criteria”Pixel-Perfect Match
Section titled “Pixel-Perfect Match”- Desktop: Two-column body with category nav (~282px) on left and slider on right
- Desktop: Header centered above body area with label + title
- Desktop: 80px vertical padding, 120px horizontal padding
- Desktop: Inner content 1200px max-width, centered
- Desktop: Cards are 257px wide with 24px gaps
- Desktop: Odd cards have portrait images (257×308), even cards have square images (257×257)
- Desktop: Cards are bottom-aligned — staggered visual rhythm
- Desktop: Category nav shows active state (bold + accent color + bullet indicator)
- Desktop: Progress bar below slider reflects scroll position
- Desktop: Navigation arrows are visible and functional
- Desktop: Custom circular cursor appears on hover over slider area
- Mobile: Full-width stacked layout — header, then horizontal categories, then slider, then controls
- Mobile: Categories become horizontal scrolling pills
- Mobile: Cards maintain 257px width, swipeable
- Mobile: No custom cursor on touch devices
- Mobile: 64px vertical padding, 24px horizontal padding
Interactive Behavior
Section titled “Interactive Behavior”- Clicking a category filters slider to show that category’s cards
- Category change resets slider to position 0
- Progress bar updates smoothly during slider drag/scroll
- Progress bar resets on category change
- Custom cursor follows mouse smoothly on slider hover (desktop only)
- Custom cursor fades in on enter, fades out on leave
- Native cursor is hidden when custom cursor is visible
- Navigation arrows scroll the slider left/right
- Navigation arrows show disabled state at start/end boundaries
- Touch swipe works on mobile (SwiperJS native)
Responsive Behaviour
Section titled “Responsive Behaviour”- Smooth transition from stacked (mobile) to side-by-side (desktop) at md breakpoint (768px)
- Category nav transitions from horizontal pills to vertical list at md breakpoint
- No horizontal scroll at any viewport width from 320px to 2560px (outside the slider area)
- Content does not clip at intermediate widths
- Custom cursor only appears on hover-capable devices
Theme Compatibility
Section titled “Theme Compatibility”- Renders correctly with
data-theme="savoy-signature"(square card images) - Renders correctly with
data-theme="savoy-palace"(square card images) - Renders correctly with
data-theme="royal-savoy"(square card images) - Renders correctly with
data-theme="saccharum"(square card images) - Renders correctly with
data-theme="the-reserve"(square card images) - Renders correctly with
data-theme="calheta-beach"(rounded card images ~24px) - Renders correctly with
data-theme="gardens"(square card images) - Renders correctly with
data-theme="hotel-next"(asymmetric top-left corner ~100px) - All text colours correct per theme
Accessibility
Section titled “Accessibility”- Category navigation uses
role="tablist"+role="tab"pattern - Slider content area uses
role="tabpanel"linked to active tab -
aria-selectedupdates on active category -
aria-live="polite"on tabpanel for content changes - Keyboard navigation works: Tab through categories, Enter/Space to activate
- All card images have descriptive
alttext - Card CTAs are keyboard-accessible (
<a>elements) - Visible
:focus-visibleon all interactive elements -
prefers-reduced-motionrespected: disable smooth cursor follow, disable slide animations
Performance
Section titled “Performance”- Server wrapper passes only serializable props to client
-
'use client'only in.client.tsx - SwiperJS imported only in client component
- Images use
ResponsiveImagefrom@savoy/uiwith desktop + mobile sources - Images do NOT have
priority={true}(not hero/LCP) - No layout shift from image loading (
aspect-ratioset in CSS) - CSS uses only custom properties — no hardcoded values
- Custom cursor uses
will-change: transformfor GPU acceleration - SwiperJS tree-shakes unused modules
16. SCSS Implementation Guide
Section titled “16. SCSS Implementation Guide”@use '../mixins' as *;
.slider-categories { padding-block: var(--space-16); // 64px mobile padding-inline: var(--space-6); // 24px mobile background-color: var(--color-slider-categories-bg);
@media (min-width: 768px) { padding-block: var(--space-20); // 80px padding-inline: 120px; }
// Container &__container { @include container; }
// Header (centered) &__header { display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--space-4); margin-bottom: var(--space-12);
@media (min-width: 768px) { gap: var(--space-6); margin-bottom: var(--space-16); max-width: 792px; margin-inline: auto; } }
// Label &__label { @include label-text(--color-slider-categories-label); }
// Title &__title { @include section-heading(--color-slider-categories-heading); }
// Body (two-column on desktop) &__body { display: flex; flex-direction: column; gap: var(--space-8);
@media (min-width: 768px) { flex-direction: row; gap: var(--space-10); } }
// Category navigation &__nav { display: flex; flex-direction: row; gap: var(--space-3); overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; // Firefox &::-webkit-scrollbar { display: none; } // Chrome/Safari
@media (min-width: 768px) { flex-direction: column; width: 282px; flex-shrink: 0; overflow-x: visible; gap: 0; } }
// Category item &__category { appearance: none; border: none; background: none; padding: var(--space-2) var(--space-4); white-space: nowrap; font-family: var(--font-body); font-size: var(--text-sm); font-weight: var(--font-weight-normal); color: var(--color-slider-categories-cat-inactive); cursor: pointer; transition: color var(--transition-fast), font-weight var(--transition-fast);
@media (min-width: 768px) { font-size: var(--text-base); padding: var(--space-3) 0; text-align: left; }
&--active { font-weight: var(--font-weight-bold); color: var(--color-slider-categories-cat-active); }
&:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } }
// Slider area &__slider { flex: 1; min-width: 0; overflow: hidden; position: relative;
@media (hover: hover) { cursor: none; // Hide native cursor when custom cursor is active } }
// Swiper slide &__slide { width: 257px !important; // Override Swiper's auto width calculation display: flex; flex-direction: column; align-self: flex-end; // Bottom-align cards
&--odd &__card-image-wrap { aspect-ratio: 257 / 308; // Portrait }
&--even &__card-image-wrap { aspect-ratio: 1; // Square } }
// Card &__card { display: flex; flex-direction: column; gap: var(--space-4); }
// Card image wrapper &__card-image-wrap { width: 100%; overflow: hidden; border-radius: var(--radius-container-image, 0);
img { width: 100%; height: 100%; object-fit: cover; } }
// Card content &__card-content { display: flex; flex-direction: column; gap: var(--space-2); }
// Card title &__card-title { font-family: var(--font-heading); font-size: var(--text-lg); font-weight: var(--font-weight-medium); color: var(--color-slider-categories-card-title); margin: 0;
@media (min-width: 768px) { font-size: var(--text-xl); } }
// Card description &__card-description { font-family: var(--font-body); font-size: var(--text-sm); font-weight: var(--font-weight-normal); color: var(--color-slider-categories-card-body); line-height: var(--leading-normal); margin: 0; }
// Card CTA &__card-cta { @include cta-secondary(--color-slider-categories-card-cta); font-size: var(--text-sm); text-transform: uppercase; }
// Controls row &__controls { display: flex; align-items: center; gap: var(--space-4); margin-top: var(--space-6); }
// Nav arrows &__nav-prev, &__nav-next { appearance: none; border: 1px solid var(--color-border); background: transparent; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: opacity var(--transition-fast); flex-shrink: 0;
&:disabled, &.swiper-button-disabled { opacity: 0.3; cursor: default; }
&:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } }
// Progress bar &__progress { flex: 1; height: 2px; background-color: var(--color-slider-categories-progress-track); position: relative; overflow: hidden; border-radius: 1px; }
&__progress-fill { position: absolute; top: 0; left: 0; height: 100%; background-color: var(--color-slider-categories-progress-fill); border-radius: 1px; transition: transform 300ms ease; will-change: transform; }
// Custom cursor (desktop only) &__cursor { display: none;
@media (hover: hover) { display: block; position: absolute; width: 80px; height: 80px; border-radius: 50%; background-color: var(--color-slider-categories-cursor-bg); color: var(--color-slider-categories-cursor-text); font-family: var(--font-body); font-size: var(--text-sm); font-weight: var(--font-weight-bold); display: flex; align-items: center; justify-content: center; pointer-events: none; z-index: 10; opacity: 0; transition: opacity var(--transition-fast); will-change: transform; transform: translate(-50%, -50%);
.slider-categories__slider:hover & { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { transition: none; } }}17. Open Questions for Design Team
Section titled “17. Open Questions for Design Team”- Custom cursor content: Does the circular cursor contain text (e.g., “Drag”, “Explore”) or an arrow icon? What’s the exact size and opacity?
- Category active indicator: Is it a bullet/dot, a line, or just bold text with color change? What’s the exact styling?
- Card CTA style: Is it an underlined text link, a button, or an arrow link? Confirm the exact CTA pattern.
- Progress bar thumb size: Is the fill a fixed-width thumb that moves, or a fill that grows from left to right?
- Category transition animation: When switching categories, do cards crossfade, slide out/in, or instant swap?
- Decorative watermark/symbol: Is the background watermark visible in the header area? Is it theme-specific? How should it be implemented (CSS background-image, SVG, etc.)?
- Card hover state: Besides the custom cursor, is there any hover effect on individual cards (scale, shadow, opacity)?
- Exact theme colors: Confirm colors for Royal Savoy, The Reserve, Gardens, and Saccharum if they differ from the default palette.
- Number of visible cards: At 1440px, how many cards should be fully visible before the overflow? (~3.5 based on 257px width + gaps)
18. Common Pitfalls to Avoid
Section titled “18. Common Pitfalls to Avoid”- Hardcoding colours, fonts, or spacing — use
var(--token-name)frompackages/themes/src/ - Forgetting
imageMobile— every card image needs both desktop and mobile variants - Mapper crash on null — Umbraco sends
nullfor optional fields; always use?.and fallbacks - Wrong BEM nesting — use
&__elementinside the block, not standalone.slider-categories__element - Missing registry entry — module will not render on any page without it
- Desktop-first media queries — use
min-width(mobile-first), NEVERmax-width - Hardcoding image heights — use
aspect-ratiofor odd/even pattern, not fixed pixel heights - Lorem ipsum in stories — use realistic hotel context (room names, restaurant descriptions)
- Putting
'use client'inindex.ts— only in.client.tsx - Non-serializable props crossing Server-Client boundary — no functions, Date objects, Map/Set
- Missing keyboard navigation — categories need Tab + Enter/Space; slider needs arrow keys
- Missing
aria-selectedupdate on category tab buttons - No
prefers-reduced-motionhandling — disable smooth cursor follow, slider animations - Hardcoding border-radius for card images — MUST use
var(--radius-container-image)token - Hardcoding Swiper animations — use Swiper’s built-in
freeMode,navigation,progressevent instead of manual scroll/animation logic - Forgetting to reset Swiper on category change — call
swiper.slideTo(0)and reset progress - Custom cursor on mobile — must be hidden on touch devices (use
@media (hover: hover)) - Missing
data-module/data-module-idattributes — root<section>MUST havedata-module="sliderCategories"anddata-module-id="M07" - Forgetting to add tokens to ALL 8 theme files — or use
_base.csswith global token fallbacks - Nested Block List mapper — Umbraco nests content inside
items[].content.properties; navigate the full path