Submenu columns not aligned under parent links in mega menu

Topic summary

Issue: In a custom Shopify mega menu, submenu columns are not aligning directly beneath their parent top-level links. The desired behavior is for each submenu to appear directly under its parent link.

Context: Implemented in Liquid as a theme customization. A storefront preview URL and password are provided, along with a screenshot showing the misalignment.

Shared code: A ‘mega-menu-list’ snippet with parameters for grid columns (desktop/tablet), content type (featured_products, featured_collections, collection_images, text), image aspect/border radius, and section. The code tracks open_column_span to stack links within columns, counts columns, and limits columns per device.

Key logic: Closes column spans based on next_linklist and whether current/next items have child links; explicitly outputs closing . Post-loop logic allocates columns for featured products/collections (desktop/tablet), considers eager_loading to cap product columns, and updates max_menu_columns. The snippet is partially truncated.

Ask: Guidance to ensure submenus render directly under their respective parent links.

Status: No replies or resolution yet. The screenshot and Liquid code are central to diagnosing the issue.

Summarized with AI on December 22. AI used: gpt-5.

Hi!
I’m working on a custom mega menu in Liquid for my Shopify store. The structure of my menu is something like this:


I want the submenu to appear directly under its parent link.

password: 1

{%- doc -%}
  Renders mega menu list markup and optional featured content.

  @param {object} parent_link - The linklist to render.
  @param {string} id - Unique ID to assign to the `<ul>` element.
  @param {number} [grid_columns_count] - Number of grid columns for the mega menu.
  @param {number} [grid_columns_count_tablet] - Number of grid columns for the mega menu on tablets.
  @param {number} [grid_columns_count_collection_images] - Number of grid columns when `menu_content_type` is 'collection_images'.
  @param {string} [menu_content_type] - Type of content: 'featured_products', 'featured_collections', 'collection_images', or 'text'.
  @param {number} [content_aspect_ratio] - Aspect ratio for content images.
  @param {number} [image_border_radius] - Border radius for content images.
  @param {object} [section] - The section object.

  @example
  {% render 'mega-menu-list', parent_link: link, id: 'MegaMenuList-1', grid_columns_count: 6, menu_content_type: 'featured_products' %}
{%- enddoc -%}

{% liquid
  comment
    open_column_span tracks when a vertical column in the mega menu is open. Links will be stacked
    in the column until the code closes the column span.
  endcomment
  assign open_column_span = false
  assign column_count = 0
  assign links_before_wrap = 10
  assign max_menu_columns = grid_columns_count | default: 6
  assign max_menu_columns_tablet = grid_columns_count_tablet | default: 4

  ##
  # We only eager load heavy elements of the page when rendering this template
  # through the Section Rendering API.
  #
  # This keeps the initial render lightweight, minimizing the impact on TTFB.
  # The second render then refreshes the page with additional content.
  #
  # At the moment, we check if the Section Rendering API is being used by
  # checking if section.index is blank.
  assign eager_loading = false
  if section.index == blank
    assign eager_loading = true
  endif

  if menu_content_type == 'collection_images'
    assign collection_links = parent_link.links | where: 'type', 'collection_link'
    assign catalog_links = parent_link.links | where: 'type', 'catalog_link'
    assign collection_list_links = parent_link.links | where: 'type', 'collections_link'
    if collection_links.size == 0 and catalog_links.size == 0 and collection_list_links.size == 0
      assign menu_content_type = 'text'
    endif
  endif

  if menu_content_type == 'featured_collections'
    if parent_link.type == 'collection_link'
      assign collection_handles = parent_link.object.handle | append: ','
    elsif parent_link.type == 'catalog_link' or parent_link.type == 'collections_link'
      assign collection_handles = 'all' | append: ','
    endif
    assign collection_links = parent_link.links | where: 'type', 'collection_link'
    for collection_link in collection_links
      assign collection_handles = collection_handles | append: collection_link.object.handle | append: ','
    endfor
  endif

  if menu_content_type == 'featured_products'
    if parent_link.type == 'collection_link'
      assign collection_object = parent_link.object
    elsif parent_link.type == 'catalog_link' or parent_link.type == 'collections_link'
      assign collection_object = collections.all
    else
      assign menu_content_type = 'text'
    endif
  endif
%}
<ul
  data-menu-list-id="{{ id }}"
  class="mega-menu__list list-unstyled"
  style="--menu-image-border-radius: {{ image_border_radius }}px;"
