📘 HubSpot CMS Development Textbook — 2026 Edition
Chapter 4

Custom module design/development pattern

The heart of HubSpot CMS development is custom modules. Field type usage frequency derived from 741 module analysis, group/repeat field design, all implementation patterns of module.html, CSS/JS design, and meta.json settings. A complete guide to module development that can be used immediately in the field.

🎯 Target level:Intermediate to advanced
⏱ Estimated reading completion:120-150 minutes
🔗 Previous chapter:Chapter 3 Template design/implementation patterns

Contents of this chapter

  1. Custom module 5 file structure
  2. Field type usage frequency ranking (741 cases analyzed)
  3. fields.json design basics and complete field type reference
  4. Group/repeat field design pattern
  5. Layout control with choice field
  6. module.html — Variable reference/existence check/default value
  7. module.html — Complete implementation of image fields
  8. module.html — Link field implementation pattern
  9. module.html — Looping and namespace patterns
  10. module.css — BEM/CSS variables/responsive design
  11. module.js — DOM manipulation, data attributes, and initialization patterns
  12. meta.json — Module meta information settings
  13. 5 best practices learned from real projects
Section 4-1

Custom module 5 file structure

Custom modules in HubSpot CMS1 module = 1 directory (.module)Managed in units of That directory always contains five types of files. This set is the "basic unit of module development."

⚙️
fields.json
Field definition. Decide the editing UI displayed in the page editor
📄
module.html
Template written in HubL. Output HTML by referencing field values
🎨
module.css
Module-specific CSS. BEM naming. Loaded only when placed on the page
module.js
Module-specific JS. Operation definition of slider, accordion, etc.
🏷️
meta.json
Meta information such as module name, icon, category, etc.
Terminal — module skeleton generation
# Generate module template using CLI (5 files are automatically created)
$ hs create module hero-banner --path=./src/my-theme/modules

✔ Created "hero-banner.module" at:
  src/my-theme/modules/hero-banner.module/
  ├── fields.json   # Empty array [] is the initial value
  ├── module.html   # empty file
  ├── module.css    # empty file
  ├── module.js     # empty file
  └── meta.json     # Basic information is automatically set
📊 Scale of actual project

The code base of the actual case analyzed is741 moduleexisted. In a typical configuration for a single site/single theme20-60 modulesis standard. 741 items is a scale in which multiple themes and multiple clients are managed in one repository. This chapter systematizes the patterns obtained from the analysis of all 741 modules.


Section 4-2

Field type usage frequency ranking (741 cases analyzed)

741 items of all modules fields.json scanned and aggregated the Actual usage frequency ranking of field typesis. Please use it to determine the priority of "which field type should be learned first."

1text
Most used — Required for labels, headings, subtext, etc.
2image
Appears in almost all modules — background, icon, thumbnail
3boolean
Frequently used for display/hide switching
4choice
Required for switching layout, style, and number of columns
5link
Appears on CTA buttons, card links, and navigation
6richtext
Used for main text, explanatory text, and rich content
7group
Repeating structure of FAQ items, card lists, etc.
8color
Individual specification of background color and text color (STYLE tab)
9number
Specify the number of displayed items, animation speed, and margin amount
10url
Directly specify external URL (simpler situation than link type)
✅ If you master the TOP5 perfectly, you will be able to handle 90% of actual projects.

text / image / boolean / choice / link The five of This is the field type most frequently used in actual projects. Mastering these five design patterns is a top priority. Group has a complex structure, but it is necessary for all kinds of repetitive content such as FAQs, card grids, tabs, etc. Be sure to master it.


Section 4-3

fields.json design basics and complete field type reference

Basic structure of fields.json

fields.json — Basic structure and common properties
[
  {
    // ===== Required property =====
    "type"   : "text",          // field type
    "name"   : "heading",       // Key name referenced from HubL (snake_case)
    "label"  : "Heading",        //Label to display in page editor (Japanese recommended)

    // ===== Highly recommended properties =====
    "default"   : "Heading text", // Default value (set so that the display will not be distorted even if it is not set)
    "required"  : false,           // Required input flag
    "help_text" : "Maximum 30 characters recommended",   // Guide text to display in editor
    "placeholder": "Example: Service introduction",  // Placeholder for input field (text type only)

    // ===== Optional properties =====
    "locked"    : false,           // If set to true, it can be disabled in the editor
    "hidden"    : false,           // Hide in editor if set to true (can be used in HubL)
    "tab"       : "CONTENT"        // "CONTENT" (default) or "STYLE" (style tab)
  }
]

All field types reference

"type": "text"

Single line text input. Used for headings, labels, button text, etc.
Reference: module.text / "default": "text"

