Skip to content

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)

FieldValue
Module IDM07
Module NameSlider Categories
Folderm07-slider-categories
PascalCaseSliderCategories
camelCase (Registry Key / Umbraco Alias)sliderCategories
BEM Block.slider-categories
Component TypeInteractive (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:

VariantURL
Desktop (Default / Savoy Signature)https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1079-49968&m=dev
Mobilehttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1083-8719&m=dev
Savoy Palace Themehttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1704-29637&m=dev
Calheta Beach Themehttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1704-32241&m=dev
Hotel Next Themehttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1992-10991&m=dev

User Story / Task: TBD (Zoho Project)


  • 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)

The module has three distinct visual zones:

  1. Header — Centered label (eyebrow) + title above the body content
  2. Body — Two-column layout: category navigation (left) + card carousel (right)
  3. Controls — Navigation arrows + progress bar below the carousel

Within each category’s card set, cards alternate between two image heights:

Card IndexImage TypeImage Dimensions (Figma)Aspect Ratio
Odd (1st, 3rd, 5th…)Portrait/tall257 × 308px~1:1.2
Even (2nd, 4th, 6th…)Square/short257 × 257px1: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.

The primary visual differentiation across themes is:

ThemeImage Border RadiusNotes
Savoy Signature0Square corners
Savoy Palace0Square corners
Royal Savoy0Square corners
Saccharum0Square corners
The Reserve0Square corners
Calheta Beach~24pxRounded corners
Gardens0Square corners
Hotel Next100px 0 0 0Large 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).

The module uses a light/white background (var(--color-bg) or var(--color-surface)). No background variant toggle — single variant.


SliderCategories.types.ts
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;
}
FieldRequiredDefaultNotes
labelNoundefinedUppercase eyebrow text (HtmlHeading from CMS)
titleNoundefinedMain heading (HtmlHeading from CMS)
categoriesYesAt least 1 category with cards
categories[].nameYesCategory label for navigation
categories[].cardsYesArray of cards (minimum 1)
cards[].imageDesktopYesDesktop image with url, width, height
cards[].imageMobileYesMobile image with url, width, height
cards[].imageAltYesDescriptive alt text
cards[].titleNoundefinedCard heading (HtmlHeading)
cards[].descriptionNoundefinedCard body text
cards[].ctaNoundefinedCard CTA with label + href
siteKeyYesInjected by ModuleRenderer
localeYesInjected by ModuleRenderer
moduleIdNoundefinedInjected by page renderer

+------------------------------------------------------------------------+
| 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 | | |
| | +-----------------------------------+ | |
| +------------------------------------------------------------------+ |
+------------------------------------------------------------------------+
PropertyValueCSS Token / Implementation
Section vertical padding80px top and bottompadding-block: var(--space-20)
Section horizontal padding120pxpadding-inline: 120px
Inner container max-width1200pxmax-width: var(--container-max, 1200px)
Inner container centeringCenteredmargin-inline: auto
Header width~792px centeredmax-width: 792px; margin-inline: auto; text-align: center
Header-to-body gap48–64pxgap: var(--space-12) to var(--space-16)
Body layoutFlex rowdisplay: flex; flex-direction: row
Category nav width~282px fixedwidth: 282px; flex-shrink: 0
Category-to-slider gap~40pxgap: var(--space-10)
Slider areaFills remaining space, overflow hiddenflex: 1; min-width: 0; overflow: hidden

Header Internal Spacing:

ElementSpacingToken
Label to title gap16–24pxgap: var(--space-4) to var(--space-6)
PropertyValueImplementation
LayoutVertical listdisplay: flex; flex-direction: column
Gap between items0Items separated by borders/dividers or tight spacing
Active indicatorBullet/dot or bold text + accent color.slider-categories__category--active
Item padding12–16px verticalpadding-block: var(--space-3) to var(--space-4)
Font familyBody fontvar(--font-body)
Font size16pxvar(--text-base)
Font weight (inactive)Normal (400)var(--font-weight-normal)
Font weight (active)Bold (700)var(--font-weight-bold)
Text color (inactive)Muted textvar(--color-text-muted)
Text color (active)Primary/accentvar(--color-primary) or var(--color-accent)
CursorPointercursor: pointer
Active indicator styleSmall bullet/dot before text&::before pseudo-element or dedicated element
PropertyValueImplementation
Card width257px fixedwidth: 257px; flex-shrink: 0
Card gap (between cards)24pxSwiper spaceBetween: 24
Odd card image height308pxheight: 308px (portrait)
Even card image height257pxheight: 257px (square)
Image widthFull card width (257px)width: 100%
Image object-fitcoverobject-fit: cover
Image border-radiusTheme-drivenborder-radius: var(--radius-container-image, 0)
Card vertical alignmentBottom-alignedalign-self: flex-end on each card in the Swiper
Title fontHeading font, ~20pxfont-family: var(--font-heading); font-size: var(--text-xl)
Title colorHeading colorvar(--color-slider-categories-heading)
Description fontBody font, 14–16pxfont-family: var(--font-body); font-size: var(--text-sm)
Description colorBody/muted textvar(--color-slider-categories-body)
CTA styleUnderlined text linktext-decoration: underline — use @include cta-secondary(...) mixin
Image-to-title gap16pxgap: var(--space-4)
Title-to-description gap8pxgap: var(--space-2)
Description-to-CTA gap12pxgap: var(--space-3)
PropertyValueImplementation
PositionBelow slider, right-aligned with slider areaAligned under the carousel
Height2–3pxheight: 2px
Track backgroundLight border/mutedvar(--color-border) or rgba(0,0,0,0.1)
Fill/thumb colorPrimary/accentvar(--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(...)
TransitionSmooth followtransition: transform 300ms ease
PropertyValueImplementation
PositionLeft of progress bar or inline with controls rowFlex row with arrows + progress bar
Size~40px circle or icon-onlywidth: 40px; height: 40px
StyleMinimal, outline or ghostBorder or transparent background with icon
Disabled stateReduced opacity when at start/endopacity: 0.3; cursor: default
IconsLeft arrow / Right arrowSVG icons or characters
PropertyValueImplementation
Trigger areaOn hover over the entire slider/carousel areaApplied to .slider-categories__slider on desktop only
ShapeCircleCircular div following mouse position
Size~80px diameterwidth: 80px; height: 80px; border-radius: 50%
AppearanceSemi-transparent or solid with text/iconBackground with “Drag” or arrow icon
BehaviorFollows mouse cursor position with slight delaytransform: translate(...) updated via mousemove event
Native cursorHidden when custom cursor visiblecursor: none on the slider area
MobileHidden (no custom cursor on touch)Only active on @media (hover: hover) or desktop viewport
ImplementationAbsolute-positioned <div> inside slider container, pointer-events: noneUpdated via requestAnimationFrame or mousemove handler
Entrance/exitFade in on mouseenter, fade out on mouseleaveopacity transition with var(--transition-fast)
+------------------------------------+
| 24px padding 24px |
| +------------------------------+ |
| | LABEL (uppercase, centered) | |
| | TITLE (centered) | |
| | | |
| | [Cat 1] [Cat 2] [Cat 3] → | |
| | (horizontal scroll) | |
| | | |
| | [Card] [Card] [Card] → | |
| | (horizontal swipe) | |
| | | |
| | ← → ████░░░░░░░ bar | |
| +------------------------------+ |
+------------------------------------+
PropertyMobile ValueImplementation
Section padding64px vertical, 24px horizontalpadding-block: var(--space-16); padding-inline: var(--space-6)
LayoutSingle column, stackedflex-direction: column
HeaderFull width, centeredtext-align: center
Category navHorizontal scrolling rowdisplay: flex; flex-direction: row; overflow-x: auto; gap: var(--space-4)
Category itemsPill-shaped buttons or inline tabswhite-space: nowrap; padding: var(--space-2) var(--space-4)
Card width257px (fixed, same as desktop)Cards remain fixed width, fewer visible
Custom cursorHiddenNo custom cursor on touch devices
ControlsArrows + progress bar below sliderSame as desktop but full-width
SwipeTouch swipe left/rightSwiperJS handles touch natively
BreakpointLayout 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.

ElementFont FamilySize (Desktop)Size (Mobile)WeightLine HeightColour TokenExtra
Module Labelvar(--font-body)var(--text-base) (16px)var(--text-sm) (14px)var(--font-weight-bold) (700)20pxvar(--color-slider-categories-label)text-transform: uppercase; letter-spacing: 0.05em
Module Titlevar(--font-heading)var(--text-5xl) (48px)var(--text-3xl) (30px)var(--font-weight-medium) (500)1.1var(--color-slider-categories-heading)
Category Name (inactive)var(--font-body)var(--text-base) (16px)var(--text-sm) (14px)var(--font-weight-normal) (400)1.5var(--color-text-muted)
Category Name (active)var(--font-body)var(--text-base) (16px)var(--text-sm) (14px)var(--font-weight-bold) (700)1.5var(--color-primary)Active indicator (bullet/dot)
Card Titlevar(--font-heading)var(--text-xl) (20px)var(--text-lg) (18px)var(--font-weight-medium) (500)1.3var(--color-slider-categories-card-title)
Card Descriptionvar(--font-body)var(--text-sm) (14px)var(--text-sm) (14px)var(--font-weight-normal) (400)1.5var(--color-slider-categories-card-body)Max 2–3 lines recommended
Card CTAvar(--font-body)var(--text-sm) (14px)var(--text-sm) (14px)var(--font-weight-bold) (700)1.5var(--color-slider-categories-card-cta)text-decoration: underline; text-transform: uppercase

Every theme file must define these tokens. Values below are from the Savoy Signature/Palace Figma:

TokenDefault ValuePurpose
--color-slider-categories-bgvar(--color-bg)Module background
--color-slider-categories-labelvar(--color-accent)Label/eyebrow text
--color-slider-categories-headingvar(--color-primary)Module title text
--color-slider-categories-card-titlevar(--color-primary)Card title text
--color-slider-categories-card-bodyvar(--color-text)Card description text
--color-slider-categories-card-ctavar(--color-accent)Card CTA link text
--color-slider-categories-progress-trackvar(--color-border)Progress bar track
--color-slider-categories-progress-fillvar(--color-primary)Progress bar fill
--color-slider-categories-cursor-bgvar(--color-primary)Custom cursor background
--color-slider-categories-cursor-text#ffffffCustom 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 use var() fallbacks to the global tokens. This allows per-theme override when needed while defaulting to the global palette.

PropertyOdd Cards (Portrait)Even Cards (Square)
Figma dimensions257 × 308px257 × 257px
Aspect ratio~1:1.21:1
Object fitcovercover
Border radiusvar(--radius-container-image)var(--radius-container-image)
Overflowhiddenhidden
Recommended upload (desktop)514 × 616 (retina)514 × 514 (retina)
Recommended upload (mobile)514 × 616514 × 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.


// 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 snapping
  • spaceBetween: 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
TriggerAction
Click categorySet 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 stateFirst category is active on mount
AnimationCrossfade or instant swap (respect prefers-reduced-motion)

Implementation:

  1. Store activeCategory index in React state
  2. On category change: update state → Swiper re-renders with new cards → call swiper.slideTo(0) or swiper.update()
  3. Progress bar resets to 0
  4. Category nav scrolls to keep active item visible (mobile horizontal scroll)
EventAction
mouseenter on slider areaShow custom cursor, hide native cursor (cursor: none)
mousemove on slider areaUpdate cursor position via transform: translate(clientX, clientY)
mouseleave on slider areaHide custom cursor, restore native cursor
Touch deviceCustom cursor is completely hidden and never rendered

Implementation notes:

  • Use a &lt;div&gt; with position: absolute, pointer-events: none, z-index above cards
  • Update position in mousemove handler (or via requestAnimationFrame for smoothness)
  • Apply will-change: transform for GPU acceleration
  • Only render on devices with hover capability: wrap in @media (hover: hover) or check matchMedia
  • Respect prefers-reduced-motion: disable smooth follow, snap to position instead
StateBar WidthBar Position
At start (progress = 0)Thumb at far lefttranslateX(0)
Scrolled halfwayThumb at centertranslateX(50%) of track
At end (progress = 1)Thumb at far righttranslateX(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 progress event (0–1 float)
  • transform: translateX(${progress * (100 - thumbWidth)}%) for smooth movement
  • CSS transition: transform 300ms ease for smooth follow
KeyContextAction
TabCategory listFocus moves through categories
Enter / SpaceFocused categoryActivate category (same as click)
ArrowUp / ArrowDownCategory list (desktop vertical)Move focus between categories
ArrowLeft / ArrowRightCategory list (mobile horizontal)Move focus between categories
TabSlider areaFocus moves to next focusable card CTA
ArrowLeft / ArrowRightSlider focusedNavigate slides (optional, or use nav buttons)
<!-- 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>

.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)

Story title: Modules/M07 — Slider Categories

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'),
},
},
}
Story NameContentPurpose
Default3 categories, 5–6 cards each, all props filledPrimary design state
MinimalContent1 category, 2 cards with minimal dataMinimum viable content
EmptyState1 category, 0 cardsTests graceful empty state
LongContent4 categories with long names, 8+ cards with long textTests overflow and wrapping
AllOptions5 categories, rich content, every optional prop filledConfirms all options work
SingleCategory1 category (no nav visible), 6 cardsTests layout without category nav
ManyCards2 categories, 12+ cards eachTests progress bar with many items
WithInteractionDefault + play interaction hintsShows category switching behavior
ReducedMotionDefault wrapped in prefers-reduced-motion contextShows non-animated behavior
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: {
siteKey: { table: { disable: true } },
locale: { table: { disable: true } },
moduleId: { table: { disable: true } },
},

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 moduleindex.ts is the Server Component wrapper, SliderCategories.client.tsx contains all client-side logic.

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 { default as SliderCategories } from './SliderCategories.client';
export type { SliderCategoriesProps, SliderCategory, SliderCategoryCard } from './SliderCategories.types';
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.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”