>
  {% for link in parent_link.links %}
    {% liquid
      # Decide whether to open a column span, and how many columns of the grid to span
      if forloop.first or open_column_span == false
        assign open_column_span = true

        # Don't allow orphan links in a column
        assign break_after_modulo = link.links.size | modulo: links_before_wrap
        if break_after_modulo == 1
          assign links_before_wrap = links_before_wrap | minus: 1
        endif
        assign break_after_float = links_before_wrap | times: 1.0
        assign column_span = link.links.size | divided_by: break_after_float | ceil | at_least: 1
        assign column_count = column_count | plus: column_span
        assign should_break_columns = true

        if menu_content_type == 'collection_images'
          assign should_break_columns = false
          # For collection image mode, we have an 8-column grid when 4 or fewer parent links are used. Set column span to 2 in that case, otherwise use 1 column
          if parent_link.links.size <= 4
            assign span_class = 'mega-menu__column--wide-collection-image'
          else
            assign span_class = 'mega-menu__column--span-1'
          endif
        else
          # For all other modes, column span is determined by the number of links in the parent link
          assign span_class = 'mega-menu__column--span-' | append: column_span
        endif
        echo '<li class="mega-menu__column ' | append: span_class | append: '">'
      endif
    %}
    <div>
      <a
        href="{{ link.url }}"
        class="mega-menu__link {% if link.links != blank %}mega-menu__link--parent{% endif %}"
      >
        {% if menu_content_type == 'collection_images' %}
          {%- capture collection_image_sizes -%}
            {%- if parent_link.links.size <= 4 -%}
              {%- assign columns_per_item = 2 -%}
            {%- else -%}
              {%- assign columns_per_item = 1 -%}
            {%- endif -%}
            {%- render 'util-mega-menu-img-sizes-attr',
              menu_content_type: 'collection_images',
              settings: settings,
              grid_columns: grid_columns_count,
              grid_columns_tablet: grid_columns_count_tablet,
              grid_columns_collection_images: grid_columns_count_collection_images,
              parent_links_size: parent_link.links.size,
              columns_per_item: columns_per_item
            -%}
          {%- endcapture -%}

          {% render 'link-featured-image', link: link, class: 'mega-menu__link-image', sizes: collection_image_sizes %}
        {% endif %}
        <span
          class="mega-menu__link-title wrap-text"
        >
          {{- link.title -}}
        </span>
      </a>
      {% if link.links != blank %}
        <ul
          class="list-unstyled"
          {% if should_break_columns %}
            style="column-count: {{ column_span }};"
          {% endif %}
        >
          {% for childLink in link.links %}
            {% assign break_after_modulo = forloop.index | modulo: links_before_wrap %}
            <li
              {% if break_after_modulo == 0 and should_break_columns %}
                style="break-after: column;"
              {% endif %}
            >
              <a
                href="{{ childLink.url }}"
                class="mega-menu__link"
              >
                <span class="mega-menu__link-title wrap-text">{{- childLink.title -}}</span>
              </a>
            </li>
          {% endfor %}
        </ul>
      {% endif %}
    </div>

    {% liquid
      # Check conditions for closing the span at the end of each iteration, then close the span if needed
      assign next_linklist = parent_link.links[forloop.index]

      if forloop.last
        assign open_column_span = false
      elsif menu_content_type == 'collection_images'
        assign open_column_span = false
      elsif next_linklist.links != blank
        assign open_column_span = false
      elsif link.links != blank and next_linklist.links == blank
        assign open_column_span = false
      endif

      if open_column_span == false
        echo '</li>'
      endif
    %}
  {% endfor %}