"type": "richtext"

Rich text editor. Used for body text and explanatory text containing HTML. When outputting, expand it as is with {{ module.body }}.
Reference: module.body / "default": "<p>Text</p>"

"type": "image"

Image selection field. It has subproperties of .src / .alt / .width / .height.
Reference: module.image.src / .alt / .width / .height

"type": "boolean"

ON/OFF toggle. Used to switch section display/hide, with/without background color, etc.
Reference: module.show_section / "default": true

"type": "choice"

A field where you select one option. Define choices in the choices array. display: "select" / "radio" / "checkbox".
Reference: module.layout / "display": "radio"

"type": "link"

A complete set of link information including URL, link text, open in another tab, and nofollow. Perfect for CTA buttons.
Reference: module.cta.url / .open_in_new_tab / .no_follow

"type": "color"

Color picker. Subproperties of .color (hex) and .opacity (0-100). Use in combination with tab:"STYLE".
Reference: module.bg_color.color / .opacity

"type": "font"

A set of font families, sizes, weights, and colors. It is usually managed in theme.json and used for exceptional individual settings.
Reference: module.heading_font.font / .size / .bold

"type": "number"

Numerical input. Used for display number, animation speed, margin amount, number of columns, etc.
Reference: module.count / "default": 3 / "min": 1 / "max": 12

"type": "url"

A simple link field containing just the URL (simpler than the link type). Used for image links, external embedded URLs, etc.
Reference: module.video_url / "default": ""

"type": "hubspot_cta"

Field to select a HubSpot call-to-action (CTA) object. Linked with CTA management screen.
Reference: {% cta module.cta %}

"type": "form"

Field to select a HubSpot form. Returns the form ID and form object.
Reference: module.form_field.form_id / .response_redirect_url

"type": "blog"

Field to select blog. Used to obtain the blog ID to be passed to blog_recent_posts() etc.
Reference: module.blog_field.id

"type": "tags"

Field to select multiple blog tags. Used for tag filter UI.
Reference: module.selected_tags (array)

"type": "simple_menu"

Field to create a simple navigation menu. It has a hierarchical structure of labels, URLs, and child menus.
Reference: module.menu_field.children

"type": "group"

A group that combines multiple fields. Occurrence controls the number of repetitions. Required for FAQs and card lists.
Reference: module.items / occurrence.min / .max

How to use field types is often confusing

sceneRecommended typereason
CTA button (text + link)linkYou can manage everything from opening a separate tab to nofollow in one field.
External embed URL onlyurllink Lighter. If you don't need text or targeting
Main text/explanation (with format)richtextWhen formatting such as bold, list, link etc. is required
Heading/Label (no formatting)textIf you only need plain text, text is lightweight and simple.
Show/hide on/offbooleanIntuitive with toggle UI. Branch at {% if module.xxx %}
Switch layout stylechoicePrevent unexpected values ​​from being entered and ensure ease of use of the editor

Section 4-4

Group/repeat field design pattern

"type": "group" and occurrence The combination of This is the most important and complex design pattern in HubSpot CMS. Required for all modules that "repeat items" such as FAQs, card grids, tabs, timelines, etc.

Pattern ① FAQ accordion (the most typical repetition)

fields.json — FAQ accordion module
[
  {
    "type"   : "text",
    "name"   : "section_title",
    "label"  : "Section Heading",
    "default": "FAQ"
  },
  {
    // ===== Repeat group (control number with occurrence) =====
    "type"       : "group",
    "name"       : "items",
    "label"      : "FAQ Item",
    "occurrence" : {
      "min"             : 1,   // Minimum number of items
      "max"             : 20,  // Maximum number
      "default"         : 3,   // Initial display number
      "sorting_label"   : "FAQ"  // Sort label in editor
    },
    "children"   : [
      {
        "type"   : "text",
        "name"   : "question",
        "label"  : "question",
        "required": true,
        "default": "Please enter your question"
      },
      {
        "type"   : "richtext",
        "name"   : "answer",
        "label"  : "answer",
        "required": true,
        "default": "<p>Please enter your answer</p>"
      }
    ]
  }
]

Pattern ② Card grid (set of images, text, and links)