PropertyAliasEditorRequiredTabVaries by Culture
LabellabelSingle Html Heading Block ListNoContentYes
TitletitleSingle Html Heading Block ListNoContentYes
CategoriescategoriesBlock List (allows sliderCategoryItem)YesContentYes

Child Element Type: sliderCategoryItem Icon: icon-folder Description: “A single category containing a name and a set of cards”

PropertyAliasEditorRequiredTabVaries by Culture
Category NamecategoryNameTextstringYesContentYes
CardscardsBlock List (allows sliderCategoryCard)YesContentYes

Child Element Type: sliderCategoryCard Icon: icon-picture Description: “A card within a slider category with image, title, text, and CTA”

PropertyAliasEditorRequiredTabVaries by Culture
TitletitleSingle Html Heading Block ListNoContentYes
DescriptiondescriptionTextareaNoContentYes
CTA LinkctaLinkURL PickerNoContentYes
Image DesktopimageDesktopMedia Picker (Image)YesImagesNo
Image MobileimageMobileMedia Picker (Image)YesImagesNo
Image Alt TextimageAltTextstringYesImagesYes
SliderCategories.mapper.ts
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;
}),
};
}

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)
  • Three Element Types to create: sliderCategories, sliderCategoryItem, sliderCategoryCard
  • sliderCategories uses a Block List for categories that allows only sliderCategoryItem
  • sliderCategoryItem uses a Block List for cards that allows only sliderCategoryCard
  • sliderCategoryCard uses “Single Html Heading Block List” for title field
  • Both categories and cards Block Lists should NOT use useSingleBlockMode (they hold multiple items)
  • title and label on the parent sliderCategories use useSingleBlockMode: true

