Skip to content

05 — BEM + SASS + Theming Conventions

Dev Guide — Savoy Signature Hotels
PRD refs: 05_Design_System_and_Theming.md, 07_Modules_and_Templates.md, 04_Frontend_Architecture.md


This guide defines the CSS architecture for the Savoy platform: BEM methodology for class naming, SASS for nesting and organization, and CSS custom properties for multi-site theming. Following these conventions ensures that all 8 hotel themes work with zero JavaScript overhead.


LayerToolRole
MethodologyBEM (Block Element Modifier)Predictable, flat class naming
PreprocessorSASS (.scss)Nesting, & shorthand for BEM, mixins
ThemingCSS Custom Properties (variables)Per-site visual tokens, switchable via data-theme
ScopingFile per componentEach module has its own .scss file

What we do NOT use:

  • CSS Modules (.module.css) — PRD 04 mentions these but PRD 07 specifies BEM + SASS
  • CSS-in-JS / styled-components — no runtime CSS
  • Tailwind / utility classes — we use semantic BEM classes
  • SASS variables for theme values — always CSS custom properties

.block
.block__element
.block--modifier
.block__element--modifier
BEM PartConventionExample
BlockComponent name in kebab-case.hero-slider
Element__ separator.hero-slider__title
Modifier-- separator.hero-slider--fullscreen
StateModifier or is- prefix.hero-slider--active, .is-visible
Component (PascalCase)BEM Block (kebab-case)
HeroSlider.hero-slider
CardGrid.card-grid
BookingBar.booking-bar
RichTextBlock.rich-text-block
ResponsiveImage.responsive-image

Each module has a single .scss file named {PascalName}.scss:

packages/modules/src/m08-card-grid/CardGrid.scss
packages/ui/src/Button/Button.scss

Use SASS & to co-locate BEM elements and modifiers:

CardGrid.scss
.card-grid {
padding: var(--space-12) 0;
// Elements
&__container {
max-width: var(--container-max);
margin: 0 auto;
padding: 0 var(--container-padding);
}
&__title {
font-family: var(--font-heading);
font-size: var(--text-3xl);
color: var(--color-text);
margin-bottom: var(--space-8);
// Responsive
@media (min-width: 1024px) {
font-size: var(--text-4xl);
}
}
&__grid {
display: grid;
gap: var(--space-6);
}
&__card {
background-color: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition-normal);
// Interaction
&:hover {
box-shadow: var(--shadow-md);
}
}
// Modifiers
&--dark {
background-color: var(--color-bg-dark);
}
// Grid column modifiers
&__grid--cols-2 {
@media (min-width: 768px) { grid-template-columns: repeat(2, 1fr); }
}
&__grid--cols-3 {
@media (min-width: 768px) { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 1024px) { grid-template-columns: repeat(3, 1fr); }
}
&__grid--cols-4 {
@media (min-width: 640px) { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 1024px) { grid-template-columns: repeat(4, 1fr); }
}
}
RuleRationale
Max 3 levels of nestingKeeps selectors flat and predictable
& for BEM onlyDon’t nest .other-class inside — keep blocks independent
Media queries inside the element they modifyCo-locates responsive behavior
No SASS variables for theme valuesUse CSS custom properties (var(--token)) exclusively
No @extendProduces unpredictable CSS; use mixins or class composition

<html data-theme="savoy-palace"> <!-- Set by layout.tsx based on site -->
<body>
<section class="card-grid"> <!-- Uses var(--color-primary) -->
...
</section>
</body>
</html>

When data-theme changes, all CSS variables resolve to the new theme’s values automatically. Zero JavaScript involved.

Always use CSS variables, never raw values:

// CORRECT
.hero-slider__title {
font-family: var(--font-heading);
font-size: var(--text-4xl);
color: var(--color-text-inverse);
}
// WRONG - hardcoded values
.hero-slider__title {
font-family: 'Playfair Display', serif;
font-size: 2.25rem;
color: #ffffff;
}
// WRONG - SASS variables for theme values
$font-heading: 'Playfair Display', serif;
.hero-slider__title {
font-family: $font-heading;
}
CategoryPrefixExample
Colors--color-var(--color-primary), var(--color-bg), var(--color-text)
Fonts--font-var(--font-heading), var(--font-body)
Font weights--font-weight-var(--font-weight-bold)
Text sizes--text-var(--text-2xl), var(--text-base)
Spacing--space-var(--space-4), var(--space-8)
Radius--radius-var(--radius-md), var(--radius-full)
Shadows--shadow-var(--shadow-sm), var(--shadow-lg)
Z-index--z-var(--z-header), var(--z-modal)
Transitions--transition-var(--transition-fast), var(--transition-normal)
Container--container-var(--container-max), var(--container-padding)
Line height--leading-var(--leading-tight), var(--leading-normal)

Always write base styles for mobile, then enhance with @media (min-width: ...):

.hero-slider {
// Mobile (default)
padding: var(--space-8) var(--container-padding);
min-height: 60vh;
// Tablet
@media (min-width: 768px) {
padding: var(--space-12) var(--container-padding);
}
// Desktop
@media (min-width: 1024px) {
min-height: 80vh;
}
// Large desktop
@media (min-width: 1280px) {
padding: var(--space-16) var(--container-padding);
max-width: var(--container-max);
margin: 0 auto;
}
}
NameValueUse
sm640pxLarge phones (landscape)
md768pxTablets (portrait)
lg1024pxTablets (landscape), small laptops
xl1280pxDesktops
2xl1440pxLarge desktops (max container)

Common pattern for module content containment:

.module-name {
&__container {
width: 100%;
max-width: var(--container-max);
margin-left: auto;
margin-right: auto;
padding-left: var(--container-padding);
padding-right: var(--container-padding);
}
}

Global focus styles are defined in _base.scss (or _base.css):

*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}

Skip link:

.skip-link {
position: absolute;
top: -100%;
left: var(--space-4);
z-index: var(--z-toast);
padding: var(--space-2) var(--space-4);
background-color: var(--color-primary);
color: var(--color-text-on-primary);
&:focus {
top: var(--space-4);
}
}

Anti-PatternWhyCorrect Approach
color: #1a365dBreaks themingcolor: var(--color-primary)
$sass-var: 16px for theme valuesNot theme-switchablevar(--space-4)
.card-grid .card-grid__titleOver-specific selector.card-grid__title (BEM is already specific)
Nesting 4+ levels deepHard to maintainMax 3 levels with BEM &
@extend .some-classUnpredictable outputUse mixins or direct classes
!importantSpecificity warFix the selector instead
style={{ color: 'red' }} in JSXInline styles bypass themingUse BEM class + CSS variable
@media (max-width: 768px)Not mobile-first@media (min-width: 768px)