fields.json — Card grid module
[
  {
    "type": "text",
    "name": "section_title",
    "label": "Section Heading",
    "default": "Service list"
  },
  {
    "type": "choice",
    "name": "columns",
    "label": "Number of columns",
    "choices": [
      ["2", "2 rows"],
      ["3", "3 columns (default)"],
      ["4", "4 rows"]
    ],
    "default": "3",
    "display": "radio"
  },
  {
    // ===== Repeating group of cards =====
    "type": "group",
    "name": "cards",
    "label": "card",
    "occurrence": { "min": 1, "max": 12, "default": 3 },
    "children": [
      {
        "type"       : "image",
        "name"       : "image",
        "label"      : "image",
        "help_text"  : "Recommended size: 640×360px",
        "default"    : { "src": "", "alt": "", "width": 640, "height": 360 }
      },
      {
        "type"   : "text",
        "name"   : "title",
        "label"  : "Card title",
        "required": true,
        "default": "Service name"
      },
      {
        "type"   : "richtext",
        "name"   : "description",
        "label"  : "Description",
        "default": "<p>Please enter a description</p>"
      },
      {
        "type"   : "link",
        "name"   : "cta",
        "label"  : "Link (optional)",
        "default": { "url": { "href": "" }, "open_in_new_tab": false }
      }
    ]
  }
]

Pattern 3: Nesting of groups (items within tabs)

fields.json — Tab module (nesting groups)
[
  {
    "type": "group",
    "name": "tabs",
    "label": "tab",
    "occurrence": { "min": 2, "max": 8, "default": 3 },
    "children": [
      {
        "type"   : "text",
        "name"   : "tab_label",
        "label"  : "Tab name",
        "required": true,
        "default": "Tab name"
      },
      {
        "type"   : "richtext",
        "name"   : "tab_content",
        "label"  : "Tab Content",
        "default": "<p>Enter content</p>"
      },
      {
        // nested group (add icon list inside tab)
        "type": "group",
        "name": "icon_list",
        "label": "Icon list (optional)",
        "occurrence": { "min": 0, "max": 6, "default": 0 },
        "children": [
          { "type": "image", "name": "icon",  "label": "icon"  },
          { "type": "text",  "name": "label", "label": "text" }
        ]
      }
    ]
  }
]
💡 "occurrence" min: 0 realizes "arbitrary section"

"min": 0 , you can set the number of items to 0 in the editor. You can create optional sections that can be emptied if not used, like the icon list in the example above. On the module.html side {% if module.items %} Used in conjunction with existence check.


Section 4-5

Layout control with choice field

choice The field isMarketers can “change the appearance without touching code” It's the strongest system. We will introduce three typical patterns confirmed through analysis of actual projects.

Pattern ① Switching layout direction (horizontal reversal)

fields.json + module.html — Left/right switching between images and text
// fields.json
{
  "type"    : "choice",
  "name"    : "layout",
  "label"   : "Layout",
  "choices" : [
    ["image-left",  "Image: Left / Text: Right (default)"],
    ["image-right", "Image: Right / Text: Left"]
  ],
  "default" : "image-left",
  "display" : "radio"
}// module.html — Switch CSS class with choice value
<div class="split-section split-section--{{ module.layout }}">
  <div class="split-section__image">...</div>
  <div class="split-section__text">...</div>
</div>/* module.css — Implemented with CSS (no JS) */
.split-section               { display: flex; gap: 40px; align-items: center; }
.split-section--image-left   { flex-direction: row; }
.split-section--image-right  { flex-direction: row-reverse; }
@media (max-width: 768px) {
  .split-section, .split-section--image-right { flex-direction: column; }
}

Pattern ② Switching color theme

fields.json + module.html — Background color theme
// fields.json
{
  "type"    : "choice",
  "name"    : "color_theme",
  "label"   : "Color Theme",
  "tab"     : "STYLE",
  "choices" : [
    ["white",   "White (default)"],
    ["light",   "light gray"],
    ["primary", "Primary color"],
    ["dark",    "dark"]
  ],
  "default" : "white",
  "display" : "select"
}// module.html
<section class="cta-section cta-section--{{ module.color_theme }}">
  ...