</ul>

{% liquid
  # decide how many grid columns are needed for the menu list, and how many columns are needed for the featured content
  # prioritize a minimum of 1 featured_collection (2 columns), and minimum 2 featured_products (2 columns)
  assign min_products = 2
  assign max_products = 3
  assign min_products_tablet = 1
  assign max_products_tablet = 3
  assign min_collections = 1

  if menu_content_type == 'featured_products'
    # desktop breakpoint
    assign temp_column_count = column_count | plus: min_products

    if temp_column_count > max_menu_columns
      assign max_product_columns = 2
    else
      assign max_product_columns = max_menu_columns | minus: column_count | at_most: max_products
    endif

    if eager_loading
      assign max_product_columns = max_product_columns | at_most: collection_object.products_count
    else
      assign max_product_columns = 0
    endif

    assign max_featured_products = max_product_columns
    assign max_menu_columns = max_menu_columns | minus: max_product_columns

    # tablet breakpoint
    assign temp_column_count = column_count | plus: min_products_tablet

    if temp_column_count > max_menu_columns_tablet
      assign max_product_columns_tablet = 1
    else
      assign max_product_columns_tablet = max_menu_columns_tablet | minus: column_count | at_most: max_products_tablet
    endif

    assign max_product_columns_tablet = max_product_columns_tablet | at_most: max_product_columns

    assign max_featured_products_tablet = max_product_columns_tablet
    assign max_menu_columns_tablet = max_menu_columns_tablet | minus: max_product_columns_tablet
  endif

  if menu_content_type == 'featured_collections'
    # desktop breakpoint
    assign min_featured_collection_columns = min_collections | times: 2
    assign temp_column_count = column_count | plus: min_featured_collection_columns

    if temp_column_count > max_menu_columns
      assign max_collection_columns = 2
    else
      assign max_collection_columns = max_menu_columns | minus: column_count
    endif

    assign max_featured_collections = max_collection_columns | divided_by: 2 | floor
    assign max_menu_columns = max_menu_columns | minus: max_collection_columns

    # tablet breakpoint
    assign max_collection_columns_tablet = 2
    assign max_featured_collections_tablet = 1
    assign max_menu_columns_tablet = max_menu_columns_tablet | minus: max_collection_columns_tablet
  endif
%}

{% style %}
  [data-menu-grid-id="{{ id }}"] {
    {% if menu_content_type == 'collection_images' and parent_link.links.size < 5 %}
      --menu-columns-desktop: {{ grid_columns_count_collection_images }};
      --menu-columns-tablet: {{ grid_columns_count_tablet }};
    {% else %}
      --menu-columns-desktop: {{ grid_columns_count }};
      --menu-columns-tablet: {{ grid_columns_count_tablet }};
    {% endif %}
  }

  [data-menu-list-id="{{ id }}"] {
    {% if menu_content_type == 'collection_images' and parent_link.links.size < 5 %}
      --menu-columns-desktop: {{ grid_columns_count_collection_images }};
      --menu-columns-tablet: {{ max_menu_columns_tablet }};
    {% else %}
      --menu-columns-desktop: {{ max_menu_columns }};
      --menu-columns-tablet: {{ max_menu_columns_tablet }};
    {% endif %}
  }
{% endstyle %}

