Skip to content

05 — Design System and Theming

PRD Document · Savoy Signature Hotels — Multi-Site Headless Platform
Version: 1.0 · Date: 2026-03-04
Related docs: 04_Frontend_Architecture.md, 03_MultiSite_and_Domains.md, A03_Design_Tokens.md, A04_Module_Catalog.md


This document defines the design system architecture for the Savoy Signature platform. The system supports 8 distinct visual identities (one per hotel/site) while sharing a common set of components and modules. Theming is achieved through CSS custom properties (CSS variables) with per-site value overrides.


Modules (packages/modules)

UI Kit (packages/ui)

Design Tokens (packages/themes)

_base.css — Shared tokens (spacing, breakpoints, z-index)

savoy-palace.css

royal-savoy.css

savoy-signature.css

... (8 theme files)

Button

Input

Typography

Card

ResponsiveImage

Icon

Header

HeroSlider

RoomCard

...

PrincipleDescription
Token-firstAll visual values (colors, fonts, spacing) are defined as CSS variables — never hardcoded
Theme = CSS variablesSwitching themes means switching a set of CSS variable values; no JS needed
Component-agnostic themingComponents reference tokens (var(--color-primary)), not specific values
Mobile-firstAll components designed mobile-first, enhanced for larger viewports
Figma as source of truthDesign tokens extracted from Figma; Storybook mirrors Figma components
Zero runtime overheadCSS Modules + CSS variables = no JavaScript for styling at runtime

CategoryToken PrefixExamplesScope
Color--color---color-primary, --color-bg, --color-textPer-theme
Typography--font-, --text---font-heading, --text-base-size, --text-line-heightPer-theme (fonts), Shared (sizes)
Spacing--space---space-xs, --space-sm, --space-md, --space-lg, --space-xlShared
Breakpoints--breakpoint---breakpoint-sm: 640px, --breakpoint-lg: 1024pxShared
Border Radius--radius---radius-sm, --radius-md, --radius-fullShared
Shadow--shadow---shadow-sm, --shadow-md, --shadow-lgPer-theme
Z-Index--z---z-header, --z-modal, --z-overlayShared
Transition--transition---transition-fast, --transition-normalShared
packages/themes/src/_base.css
:root {
/* ── Spacing Scale ── */
--space-0: 0;
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
--space-24: 6rem; /* 96px */
--space-32: 8rem; /* 128px */
/* ── Breakpoints (reference only, used in media queries) ── */
/* sm: 640px | md: 768px | lg: 1024px | xl: 1280px | 2xl: 1440px */
/* ── Typography Scale ── */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
--text-5xl: 3rem; /* 48px */
--text-6xl: 3.75rem; /* 60px */
/* ── Line Heights ── */
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* ── Border Radius ── */
--radius-none: 0;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-full: 9999px;
/* ── Z-Index ── */
--z-base: 0;
--z-header: 100;
--z-dropdown: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
/* ── Transitions ── */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 400ms ease;
/* ── Container ── */
--container-max: 1440px;
--container-padding: var(--space-4);
}
@media (min-width: 768px) {
:root {
--container-padding: var(--space-6);
}
}
@media (min-width: 1280px) {
:root {
--container-padding: var(--space-8);
}
}

3.3 Theme-Specific Tokens (Example: Savoy Palace)

Section titled “3.3 Theme-Specific Tokens (Example: Savoy Palace)”
packages/themes/src/savoy-palace.css
[data-theme="savoy-palace"] {
/* ── Colors ── */
--color-primary: #1a365d;
--color-primary-light: #2a5a8e;
--color-primary-dark: #0f2340;
--color-secondary: #c9a96e;
--color-secondary-light: #d4bc8e;
--color-secondary-dark: #b08f4f;
--color-accent: #e8d5b7;
--color-bg: #faf9f7;
--color-bg-alt: #f2efe9;
--color-bg-dark: #1a1a1a;
--color-surface: #ffffff;
--color-surface-elevated: #ffffff;
--color-text: #1a1a1a;
--color-text-muted: #6b7280;
--color-text-inverse: #ffffff;
--color-text-on-primary: #ffffff;
--color-text-on-secondary: #1a1a1a;
--color-border: #e5e2dc;
--color-border-strong: #c9c4bb;
--color-success: #059669;
--color-warning: #d97706;
--color-error: #dc2626;
/* ── Typography ── */
--font-heading: 'Playfair Display', serif;
--font-body: 'Inter', sans-serif;
--font-accent: 'Cormorant Garamond', serif;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* ── Shadows ── */
--shadow-sm: 0 1px 2px rgba(26, 54, 93, 0.05);
--shadow-md: 0 4px 12px rgba(26, 54, 93, 0.08);
--shadow-lg: 0 8px 24px rgba(26, 54, 93, 0.12);
}

Complete token tables for all 8 themes in A03_Design_Tokens.md


packages/themes/src/
├── _base.css # Shared tokens (all themes inherit)
├── savoy-signature.css # [data-theme="savoy-signature"]
├── savoy-palace.css # [data-theme="savoy-palace"]
├── royal-savoy.css # [data-theme="royal-savoy"]
├── saccharum.css # [data-theme="saccharum"]
├── the-reserve.css # [data-theme="the-reserve"]
├── calheta-beach.css # [data-theme="calheta-beach"]
├── gardens.css # [data-theme="gardens"]
├── hotel-next.css # [data-theme="hotel-next"]
└── index.css # Imports _base.css + all themes