</section>/* module.css */
.cta-section--white   { background: #ffffff; color: #2d3748; }
.cta-section--light   { background: #f7fafc; color: #2d3748; }
.cta-section--primary { background: var(--color-primary); color: #ffffff; }
.cta-section--dark    { background: #1a202c; color: #f7fafc; }

Pattern ③ Branch the entire module structure based on the choice value

module.html — Output completely different HTML with choice value
{% set layout = module.card_style|default("vertical") %}

{% if layout == "vertical" %}
  {# Vertical card (image → text) #}
  <div class="card card--vertical">
    {% if module.image.src %}
      <div class="card__thumb">
        <img src="{{ module.image.src }}?width=640&format=webp"
             alt="{{ module.image.alt|escape }}"
             loading="lazy">
      </div>
    {% endif %}
    <div class="card__body">
      <h3>{{ module.title }}</h3>
      {{ module.description }}
    </div>
  </div>{% elif layout == "horizontal" %}
  {#Horizontal card (image + text horizontally)#}
  <div class="card card--horizontal">
    {% if module.image.src %}
      <div class="card__thumb">
        <img src="{{ module.image.src }}?width=320&format=webp"
             alt="{{ module.image.alt|escape }}"
             loading="lazy">
      </div>
    {% endif %}
    <div class="card__body">
      <h3>{{ module.title }}</h3>
      {{ module.description }}
    </div>
  </div>{% elif layout == "icon" %}
  {# Icon type card (small icon + text) #}
  <div class="card card--icon">
    {% if module.image.src %}
      <img class="card__icon"
           src="{{ module.image.src }}?width=80"
           alt="" aria-hidden="true"
           width="40" height="40">
    {% endif %}
    <h3>{{ module.title }}</h3>
    {{ module.description }}
  </div>{% endif %}

Section 4-6

module.html — Variable reference/existence check/default value

module.html Well then module.フィールド名 Reference the field value with . If referenced without checking the existence, an error will occur if there is no value.Because, It is essential to use appropriate check patterns.

How to use existence check by pattern

module.html — Presence check by field type
{# ===== Text (text / richtext) =====
   If the value is an empty string, it is treated as falsy #}
{% if module.heading %}
  <h2>{{ module.heading }}</h2>{% endif %}

{# ===== Image field =====
   Check existence of image URL in .src
   ★ Be sure to check not only module.image but also .src #}
{% if module.image.src %}
  <img src="{{ module.image.src }}" alt="{{ module.image.alt|escape }}">{% endif %}

{# ===== Link field =====
   Check existence of actual URL with .url.href #}
{% if module.cta.url.href %}
  <a href="{{ module.cta.url.href }}">{{ module.cta.name }}</a>{% endif %}

{# ===== Group (repeat) field =====
   Check that the array is not empty and then loop #}
{% if module.items %}
  {% for item in module.items %}
    <div>{{ item.title }}</div>
  {% endfor %}
{% endif %}

{# ===== boolean field =====
   Use true/false as a condition #}
{% if module.show_button %}
  <button>{{ module.button_label }}</button>{% endif %}

{# ===== |default() Filter exists and default value at the same time =====
   You can set a fallback value when referencing without checking #}
<h2>{{ module.heading|default("Please enter a heading") }}</h2>
<a href="{{ module.cta.url.href|default("#") }}">
  {{ module.cta.name|default("Learn more") }}
</a>

Section 4-7

module.html — Complete implementation of image fields

Since images are directly linked to performance, HubSpot's image conversion function/loading attribute·fetchpriority of Correct settings are very important. We will introduce complete implementation patterns extracted from actual projects.

module.html — Complete implementation pattern for image fields
{# ===== Pattern 1: Standard image (lazy loading) =====
   Use when it is not an LCP element (image other than first view)
===================================================== #}
{% if module.image.src %}
  <img
    src="{{ module.image.src }}?width=800&format=webp"
    alt="{{ module.image.alt|default("")|escape }}"
    width="{{ module.image.width|default(800) }}"
    height="{{ module.image.height|default(450) }}"
    loading="lazy"
    decoding="async">{% endif %}

{# ===== Pattern 2: LCP element (hero banner image) =====
   Use eager + high for the highest content element in first view
===================================================== #}
{% if module.hero_image.src %}
  <img
    src="{{ module.hero_image.src }}?width=1440&format=webp"
    alt="{{ module.hero_image.alt|default("")|escape }}"
    width="1440"
    height="640"
    loading="eager"
    fetchpriority="high"
    decoding="sync">{% endif %}

{# ===== Pattern 3: Responsive image with picture element =====
   Distinguishing different image sizes and aspect ratios on SP/PC
===================================================== #}
{% if module.image.src %}
  <picture>
    {# WebP (for modern browsers) #}
    <source
      type="image/webp"
      srcset="
        {{ module.image.src }}?width=480&format=webp  480w,
        {{ module.image.src }}?width=800&format=webp  800w,
        {{ module.image.src }}?width=1200&format=webp 1200w
      "
      sizes="(max-width: 480px) 480px, (max-width: 800px) 800px, 1200px">
    {#Fallback (JPG)#}
    <img
      src="{{ module.image.src }}?width=800"
      alt="{{ module.image.alt|default("")|escape }}"
      width="{{ module.image.width|default(800) }}"
      height="{{ module.image.height|default(450) }}"
      loading="lazy"
      decoding="async">
  </picture>{% endif %}

{# ===== Pattern 4: Pass image URL to CSS background image =====
   Heroes etc. combined with gradient overlay
===================================================== #}
<div
  class="hero"
  {% if module.bg_image.src %}
    style="--bg-image: url('{{ module.bg_image.src|escape_url }}?width=1440&format=webp')"
  {% endif %}>
  ...
</div>/* CSS side: .hero { background-image: var(--bg-image); } */

HubSpot image conversion parameter list

parametersExample valueeffect
?width=Nwidth=800Resize width to Npx (height maintains aspect ratio)
?height=Nheight=600Resize height to Npx
?format=webpformat=webpConvert to WebP format (great for reducing file size)
?format=jpgformat=jpgConvert to JPEG format
?quality=Nquality=80Specify the image quality from 0 to 100 (default 80)
multiple combinationswidth=800&format=webp&quality=85Parameters can be concatenated with &

Section 4-8

module.html — Link field implementation pattern

module.html — Complete implementation of the link field
{# ===== link field subproperties =====
   Properties that module.cta (link type field) has:
   .url.href : Link destination URL
   .url.type : "EXTERNAL" / "INTERNAL" / "EMAIL"
   .name : Link text (label)
   .open_in_new_tab: Whether to open in a separate tab (true/false)
   .no_follow : Whether to add rel="nofollow"
===================================================== #}

{% if module.cta.url.href %}
  <a
    href="{{ module.cta.url.href }}"
    class="btn btn--primary"

    {# Separate tab/nofollow control =====
       Append target only if open_in_new_tab is true
       Add nofollow only if no_follow is true
       Always add noopener / noreferrer for security #}
    {% if module.cta.open_in_new_tab %}
      target="_blank"
    {% endif %}
    rel="noopener noreferrer{% if module.cta.no_follow %} nofollow{% endif %}"

    {# Tell screen readers it's an external link #}
    {% if module.cta.open_in_new_tab %}
      aria-label="{{ module.cta.name|escape }}(Opens in new tab)"
    {% endif %}>

    {{ module.cta.name|default("Learn more") }}

    {# Display external link icon if in separate tab (optional) #}
    {% if module.cta.open_in_new_tab %}
      <span aria-hidden="true">↗</span>
    {% endif %}
  </a>{% endif %}
⚠️ noopener / noreferrer is a mandatory security measure when target="_blank"

target="_blank" If you open a link withrel="noopener noreferrer" of Must be attachedThis is essential for security. Without this, there is a risk that the original page may be manipulated from the opened page (Tabnabbing attack). module.cta.open_in_new_tab Conditionally granted if is true, even if false rel="noopener noreferrer" Best practice is to leave it on.


Section 4-9

module.html — Looping and namespace patterns

Basic loop processing

module.html — Group field loop implementation
{# ===== Basic loop + Use of loop variables ===== #}
{% if module.items %}
  <ul class="card-grid card-grid--{{ module.columns|default("3") }}">
    {% for item in module.items %}

      {# Values ​​that can be controlled by loop variable #}
      {# loop.index : Sequential number starting from 1 (1, 2, 3...) #}
      {# loop.index0 : Sequential number starting from 0 (0, 1, 2...) #}
      {# loop.first : First item (true/false) #}
      {# loop.last : Last item (true/false) #}
      {# loop.length : Total number of items #}

      <li
        class="card-grid__item
          {% if loop.first %} is-first{% endif %}
          {% if loop.last  %} is-last{% endif %}"
        data-index="{{ loop.index0 }}">

        {% if item.image.src %}
          <img
            src="{{ item.image.src }}?width=640&format=webp"
            alt="{{ item.image.alt|default(item.title)|escape }}"
            loading="{% if loop.index <= 2 %}eager{% else %}lazy{% endif %}"
            width="640" height="360">
            {# ↑ The first two are eager (display speed is prioritized), the rest are lazy #}
        {% endif %}

        {% if item.title %}
          <h3>{{ item.title }}</h3>
        {% endif %}

        {% if item.description %}
          <div>{{ item.description }}</div>
        {% endif %}

        {% if item.cta.url.href %}
          <a href="{{ item.cta.url.href }}"
             class="btn"
             {% if item.cta.open_in_new_tab %}
               target="_blank" rel="noopener noreferrer"
             {% endif %}>
            {{ item.cta.name|default("Learn more") }}
          </a>
        {% endif %}
      </li>
    {% endfor %}
  </ul>{% endif %}

namespace pattern (state management inside loops)

Variables cannot be reassigned inside a HubL loop. If you want to carry over the state across the loop, namespace use. This is a pattern often used for tag prefix determination in actual projects.

module.html — Manage state inside the loop with namespace
{# ===== Problem: Variables cannot be rewritten with set inside a loop =====
   The code below does not work (HubL specifications)
   {% for item in module.items %}
     {% set found = true %} ← After exiting the loop, found remains false
   {%endfor%}
===================================================== #}

{# ===== Solution: Use namespace ===== #}
{% set ns = namespace(
  has_featured = false,   // Featured?
  total_count  = 0        // counter
) %}

{% for item in module.items %}
  {% if item.is_featured %}
    {% set ns.has_featured = true %}   // rewritten via namespace
    {% set ns.total_count = ns.total_count + 1 %}
  {% endif %}

  <div class="item{% if item.is_featured %} item--featured{% endif %}">
    {{ item.title }}
  </div>{% endfor %}

{# You can refer to the namespace value after the loop #}
{% if ns.has_featured %}
  <p>フィーチャーアイテム:{{ ns.total_count }}件</p>{% endif %}

Section 4-10

module.css — BEM/CSS variables/responsive design

Applying BEM naming conventions

The naming pattern confirmed by the code base analysis of the actual project isBEM(Block__Element--Modifier)is. Prefixing the module name with the Block name prevents collisions with the global scope.

module.css — Example implementation of BEM naming convention (card grid)
/* ===================================================
   card-grid.module/module.css
   BEM naming convention: .card-grid (Block) as the base point
=================================================== */

/* ===== Block ===== */
.card-grid {
  padding-block: var(--section-padding);
}/* ===== Block Element ===== */
.card-grid__title {
  font-size: clamp(1.5rem, 3vw, 2.25rem);
  font-weight: 700;
  text-align: center;
  margin-bottom: 2rem;
}

.card-grid__list {
  display: grid;
  gap: 24px;
  list-style: none;
  /* Flexibly control the number of columns with CSS variables (linked with data-columns attribute of module.html) */
  grid-template-columns: repeat(var(--cols, 3), 1fr);
}/* ===== Modifier (grid column count variant) ===== */
.card-grid__list--2 { --cols: 2; }
.card-grid__list--3 { --cols: 3; }
.card-grid__list--4 { --cols: 4; }/* ===== Card (child component) ===== */
.card-grid__item {}

.card-grid__card {
  background: #ffffff;
  border-radius: var(--border-radius, 8px);
  overflow: hidden;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);
  transition: box-shadow 0.2s ease, transform 0.2s ease;
  height: 100%;
  display: flex;
  flex-direction: column;
}
.card-grid__card:hover {
  box-shadow: 0 4px 16px rgba(0,0,0,0.14);
  transform: translateY(-2px);
}

.card-grid__thumb { aspect-ratio: 16/9; overflow: hidden; }
.card-grid__thumb img {
  width: 100%; height: 100%;
  object-fit: cover;
  transition: transform 0.3s ease;
}
.card-grid__card:hover .card-grid__thumb img { transform: scale(1.04); }

.card-grid__body {
  padding: 20px;
  flex: 1;
  display: flex;
  flex-direction: column;
}

.card-grid__card-title {
  font-size: 1.05rem;
  font-weight: 700;
  margin-bottom: 8px;
  color: var(--color-text, #2d3748);
}

.card-grid__card-desc {
  font-size: 0.9rem;
  color: #718096;
  line-height: 1.7;
  flex: 1;
}

.card-grid__card-cta {
  margin-top: 16px;
}/* ===== Responsive (Mobile First) ===== */
@media (max-width: 480px) {
  .card-grid__list--2,
  .card-grid__list--3,
  .card-grid__list--4 { --cols: 1; }
}
@media (min-width: 481px) and (max-width: 768px) {
  .card-grid__list--3,
  .card-grid__list--4 { --cols: 2; }
}
@media (min-width: 769px) and (max-width: 1024px) {
  .card-grid__list--4 { --cols: 3; }
}

Reference CSS variables in theme.json

In analyzing actual projects, rather than referencing CSS variables (var(--xxx)) We observed many patterns that expand values directly in HubL. For maintainability, we recommend referencing via CSS variables.I will. Define CSS variables in base.html or 01-settings.css.

module.css — Reference theme colors using CSS variables (recommended pattern)
/* ===== Reference CSS variables (linked with theme.json value) =====
   var(--color-primary) is a variable defined in base.html
   Just change theme.json and it will be reflected in all modules
=================================================== */

.cta-banner { background: var(--color-primary); }
.cta-banner__title { color: #ffffff; }
.cta-banner__btn {
  background: #ffffff;
  color: var(--color-primary);
  border-radius: var(--border-radius);
}
.cta-banner__btn:hover {
  background: var(--color-primary);
  color: #ffffff;
  outline: 2px solid #ffffff;
}

Section 4-11

module.js — DOM manipulation, data attributes, and initialization patterns

In the analysis of actual cases,Modules with JS are in the minorityIt was. Use module.js only for dynamic UI like accordions, tabs, and sliders. No JS required for simple content display.

module.js — Accordion (FAQ) implementation pattern
/* faq-accordion.module / module.js
   ===================================================
   Implementation policy:
   - Don't use DOMContentLoaded, load the script with the defer attribute
   - Identify target element by class name (class, not ID)
   - Designed to work even if multiple modules exist on the same page
   - Accessibility support with ARIA attributes (aria-expanded / aria-controls)
=================================================== */

(function() {
  // Get all accordions for this module
  var accordions = document.querySelectorAll('.faq-accordion__item');

  accordions.forEach(function(item) {
    var btn     = item.querySelector('.faq-accordion__question');
    var content = item.querySelector('.faq-accordion__answer');

    if (!btn || !content) return;

    // Initial ARIA attribute settings
    btn.setAttribute('aria-expanded', 'false');
    content.setAttribute('aria-hidden', 'true');
    content.style.height = '0';
    content.style.overflow = 'hidden';
    content.style.transition = 'height 0.3s ease';

    btn.addEventListener('click', function() {
      var isExpanded = btn.getAttribute('aria-expanded') === 'true';

      if (isExpanded) {
        // close
        content.style.height = content.scrollHeight + 'px';
        requestAnimationFrame(function() {
          content.style.height = '0';
        });
        btn.setAttribute('aria-expanded', 'false');
        content.setAttribute('aria-hidden', 'true');
        item.classList.remove('is-open');
      } else {
        // open
        content.style.height = content.scrollHeight + 'px';
        btn.setAttribute('aria-expanded', 'true');
        content.setAttribute('aria-hidden', 'false');
        item.classList.add('is-open');
        // Change height: auto after transitionend (resize compatible)
        content.addEventListener('transitionend', function handler() {
          content.style.height = 'auto';
          content.removeEventListener('transitionend', handler);
        });
      }
    });
  });
})();
module.js — Pattern for passing HubL data to JS with data attribute
/* ===== module.html side (pass data with data attribute) =====
<div
  class="staff-slider"
  data-autoplay="{{ module.autoplay|lower }}"
  data-speed="{{ module.speed|default(3000) }}"
  data-items='{{ module.items|tojson }}'>
</div>
============================================== */

// module.js side (read settings from data attribute)
(function() {
  var sliders = document.querySelectorAll('.staff-slider');

  sliders.forEach(function(el) {
    // Get the setting value from the data attribute
    var autoplay = el.dataset.autoplay === 'true';
    var speed    = parseInt(el.dataset.speed, 10) || 3000;
    var items    = JSON.parse(el.dataset.items || '[]');

    if (!items.length) return;

    // Initialize the slider (simple implementation without relying on libraries)
    var current = 0;
    var slides  = [];

    items.forEach(function(item, i) {
      var slide = document.createElement('div');
      slide.className = 'staff-slider__slide' + (i === 0 ? ' is-active' : '');
      slide.innerHTML =
        '<img src="' + item.image + '?width=400&format=webp" alt="' + item.name + '">' +
        '<p class="staff-slider__name">' + item.name + '</p>' +
        '<p class="staff-slider__title">' + item.title + '</p>';
      el.appendChild(slide);
      slides.push(slide);
    });

    function goTo(index) {
      slides[current].classList.remove('is-active');
      current = (index + slides.length) % slides.length;
      slides[current].classList.add('is-active');
    }

    if (autoplay) {
      setInterval(function() { goTo(current + 1); }, speed);
    }
  });
})();

Section 4-12

meta.json — Module meta information settings

meta.json defines information related to displaying and categorizing modules in the page editor. Proper configuration makes it easier for marketers to find your module.

meta.json — complete configuration example
{
  "label": "Card Grid",
  // Module name displayed in page editor (Japanese OK)

  "icon": "layout",
  // Icon name (choose from HubSpot's icon set)
  // Example: "layout" / "image" / "form" / "blog" / "text" / "list"
  // There are many others. You can see the list in the HubSpot documentation

  "categories": ["CONTENT"],
  // Page editor category classification
  // "CONTENT" / "COMMERCE" / "FORMS" / "SOCIAL"
  // Multiple specifications possible: ["CONTENT", "FORMS"]

  "content_types": ["SITE_PAGE", "LANDING_PAGE", "BLOG_POST"],
  // Limit the page types that this module can use
  // Can be used for all page types if omitted
  // "SITE_PAGE" / "LANDING_PAGE" / "BLOG_POST" / "BLOG_LISTING"

  "is_available_for_new_content": true,
  // Set to false to disable adding to new pages
  // Used to disable old deprecated modules

  "smart_type": "NOT_SMART",
  // Whether to use it as smart content (Enterprise)
  // "NOT_SMART" / "SMART"

  "host_template_types": ["PAGE", "BLOG_POST"]
  // Link with template type (similar concept to content_types)
}

Disablement patterns for deprecated modules

When deprecating old modules during renewal, If you suddenly delete a module used in an existing page, the page will break. is_available_for_new_content: false While setting "Prohibit new additions", The method of gradual migration without affecting existing pages is often used in actual projects.


Section 4-13

5 best practices learned from real projects

Selected from 741 module analyses, "Just remembering this pattern will greatly improve quality."Five implementation patterns.

1

Set meaningful default values ​​for all fields

in the field default If is not set, immediately after placing the module when creating a new page Problems such as images not displaying, text remaining empty, and layouts collapsing may occur. “Looking good even in its default state” is the minimum level of module quality.is. Especially the image field {"src":"","alt":"","width":640,"height":360} , The link field is {"url":{"href":""},"open_in_new_tab":false} Be sure to set

2

Always check the existence of images (.src) before referencing them and use HubSpot conversion parameters

{% if module.image.src %} It is mandatory to check the existence of ({% if module.image %} alone is not enough). and for all images ?width=N&format=webp will be granted. Webp conversion alone reduces file size by 30-50% on average. The LCP element (first view largest image) has loading="eager" fetchpriority="high", Other than that loading="lazy" decoding="async" use. This usage has a direct impact on Core Web Vitals.

3

Control layout by switching CSS classes with choice field

Rather than implementing layout changes in JavaScript, choice Embed the field value as part of the CSS class name (class="module--{{ module.layout }}"), A pattern that controls appearance using only CSS is the simplest and easiest to maintain. Marketers can change the layout by simply selecting a radio button, Engineers can add new variants simply by adding CSS rules.

4

Group fields should be designed with realistic values ​​for the minimum number of items 0 and maximum number of items.

occurrence.min: 0 By setting , you can create a state where "nothing is displayed if there is no item". On the module.html side {% if module.items %} By combining with You can enable/disable any section without an engineer. You can have some leeway by setting the maximum number to about ``twice the maximum value that is likely to be used.'' (12 for 3-row cards, 20 for FAQ).

5

Make the field label Japanese and specify the image size and character limit in help_text

"label": "bg_color" twist "label": "背景色" It is easier for marketers to get confused. moreover "help_text" to "推奨サイズ: 1440×640px、JPG形式、2MB以下" like Having specific instructions helps marketers prepare the right image. This one line will prevent many maintenance requests for ``images looking strange.'' In particular, prioritize settings for image/richtext/text (limited length) fields.

✅ Quality checklist for module development

□ All fields default Is the value set?
□ The image field is {% if module.xxx.src %} Are you checking the existence with
□ The link field is {% if module.xxx.url.href %} Are you checking the existence with
□ For all images ?format=webp Is it attached?
□ To LCP element loading="eager" fetchpriority="high" Is it attached?
target="_blank" link to rel="noopener noreferrer" Is it attached?
□ Are the field labels in Japanese?
□ The image field has a recommended size. help_text Is it listed in
□ Does the class name of module.css comply with BEM? (Does the Block name correspond to the module name?)
□ meta.json label Does it have a name that is easy to understand in Japanese?


Section 4-14

Chapter 4 Summary

📌 Key points to keep in mind in this chapter

5 Memorize the file structure

One module consists of 5 files: fields.json / module.html / module.css / module.js / meta.json. The correct order is to start implementation from fields.json and module.html after generating a template using CLI.

Learn the TOP5 field types

Text / image / boolean / choice / link cover 90% of actual cases. Next, if you master group (repetition), you can create anything from FAQs to card grids.

Existence checks vary by type

image → .src / link → .url.href / group → as is / text → as is. Prevent errors and display breakdowns by making it a habit to check for existence according to the type.

The image is a combination of conversion parameters + loading attribute

Reduce file size with ?width=N&format=webp. LCP elements are eager+high, others lazy+async. How you use it will greatly affect your Core Web Vitals score.

Variant without JS with choice×CSS class

The simplest and easiest to maintain pattern is to embed the value of the choice field as part of the CSS class name and switch the layout and color using only CSS.

Designed to be easy for marketers to use

Japanese labels, explicit image size in help_text, input restrictions by choice, and meaningful default values. Carefulness during development determines the client's self-propelled rate.

Next chapter: Chapter 5 Theme design and construction

We will explain the design philosophy of the entire theme, the integration of multiple templates, global style management, and the practice of central management using theme.json.

Go to Chapter 5 →