{% case menu_content_type %}
  {% when 'featured_products' %}
    {%- capture image_sizes -%}
      {%- render 'util-mega-menu-img-sizes-attr',
        menu_content_type: 'featured_products',
        settings: settings,
        grid_columns: grid_columns_count,
        grid_columns_tablet: grid_columns_count_tablet
      -%}
    {%- endcapture -%}

    <span
      class="mega-menu__content"
      style="--menu-content-columns-desktop: {{ max_product_columns }}; --menu-content-columns-tablet: {{ max_product_columns_tablet }}; --resource-card-corner-radius: {{ image_border_radius }}px;"
    >
      <ul
        class="mega-menu__content-list mega-menu__content-list--products list-unstyled"
      >
        {% if eager_loading %}
          {% paginate collection_object.products by max_featured_products %}
            {% for item in collection_object.products %}
              <li class="mega-menu__content-list-item {% if forloop.index > max_featured_products_tablet %} mega-menu__content-list-item--hidden-tablet{% endif %}">
                {% render 'resource-card',
                  resource: item,
                  resource_type: 'product',
                  image_hover: true,
                  image_aspect_ratio: content_aspect_ratio,
                  image_sizes: image_sizes
                %}
              </li>
            {% endfor %}
          {% endpaginate %}
        {% endif %}
      </ul>
    </span>
  {% when 'featured_collections' %}
    {% assign collection_handles = collection_handles | split: ',' | uniq %}
    {%- capture image_sizes -%}
      {%- render 'util-mega-menu-img-sizes-attr',
        menu_content_type: 'featured_collections',
        settings: settings
      -%}
    {%- endcapture -%}

    {% if collection_handles.size == 1 %}
      {% assign max_featured_collections = 1 %}
    {% endif %}

    <span
      class="mega-menu__content"
      style="--menu-content-columns-desktop: {{ max_collection_columns }}; --menu-content-columns-tablet: {{ max_collection_columns_tablet }}; --resource-card-corner-radius: {{ image_border_radius }}px;"
    >
      <ul
        class="mega-menu__content-list mega-menu__content-list--collections list-unstyled"
        style="--menu-content-columns-desktop: {{ max_featured_collections }}; --menu-content-columns-tablet: {{ max_featured_collections_tablet }};"
      >
        {% for handle in collection_handles limit: max_featured_collections %}
          {% if handle == 'all' %}
            {% assign collection_object = collections.all %}
          {% else %}
            {% assign collection_object = collections[handle] %}
          {% endif %}
          <li class="mega-menu__content-list-item{% if forloop.index > max_featured_collections_tablet %} mega-menu__content-list-item--hidden-tablet{% endif %}">
            {% render 'resource-card',
              resource: collection_object,
              resource_type: 'collection',
              style: 'overlay',
              image_aspect_ratio: content_aspect_ratio,
              image_sizes: image_sizes
            %}
          </li>
        {% endfor %}
      </ul>
    </span>
{% endcase %}

Hi, thanks for sharing the code and context.

The misalignment isn’t CSS related it comes from the Liquid logic. Right now, submenu columns are calculated based on link volume (open_column_span, column_span, column-count) instead of being structurally tied to each top-level link. This allows multiple parents to share columns, which causes submenus to drift horizontally.

The fix is to:

  • Bind each submenu to its own parent column (1 parent = 1 column container)

  • Remove column-count for submenus and use Grid/Flex inside each submenu instead

  • Let CSS handle layout, and simplify Liquid to control structure only

I can refactor the snippet so each submenu renders directly under its parent while keeping your featured content and responsive behavior intact.

Yes, I’ve managed to do that now. But I can’t make the “cover” as long as in the screenshot. At the same time, it is necessary that the columns remain under the “parent”

password: 1

{% liquid
  
  assign block_settings = block.settings
  assign menu_content_type = block_settings.menu_style | default: 'text'
  assign image_border_radius = block_settings.image_border_radius
  assign color_scheme_classes = ''
  assign color_scheme_setting_id = 'color_scheme_' | append: section.settings.menu_row
  assign current_color_scheme = block_settings.color_scheme
  assign parent_color_scheme = section.settings[color_scheme_setting_id]

  if parent_color_scheme.id != current_color_scheme.id
    assign color_scheme_classes = ' color-' | append: current_color_scheme
  endif

  # Check if header and menu colors match. This is used to apply different padding styles in css
  if parent_color_scheme.settings.background.rgb == current_color_scheme.settings.background.rgb
    assign color_scheme_classes = color_scheme_classes | append: ' color-scheme-matches-parent'
  endif

  if block_settings.menu_style == 'featured_collections'
    assign ratio = block_settings.featured_collections_aspect_ratio
  elsif block_settings.menu_style == 'featured_products'
    assign ratio = block_settings.featured_products_aspect_ratio
  endif
%}
<style>
.menu-list__link {
  display: flex;
  {% comment %} justify-content: space-between; {% endcomment %}
  align-items: center;
}