Each theme CSS file needs the following tokens. These can reference existing global tokens with the ability to override per-theme:

/* 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-image token is already defined per theme and reused for card image border-radius.


Terminal window
pnpm add swiper --filter @savoy/modules
'use client';
import { Swiper, SwiperSlide } from 'swiper/react';
import { FreeMode, Navigation } from 'swiper/modules';
import type { Swiper as SwiperType } from 'swiper';
// Import Swiper styles
import 'swiper/css';
import 'swiper/css/free-mode';
import 'swiper/css/navigation';
PropertyValueReason
modules[FreeMode, Navigation]Enable free scroll + custom nav buttons
slidesPerView'auto'Cards set their own width (257px)
spaceBetween2424px gap between cards
freeModetrueSmooth drag, no snap
grabCursorfalseCustom cursor replaces this
navigation{ prevEl, nextEl }Custom nav buttons
onProgress(swiper, progress) => ...Drive progress bar
onSlideChangeOptional: update active state
breakpointsCan adjust spaceBetween per breakpoint if needed
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)));
};

  1. Install SwiperJSpnpm add swiper --filter @savoy/modules
  2. Scaffold files — Create all 7 files in packages/modules/src/m07-slider-categories/
  3. Define types — Write SliderCategories.types.ts first (copy from Section 4)
  4. Write stories — Write SliderCategories.stories.tsx with all 9 variants and realistic mock data (Section 9)
  5. Implement component — Build SliderCategories.client.tsx (Client Component) + SliderCategories.scss
  6. Create server wrapper — Write index.ts as Server Component wrapper
  7. Add theme tokens — Add CSS custom properties from Section 12 to all theme files (or _base.css)
  8. Validate in Storybook — Check all stories across all 8 themes at 375px, 768px, 1024px, 1440px
  1. Write mapperSliderCategories.mapper.ts (Section 11.2)
  2. Write testsSliderCategories.test.tsx covering mapper transformation and component rendering
  3. Register — Add entry to packages/modules/src/registry.ts
  4. Export — Verify index.ts with named exports
  1. Define Element Type schemas — 3 Element Types per Section 11.1 (sliderCategories, sliderCategoryItem, sliderCategoryCard)
  2. Create SVG thumbnailapps/cms/Savoy.Cms/wwwroot/assets/thumbnails/m07-slider-categories.svg
  3. Create Element Types — In Umbraco backoffice (migration or manual)
  4. Configure Block Lists — Nested Block Lists: parent allows sliderCategoryItem, child allows sliderCategoryCard
  5. Add to page Document Types — Allow sliderCategories block on target pages
  6. Create test content — Verify Content Delivery API output with nested structure
  7. Validate mapper — Test mapper against real API JSON (nested Block List items)
  8. Test multi-site — Verify in savoy-signature + savoy-palace (minimum)
  9. Verify cache purge — Confirm webhook fires on publish

  • 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
  • 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)
  • 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
  • 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
  • Category navigation uses role="tablist" + role="tab" pattern
  • Slider content area uses role="tabpanel" linked to active tab
  • aria-selected updates 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 alt text
  • Card CTAs are keyboard-accessible (&lt;a&gt; elements)
  • Visible :focus-visible on all interactive elements
  • prefers-reduced-motion respected: disable smooth cursor follow, disable slide animations
  • Server wrapper passes only serializable props to client
  • 'use client' only in .client.tsx
  • SwiperJS imported only in client component
  • Images use ResponsiveImage from @savoy/ui with desktop + mobile sources
  • Images do NOT have priority={true} (not hero/LCP)
  • No layout shift from image loading (aspect-ratio set in CSS)
  • CSS uses only custom properties — no hardcoded values
  • Custom cursor uses will-change: transform for GPU acceleration
  • SwiperJS tree-shakes unused modules

SliderCategories.scss
@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;
}
}
}

  1. Custom cursor content: Does the circular cursor contain text (e.g., “Drag”, “Explore”) or an arrow icon? What’s the exact size and opacity?
  2. Category active indicator: Is it a bullet/dot, a line, or just bold text with color change? What’s the exact styling?
  3. Card CTA style: Is it an underlined text link, a button, or an arrow link? Confirm the exact CTA pattern.
  4. Progress bar thumb size: Is the fill a fixed-width thumb that moves, or a fill that grows from left to right?
  5. Category transition animation: When switching categories, do cards crossfade, slide out/in, or instant swap?
  6. 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.)?
  7. Card hover state: Besides the custom cursor, is there any hover effect on individual cards (scale, shadow, opacity)?
  8. Exact theme colors: Confirm colors for Royal Savoy, The Reserve, Gardens, and Saccharum if they differ from the default palette.
  9. Number of visible cards: At 1440px, how many cards should be fully visible before the overflow? (~3.5 based on 257px width + gaps)

  1. Hardcoding colours, fonts, or spacing — use var(--token-name) from packages/themes/src/
  2. Forgetting imageMobile — every card image needs both desktop and mobile variants
  3. Mapper crash on null — Umbraco sends null for optional fields; always use ?. and fallbacks
  4. Wrong BEM nesting — use &__element inside the block, not standalone .slider-categories__element
  5. Missing registry entry — module will not render on any page without it
  6. Desktop-first media queries — use min-width (mobile-first), NEVER max-width
  7. Hardcoding image heights — use aspect-ratio for odd/even pattern, not fixed pixel heights
  8. Lorem ipsum in stories — use realistic hotel context (room names, restaurant descriptions)
  9. Putting 'use client' in index.ts — only in .client.tsx
  10. Non-serializable props crossing Server-Client boundary — no functions, Date objects, Map/Set
  11. Missing keyboard navigation — categories need Tab + Enter/Space; slider needs arrow keys
  12. Missing aria-selected update on category tab buttons
  13. No prefers-reduced-motion handling — disable smooth cursor follow, slider animations
  14. Hardcoding border-radius for card images — MUST use var(--radius-container-image) token
  15. Hardcoding Swiper animations — use Swiper’s built-in freeMode, navigation, progress event instead of manual scroll/animation logic
  16. Forgetting to reset Swiper on category change — call swiper.slideTo(0) and reset progress
  17. Custom cursor on mobile — must be hidden on touch devices (use @media (hover: hover))
  18. Missing data-module / data-module-id attributes — root &lt;section&gt; MUST have data-module="sliderCategories" and data-module-id="M07"
  19. Forgetting to add tokens to ALL 8 theme files — or use _base.css with global token fallbacks
  20. Nested Block List mapper — Umbraco nests content inside items[].content.properties; navigate the full path