NorthJS

Template

Featured collection grid

A responsive product grid section with configurable columns, an optional heading and a "view all" link.

Save as sections/featured-collection-grid.liquid. The grid uses CSS grid with a container query, so it reflows correctly inside narrow page templates as well as full-width layouts — which is how North Start Free lays out its own collection sections.

What to change

  • The card markup, if your theme has its own product-card snippet — replace the inner markup with {% render 'card-product', card_product: product %}.
  • The image aspect ratio, set on .collection-grid__media.
  • The maximum product count, capped at 12 in the schema.

Code

Copy a single file, or download all 3 as an archive. Duplicate your theme before pasting into a live store.

  • template.liquidLiquid
    {% comment %}
      Featured collection grid — sections/featured-collection-grid.liquid
    {% endcomment %}
    
    {%- liquid
      assign collection = collections[section.settings.collection]
      assign limit = section.settings.products_to_show
    -%}
    
    <section class="collection-grid" style="--collection-columns: {{ section.settings.columns }};">
      {%- if section.settings.heading != blank -%}
        <div class="collection-grid__header">
          <h2 class="collection-grid__heading">{{ section.settings.heading | escape }}</h2>
          {%- if section.settings.show_view_all and collection != blank -%}
            <a class="collection-grid__view-all" href="{{ collection.url }}">
              View all
            </a>
          {%- endif -%}
        </div>
      {%- endif -%}
    
      {%- if collection.products_count > 0 -%}
        <ul class="collection-grid__list" role="list">
          {%- for product in collection.products limit: limit -%}
            <li class="collection-grid__item">
              <a class="collection-grid__card" href="{{ product.url }}">
                <div class="collection-grid__media">
                  {%- if product.featured_media -%}
                    {{
                      product.featured_media
                      | image_url: width: 800
                      | image_tag:
                        loading: 'lazy',
                        sizes: '(min-width: 990px) 25vw, 50vw',
                        widths: '200, 400, 600, 800',
                        alt: product.featured_media.alt | default: product.title
                    }}
                  {%- endif -%}
                </div>
                <p class="collection-grid__title">{{ product.title }}</p>
                <p class="collection-grid__price">{{ product.price | money }}</p>
              </a>
            </li>
          {%- endfor -%}
        </ul>
      {%- else -%}
        <p class="collection-grid__empty">Select a collection with products to populate this section.</p>
      {%- endif -%}
    </section>
    
    {% schema %}
    {
      "name": "Featured collection grid",
      "settings": [
        { "type": "text", "id": "heading", "label": "Heading", "default": "Featured products" },
        { "type": "collection", "id": "collection", "label": "Collection" },
        {
          "type": "range",
          "id": "products_to_show",
          "label": "Products to show",
          "min": 2,
          "max": 12,
          "step": 1,
          "default": 8
        },
        {
          "type": "range",
          "id": "columns",
          "label": "Columns on desktop",
          "min": 2,
          "max": 5,
          "step": 1,
          "default": 4
        },
        { "type": "checkbox", "id": "show_view_all", "label": "Show \"View all\" link", "default": true }
      ],
      "presets": [{ "name": "Featured collection grid" }]
    }
    {% endschema %}

  • template.cssCSS
    .collection-grid {
      container-type: inline-size;
      padding-block: 3rem;
    }
    
    .collection-grid__header {
      display: flex;
      flex-wrap: wrap;
      align-items: baseline;
      justify-content: space-between;
      gap: 1rem;
      margin-bottom: 1.5rem;
    }
    
    .collection-grid__heading {
      margin: 0;
      font-size: 1.75rem;
      line-height: 1.2;
    }
    
    .collection-grid__view-all {
      font-size: 0.875rem;
      text-decoration: underline;
      text-underline-offset: 0.2em;
    }
    
    .collection-grid__list {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 1.5rem 1rem;
      margin: 0;
      padding: 0;
    }
    
    @container (min-width: 48rem) {
      .collection-grid__list {
        grid-template-columns: repeat(var(--collection-columns, 4), minmax(0, 1fr));
      }
    }
    
    .collection-grid__card {
      display: grid;
      gap: 0.375rem;
      color: inherit;
      text-decoration: none;
    }
    
    .collection-grid__media {
      aspect-ratio: 3 / 4;
      overflow: hidden;
      background: rgb(0 0 0 / 0.04);
    }
    
    .collection-grid__media img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
    
    .collection-grid__title,
    .collection-grid__price {
      margin: 0;
      font-size: 0.9375rem;
    }
    
    .collection-grid__price {
      color: rgb(0 0 0 / 0.65);
    }
    
    .collection-grid__empty {
      margin: 0;
      color: rgb(0 0 0 / 0.65);
    }

  • template.jsJavaScript
    (() => {
      // Progressive enhancement only: reveal cards as they scroll into view.
      // The grid is fully rendered and navigable with JavaScript disabled.
      function init() {
        const items = document.querySelectorAll('.collection-grid__item');
        if (!items.length) return;
        if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
        if (!('IntersectionObserver' in window)) return;
    
        const observer = new IntersectionObserver(
          (entries) => {
            for (const entry of entries) {
              if (!entry.isIntersecting) continue;
              entry.target.classList.add('is-visible');
              observer.unobserve(entry.target);
            }
          },
          { rootMargin: '0px 0px -10% 0px' },
        );
    
        items.forEach((item) => observer.observe(item));
      }
    
      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
      } else {
        init();
      }
    
      document.addEventListener('shopify:section:load', init);
    })();