.menu-list__link-arrow {
  margin-left: 0.5rem;
  font-size: 0.8em;
  transition: transform 0.3s ease;
}

.menu-list__list-item:hover .menu-list__link-arrow {
  transform: rotate(180deg);
}

  </style>
{% comment %} vbb {% endcomment %}
{% assign menu = menu | default: block_settings.menu %}
{% comment %} vbb {% endcomment %}
{% capture children %}
  {% comment %} vbb {% endcomment %}
{% for link in menu.links %}
    {% comment %} vbb {% endcomment %}
    <li
      role="presentation"
      class="menu-list__list-item"
      on:focus="/activate"
      on:blur="/deactivate"
      on:pointerenter="/activate"
      on:pointerleave="/deactivate"
    >
      <a
        href="{{ link.url }}"
        data-skip-node-update="true"
        class="menu-list__link{% if link.active %} menu-list__link--active{% endif %}"
        {%- if link.links != blank -%}
          aria-controls="submenu-{{ forloop.index }}"
          aria-haspopup="true"
          aria-expanded="false"
        {%- endif -%}
        ref="menuitem"
      >
        <span class="menu-list__link-title">{{- link.title -}}</span>
  {%- if link.links != blank -%}
    <span class="menu-list__link-arrow">▼</span>
  {%- endif -%}

      </a>
      {%- if link.links != blank -%}
        <div class="menu-list__submenu{{ color_scheme_classes }}" ref="submenu[]">
          <div
      id="submenu-{{ forloop.index }}"
      class="menu-list__submenu-inner"
      style="{% render 'submenu-font-styles', settings: block_settings %}"
    >
      <ul class="simple-submenu cover">
        {% for child in link.links %}
          <li><a href="{{ child.url }}">{{ child.title }}</a></li>
        {% endfor %}
      </ul>
    </div>
        </div>
      {%- endif -%}
    </li>
  {% endfor %}
  {% comment %} <li
    class="menu-list__list-item"
    role="presentation"
    slot="more"
    on:focus="/activate"
    on:blur="/deactivate"
    on:pointerenter="/activate"
    on:pointerleave="/deactivate"
  >
    <button role="menuitem" class="button menu-list__link button-unstyled">
      <span class="menu-list__link-title">{{ 'actions.more' | t }}</span>
    </button>
  </li> {% endcomment %}
{% endcapture %}

<nav header-menu>
  <div
    class="menu-list"
    style="{% render 'menu-font-styles', settings: block_settings %}"
  >
    {% assign class = 'overflow-menu' | append: color_scheme_classes %}
    {% render 'overflow-list',
      class: class,
      ref: 'overflowMenu',
      children: children,
      minimum-items: 2,
      data-testid: 'header-menu-overflow-list',
      attributes: 'data-skip-node-update'
    %}
  </div>
</nav>
<style>
.header-menu__inner.header-menu__inner--split {
    display: flex;
    justify-content: space-between;
    align-items: center;
}
.header__columns.spacing-style {
    display: block;
}
.menu-list__list-item {
    position: static;
}
.menu-list__list-item > .menu-list__submenu {
    position: absolute;
    top: 100%;
    {% comment %} left: 50%;
    transform: translateX(-50%); {% endcomment %}
    padding: 1rem;
    min-width: max-content;
    text-align: center;
    text-transform:none;
}
.simple-submenu {
  list-style: none;
  margin: 0;
  padding: 15px;
}
.simple-submenu li {
  margin: 0;
}
.simple-submenu li a{
  color: black;
}
.cover {
  width: 100%;
  max-width: 1400px;
  background: #FFFFFF85;
}

.menu-list {
  position: relative;
}

.menu-list__submenu {
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
}
.menu-list__list-item {
  position: relative;
}

.simple-submenu {
  display: inline-block;
}

</style>