All themes are bundled in a single CSS file. The active theme is selected via the data-theme attribute on <html>:

apps/web/src/app/layout.tsx
import '@savoy/themes/src/index.css';
import { headers } from 'next/headers';
import { getSiteConfig } from '@/helpers/site-resolver';
import { loadFont } from '@/lib/fonts';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headersList = await headers();
const siteKey = headersList.get('x-site-key') || 'savoy-signature';
const site = getSiteConfig(siteKey);
const fontClasses = loadFont(site.theme);
return (
<html lang={site.defaultLocale} data-theme={site.theme}>
<body className={`site-${site.key} ${fontClasses}`}>
{children}
</body>
</html>
);
}
apps/web/src/lib/fonts.ts
import { Playfair_Display, Inter, Cormorant_Garamond } from 'next/font/google';
const playfair = Playfair_Display({ subsets: ['latin'], variable: '--font-heading', display: 'swap' });
const inter = Inter({ subsets: ['latin'], variable: '--font-body', display: 'swap' });
const cormorant = Cormorant_Garamond({ subsets: ['latin'], weight: ['400', '600'], variable: '--font-accent', display: 'swap' });
// Map theme → font classes
const THEME_FONTS: Record<string, string> = {
'savoy-palace': `${playfair.variable} ${inter.variable} ${cormorant.variable}`,
'savoy-signature': `${playfair.variable} ${inter.variable}`,
// ... other themes
};
export function loadFont(theme: string): string {
return THEME_FONTS[theme] || THEME_FONTS['savoy-signature'];
}

NameWidthTarget
sm640pxLarge phones (landscape)
md768pxTablets (portrait)
lg1024pxTablets (landscape), small laptops
xl1280pxDesktops
2xl1440pxLarge desktops (max container width)
/* Example: mobile-first responsive module */
.heroSlider {
padding: var(--space-8) var(--container-padding);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
@media (min-width: 768px) {
.heroSlider {
padding: var(--space-12) var(--container-padding);
flex-direction: row;
gap: var(--space-8);
}
}
@media (min-width: 1280px) {
.heroSlider {
padding: var(--space-16) var(--container-padding);
max-width: var(--container-max);
margin: 0 auto;
}
}
/* Shared container utility */
.container {
width: 100%;
max-width: var(--container-max);
margin-left: auto;
margin-right: auto;
padding-left: var(--container-padding);
padding-right: var(--container-padding);
}

Storybook runs as a separate app in the monorepo, importing from packages/ui and packages/modules:

apps/storybook/
├── .storybook/
│ ├── main.ts # Storybook config (refs to packages)
│ ├── preview.ts # Global decorators (theme provider)
│ └── theme-decorator.tsx # Theme switcher for Storybook toolbar
├── package.json
└── tsconfig.json

A Storybook toolbar addon lets reviewers switch between all 8 site themes:

apps/storybook/.storybook/theme-decorator.tsx
import React from 'react';
import '@savoy/themes/src/index.css';
const THEMES = [
'savoy-signature', 'savoy-palace', 'royal-savoy', 'saccharum',
'the-reserve', 'calheta-beach', 'gardens', 'hotel-next',
];
export const ThemeDecorator = (Story: any, context: any) => {
const theme = context.globals.theme || 'savoy-palace';
return (
<div data-theme={theme}>
<Story />
</div>
);
};
packages/modules/src/m05-hero-slider/HeroSlider.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { HeroSlider } from './HeroSlider';
const meta: Meta<typeof HeroSlider> = {
title: 'Modules/M05 — Hero Slider',
component: HeroSlider,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
design: {
type: 'figma',
url: 'https://www.figma.com/file/...', // Link to Figma design
},
},
};
export default meta;
type Story = StoryObj<typeof HeroSlider>;
export const Default: Story = {
args: {
title: 'Welcome to Savoy Palace',
subtitle: 'Luxury redefined in Madeira',
slides: [
{
image: { src: '/placeholder-1.jpg', alt: 'Pool view', width: 1920, height: 1080 },
caption: 'Infinity pool with ocean views',
},
],
autoplay: true,
interval: 5000,
},
};
export const WithCTA: Story = {
args: {
...Default.args,
slides: [
{
...Default.args!.slides![0],
cta: { label: 'Book Now', href: '/pt/rooms' },
},
],
},
};
export const MultipleSlides: Story = {
args: {
...Default.args,
slides: [
Default.args!.slides![0],
{ image: { src: '/placeholder-2.jpg', alt: 'Lobby', width: 1920, height: 1080 }, caption: 'Grand lobby' },
{ image: { src: '/placeholder-3.jpg', alt: 'Room', width: 1920, height: 1080 }, caption: 'Deluxe suite' },
],
},
};

Storybook is deployed only to internal WYcreative environments (DEV and STAGE). It is not deployed to QA or PROD.

EnvironmentURLTrigger
DEVsavoy.dev-storybook.wycreative.comMerge to develop
STAGEsavoy.stage-storybook.wycreative.comMerge to staging

  • All 8 themes render correctly in Storybook via theme switcher
  • Switching data-theme changes all visual properties (colors, fonts, shadows) without JS
  • Base tokens (spacing, breakpoints) are consistent across all themes
  • Font loading causes zero layout shift (FOUT/FOIT)
  • All components work at all breakpoints (sm → 2xl)
  • Storybook deploys automatically and is accessible to the team
  • Theme CSS files add less than 5KB each (gzipped)
  • Design tokens match Figma specifications

Next document: 06_Content_Modeling_Umbraco.md