Theme Components

It's time to build real, production-quality theme components. This chapter contains seven practical exercises โ€” each builds a component found in premium Shopify themes. By the end, you'll have a library of reusable components you can use in any project.

๐Ÿ“Œ Chapter Overview

Time estimate: 3 โ€“ 4 hours (all exercises)
Tags: Liquid HTML CSS JavaScript

Components We'll Build

๐Ÿ–ผ๏ธ

Hero Banner

Full-width image banner with overlay text, CTA button, and customizable alignment.

๐Ÿƒ

Product Card

Reusable product card snippet with image, price, vendor, and sale badge.

๐Ÿ“ฆ

Featured Collection

Collection grid section with configurable columns and product count.

๐Ÿ“ข

Announcement Bar

Dismissible top bar for promotions with auto-rotating messages.

๐Ÿงญ

Mega Menu

Multi-column dropdown navigation with images and featured links.

๐Ÿ“Œ

Sticky Add-to-Cart

Fixed bottom bar on product pages showing product info and buy button.

๐Ÿ›’

Product Page Customization

Customize Dawn's product page with enhanced features and layout.

Exercise 1: Hero Banner Section

๐Ÿงช
Exercise 1

Build a Hero Banner Section

โฑ๏ธ 30 minutes ๐Ÿ“ sections/custom-hero.liquid ๐Ÿ“ assets/section-custom-hero.css

Learning objective: Build a full-width hero banner with background image, overlay, heading, subtext, and CTA button โ€” all configurable through the theme editor.

Features:

  • Full-width background image with overlay opacity control
  • Heading, subheading, and button (via blocks for flexibility)
  • Text alignment control (left, center, right)
  • Minimum height setting
  • Responsive design
  • Accessible contrast
sections/custom-hero.liquid
{{ 'section-custom-hero.css' | asset_url | stylesheet_tag }}

{%- style -%}
  .section-{{ section.id }} {
    min-height: {{ section.settings.min_height }}px;
  }

  .section-{{ section.id }} .hero__overlay {
    background-color: rgba(0, 0, 0, {{ section.settings.overlay_opacity | divided_by: 100.0 }});
  }

  .section-{{ section.id }} .hero__content {
    text-align: {{ section.settings.text_alignment }};
    align-items: {% case section.settings.text_alignment %}
      {% when 'left' %}flex-start
      {% when 'right' %}flex-end
      {% else %}center
    {% endcase %};
  }

  .section-{{ section.id }} .hero__heading {
    color: {{ section.settings.heading_color }};
    font-size: {{ section.settings.heading_size }}px;
  }

  @media (max-width: 749px) {
    .section-{{ section.id }} {
      min-height: {{ section.settings.min_height | times: 0.6 | round }}px;
    }
    .section-{{ section.id }} .hero__heading {
      font-size: {{ section.settings.heading_size | times: 0.6 | round }}px;
    }
  }
{%- endstyle -%}

<section class="hero section-{{ section.id }}">
  {%- if section.settings.image -%}
    <img
      class="hero__image"
      src="{{ section.settings.image | image_url: width: 1920 }}"
      srcset="
        {{ section.settings.image | image_url: width: 750 }} 750w,
        {{ section.settings.image | image_url: width: 1100 }} 1100w,
        {{ section.settings.image | image_url: width: 1500 }} 1500w,
        {{ section.settings.image | image_url: width: 1920 }} 1920w
      "
      sizes="100vw"
      alt="{{ section.settings.image.alt | escape | default: section.settings.heading }}"
      width="{{ section.settings.image.width }}"
      height="{{ section.settings.image.height }}"
      loading="{% if section.index == 1 %}eager{% else %}lazy{% endif %}"
      fetchpriority="{% if section.index == 1 %}high{% else %}auto{% endif %}"
    >
  {%- endif -%}

  <div class="hero__overlay"></div>

  <div class="hero__content page-width">
    {%- for block in section.blocks -%}
      {%- case block.type -%}

        {%- when 'heading' -%}
          <h1 class="hero__heading" {{ block.shopify_attributes }}>
            {{ block.settings.heading | escape }}
          </h1>

        {%- when 'subheading' -%}
          <p class="hero__subheading" {{ block.shopify_attributes }}>
            {{ block.settings.subheading | escape }}
          </p>

        {%- when 'button' -%}
          {%- if block.settings.button_label != blank -%}
            <a
              href="{{ block.settings.button_link }}"
              class="hero__button btn btn--{{ block.settings.button_style }}"
              {{ block.shopify_attributes }}
            >
              {{ block.settings.button_label | escape }}
            </a>
          {%- endif -%}

      {%- endcase -%}
    {%- endfor -%}
  </div>
</section>

{% schema %}
{
  "name": "Hero Banner",
  "tag": "section",
  "class": "section-hero",
  "limit": 3,
  "settings": [
    {
      "type": "image_picker",
      "id": "image",
      "label": "Background image",
      "info": "Recommended: 1920 x 800px minimum"
    },
    {
      "type": "range",
      "id": "min_height",
      "label": "Minimum height",
      "min": 300,
      "max": 800,
      "step": 20,
      "default": 500,
      "unit": "px"
    },
    {
      "type": "range",
      "id": "overlay_opacity",
      "label": "Overlay darkness",
      "min": 0,
      "max": 80,
      "step": 5,
      "default": 40,
      "unit": "%",
      "info": "Makes text more readable over images"
    },
    {
      "type": "select",
      "id": "text_alignment",
      "label": "Text alignment",
      "options": [
        { "value": "left", "label": "Left" },
        { "value": "center", "label": "Center" },
        { "value": "right", "label": "Right" }
      ],
      "default": "center"
    },
    {
      "type": "color",
      "id": "heading_color",
      "label": "Heading color",
      "default": "#ffffff"
    },
    {
      "type": "range",
      "id": "heading_size",
      "label": "Heading size",
      "min": 24,
      "max": 72,
      "step": 2,
      "default": 48,
      "unit": "px"
    }
  ],
  "blocks": [
    {
      "type": "heading",
      "name": "Heading",
      "limit": 1,
      "settings": [
        {
          "type": "text",
          "id": "heading",
          "label": "Heading text",
          "default": "Welcome to Our Store"
        }
      ]
    },
    {
      "type": "subheading",
      "name": "Subheading",
      "limit": 1,
      "settings": [
        {
          "type": "text",
          "id": "subheading",
          "label": "Subheading text",
          "default": "Discover our latest collection of premium products"
        }
      ]
    },
    {
      "type": "button",
      "name": "Button",
      "limit": 2,
      "settings": [
        {
          "type": "text",
          "id": "button_label",
          "label": "Button label",
          "default": "Shop Now"
        },
        {
          "type": "url",
          "id": "button_link",
          "label": "Button link"
        },
        {
          "type": "select",
          "id": "button_style",
          "label": "Button style",
          "options": [
            { "value": "primary", "label": "Primary (filled)" },
            { "value": "outline", "label": "Outline" }
          ],
          "default": "primary"
        }
      ]
    }
  ],
  "presets": [
    {
      "name": "Hero Banner",
      "blocks": [
        { "type": "heading" },
        { "type": "subheading" },
        { "type": "button" }
      ]
    }
  ]
}
{% endschema %}
assets/section-custom-hero.css
.hero {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  width: 100%;
}

.hero__image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: 1;
}

.hero__overlay {
  position: absolute;
  inset: 0;
  z-index: 2;
}

.hero__content {
  position: relative;
  z-index: 3;
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
  padding: 3rem 1.5rem;
  max-width: 800px;
}

.hero__heading {
  font-weight: 800;
  line-height: 1.15;
  letter-spacing: -0.02em;
  margin: 0;
}

.hero__subheading {
  color: rgba(255, 255, 255, 0.9);
  font-size: 1.125rem;
  line-height: 1.6;
  margin: 0;
  max-width: 600px;
}

.hero__button {
  display: inline-block;
  padding: 0.875rem 2rem;
  font-size: 0.9375rem;
  font-weight: 600;
  text-decoration: none;
  border-radius: 6px;
  transition: all 0.2s ease;
  cursor: pointer;
  width: fit-content;
}

.btn--primary {
  background-color: #fff;
  color: #1a1a2e;
  border: 2px solid #fff;
}

.btn--primary:hover {
  background-color: transparent;
  color: #fff;
}

.btn--outline {
  background-color: transparent;
  color: #fff;
  border: 2px solid #fff;
}

.btn--outline:hover {
  background-color: #fff;
  color: #1a1a2e;
}

@media (max-width: 749px) {
  .hero__content {
    padding: 2rem 1rem;
  }
  .hero__subheading {
    font-size: 1rem;
  }
}

Exercise 2: Product Card Snippet

๐Ÿงช
Exercise 2

Create a Reusable Product Card Snippet

โฑ๏ธ 25 minutes ๐Ÿ“ snippets/custom-product-card.liquid ๐Ÿ“ assets/component-product-card.css

Learning objective: Build a versatile product card snippet that handles sale badges, secondary hover images, vendor display, and proper image optimization.

Features:

  • Sale badge with percentage calculation
  • Secondary image on hover
  • Compare-at price display
  • Sold-out state
  • Responsive images with srcset
  • Accessible link structure
snippets/custom-product-card.liquid
{%- comment -%}
  ============================================================
  Custom Product Card
  ============================================================
  Accepts:
  - product:              {Object}  Product object (required)
  - show_vendor:          {Boolean} Show vendor name (default: false)
  - show_secondary_image: {Boolean} Show 2nd image on hover (default: true)
  - show_sale_badge:      {Boolean} Show sale badge (default: true)
  - image_ratio:          {String}  'square'|'portrait'|'adapt' (default: 'adapt')
  - lazy_load:            {Boolean} Lazy load image (default: true)

  Usage:
  {% render 'custom-product-card',
    product: product,
    show_vendor: true,
    show_secondary_image: true
  %}
  ============================================================
{%- endcomment -%}

{%- liquid
  assign show_vendor_val = show_vendor | default: false
  assign show_secondary = show_secondary_image | default: true
  assign show_badge = show_sale_badge | default: true
  assign ratio = image_ratio | default: 'adapt'
  assign lazy = lazy_load | default: true
  assign on_sale = false
  assign sold_out = false

  if product.compare_at_price > product.price
    assign on_sale = true
    assign savings_pct = product.compare_at_price | minus: product.price | times: 100 | divided_by: product.compare_at_price | round
  endif

  unless product.available
    assign sold_out = true
  endunless

  assign secondary_image = nil
  if show_secondary and product.images.size > 1
    assign secondary_image = product.images[1]
  endif
-%}

<div class="product-card{% if sold_out %} product-card--sold-out{% endif %}">
  <a href="{{ product.url }}" class="product-card__link"
     aria-label="{{ product.title | escape }}">

    <!-- Image Container -->
    <div class="product-card__media product-card__media--{{ ratio }}">
      {%- if product.featured_image -%}
        <img
          class="product-card__image product-card__image--primary"
          src="{{ product.featured_image | image_url: width: 600 }}"
          srcset="
            {{ product.featured_image | image_url: width: 300 }} 300w,
            {{ product.featured_image | image_url: width: 450 }} 450w,
            {{ product.featured_image | image_url: width: 600 }} 600w
          "
          sizes="(max-width: 749px) calc(50vw - 24px), (max-width: 999px) 33vw, 25vw"
          alt="{{ product.featured_image.alt | escape | default: product.title | escape }}"
          width="{{ product.featured_image.width }}"
          height="{{ product.featured_image.height }}"
          {% if lazy %}loading="lazy" decoding="async"{% endif %}
        >

        {%- if secondary_image -%}
          <img
            class="product-card__image product-card__image--secondary"
            src="{{ secondary_image | image_url: width: 600 }}"
            alt="{{ secondary_image.alt | escape | default: product.title | escape }}"
            width="{{ secondary_image.width }}"
            height="{{ secondary_image.height }}"
            loading="lazy"
            decoding="async"
          >
        {%- endif -%}
      {%- else -%}
        <div class="product-card__placeholder">
          {{ 'product-1' | placeholder_svg_tag: 'placeholder-svg' }}
        </div>
      {%- endif -%}

      <!-- Badges -->
      <div class="product-card__badges">
        {%- if sold_out -%}
          <span class="badge badge--sold-out">
            {{ 'products.product.sold_out' | t | default: 'Sold out' }}
          </span>
        {%- elsif on_sale and show_badge -%}
          <span class="badge badge--sale">-{{ savings_pct }}%</span>
        {%- endif -%}
      </div>
    </div>

    <!-- Product Info -->
    <div class="product-card__info">
      {%- if show_vendor_val and product.vendor != blank -%}
        <span class="product-card__vendor">{{ product.vendor }}</span>
      {%- endif -%}

      <h3 class="product-card__title">{{ product.title | escape }}</h3>

      <div class="product-card__price">
        {%- if on_sale -%}
          <s class="product-card__compare-price">
            {{ product.compare_at_price | money }}
          </s>
          <span class="product-card__sale-price">
            {{ product.price | money }}
          </span>
        {%- else -%}
          <span class="product-card__regular-price">
            {%- if product.price_varies -%}
              {{ 'products.product.price.from' | t: price: product.price | money | default: product.price | money }}
            {%- else -%}
              {{ product.price | money }}
            {%- endif -%}
          </span>
        {%- endif -%}
      </div>
    </div>
  </a>
</div>
assets/component-product-card.css
.product-card {
  position: relative;
}

.product-card__link {
  text-decoration: none;
  color: inherit;
  display: block;
}

.product-card__media {
  position: relative;
  overflow: hidden;
  border-radius: 8px;
  background-color: #f5f5f5;
  margin-bottom: 0.75rem;
}

.product-card__media--adapt { aspect-ratio: auto; }
.product-card__media--square { aspect-ratio: 1 / 1; }
.product-card__media--portrait { aspect-ratio: 3 / 4; }

.product-card__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: opacity 0.4s ease, transform 0.4s ease;
}

.product-card__image--secondary {
  position: absolute;
  inset: 0;
  opacity: 0;
}

.product-card:hover .product-card__image--primary {
  opacity: 0;
}

.product-card:hover .product-card__image--secondary {
  opacity: 1;
}

.product-card:hover .product-card__image--primary:only-child {
  opacity: 1;
  transform: scale(1.05);
}

.product-card__badges {
  position: absolute;
  top: 0.5rem;
  left: 0.5rem;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  z-index: 2;
}

.badge {
  display: inline-block;
  padding: 0.2rem 0.6rem;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: 700;
  line-height: 1.4;
}

.badge--sale {
  background-color: #ef4444;
  color: #fff;
}

.badge--sold-out {
  background-color: #6b7280;
  color: #fff;
}

.product-card__info {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.product-card__vendor {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: #718096;
  font-weight: 600;
}

.product-card__title {
  font-size: 0.9375rem;
  font-weight: 500;
  line-height: 1.4;
  margin: 0;
  color: #1a1a2e;
}

.product-card__price {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.9375rem;
}

.product-card__compare-price {
  color: #9ca3af;
  text-decoration: line-through;
  font-size: 0.875rem;
}

.product-card__sale-price {
  color: #ef4444;
  font-weight: 600;
}

.product-card__regular-price {
  font-weight: 500;
  color: #1a1a2e;
}

.product-card--sold-out .product-card__image--primary {
  opacity: 0.6;
}
๐Ÿงช
Exercise 3

Build a Featured Collection Grid

โฑ๏ธ 20 minutes ๐Ÿ“ sections/custom-featured-collection.liquid

Learning objective: Combine the product card snippet with a section schema to create a configurable collection grid with the collection picker, column control, and "View all" link.

sections/custom-featured-collection.liquid
{{ 'component-product-card.css' | asset_url | stylesheet_tag }}

{%- style -%}
  .section-{{ section.id }} {
    padding-top: {{ section.settings.padding_top }}px;
    padding-bottom: {{ section.settings.padding_bottom }}px;
  }

  .section-{{ section.id }} .collection-grid {
    grid-template-columns: repeat({{ section.settings.columns }}, 1fr);
  }

  @media (max-width: 999px) {
    .section-{{ section.id }} .collection-grid {
      grid-template-columns: repeat(2, 1fr);
    }
  }

  @media (max-width: 549px) {
    .section-{{ section.id }} .collection-grid {
      grid-template-columns: repeat(2, 1fr);
      gap: 1rem;
    }
  }
{%- endstyle -%}

{%- assign collection = section.settings.collection -%}

<section class="featured-collection section-{{ section.id }}">
  <div class="page-width">

    {%- if section.settings.heading != blank -%}
      <div class="featured-collection__header">
        <h2 class="featured-collection__heading">
          {{ section.settings.heading | escape }}
        </h2>
        {%- if section.settings.show_view_all and collection -%}
          <a href="{{ collection.url }}" class="featured-collection__view-all">
            {{ 'general.view_all' | t | default: 'View all' }} โ†’
          </a>
        {%- endif -%}
      </div>
    {%- endif -%}

    {%- if collection != blank and collection.products.size > 0 -%}
      <div class="collection-grid">
        {%- for product in collection.products limit: section.settings.products_to_show -%}
          {% render 'custom-product-card',
            product: product,
            show_vendor: section.settings.show_vendor,
            show_secondary_image: section.settings.show_secondary_image,
            show_sale_badge: true,
            lazy_load: true
          %}
        {%- endfor -%}
      </div>
    {%- else -%}
      <div class="collection-grid">
        {%- for i in (1..section.settings.products_to_show) -%}
          <div class="product-card product-card--placeholder">
            <div class="product-card__media product-card__media--square">
              {{ 'product-1' | placeholder_svg_tag: 'placeholder-svg' }}
            </div>
            <div class="product-card__info">
              <h3 class="product-card__title">Product Title</h3>
              <div class="product-card__price">
                <span>$29.99</span>
              </div>
            </div>
          </div>
        {%- endfor -%}
      </div>
    {%- endif -%}

  </div>
</section>

{% schema %}
{
  "name": "Featured Collection",
  "tag": "section",
  "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": 4
    },
    {
      "type": "range",
      "id": "columns",
      "label": "Columns (desktop)",
      "min": 2,
      "max": 5,
      "step": 1,
      "default": 4
    },
    {
      "type": "checkbox",
      "id": "show_vendor",
      "label": "Show vendor",
      "default": false
    },
    {
      "type": "checkbox",
      "id": "show_secondary_image",
      "label": "Show second image on hover",
      "default": true
    },
    {
      "type": "checkbox",
      "id": "show_view_all",
      "label": "Show 'View all' link",
      "default": true
    },
    {
      "type": "range",
      "id": "padding_top",
      "label": "Top padding",
      "min": 0, "max": 100, "step": 4, "default": 40, "unit": "px"
    },
    {
      "type": "range",
      "id": "padding_bottom",
      "label": "Bottom padding",
      "min": 0, "max": 100, "step": 4, "default": 40, "unit": "px"
    }
  ],
  "presets": [
    {
      "name": "Featured Collection"
    }
  ]
}
{% endschema %}
๐Ÿ’ก Placeholder Content

Notice the {% else %} branch that renders placeholder content when no collection is selected. This ensures the section looks reasonable in the theme editor even before the merchant picks a collection. Professional themes always handle empty states gracefully.

Exercise 4: Announcement Bar

๐Ÿงช
Exercise 4

Create an Announcement Bar

โฑ๏ธ 25 minutes ๐Ÿ“ sections/custom-announcement-bar.liquid

Learning objective: Build a top-of-page announcement bar with multiple rotating messages, custom colors, and a dismiss button using JavaScript.

sections/custom-announcement-bar.liquid
{%- style -%}
  .announcement-bar-{{ section.id }} {
    background-color: {{ section.settings.bg_color }};
    color: {{ section.settings.text_color }};
  }

  .announcement-bar-{{ section.id }} a {
    color: {{ section.settings.text_color }};
  }
{%- endstyle -%}

{%- if section.blocks.size > 0 -%}
  <div
    class="announcement-bar announcement-bar-{{ section.id }}"
    role="region"
    aria-label="Announcement"
    data-announcement-bar
  >
    <div class="announcement-bar__content">
      {%- for block in section.blocks -%}
        <div
          class="announcement-bar__message{% if forloop.first %} announcement-bar__message--active{% endif %}"
          {{ block.shopify_attributes }}
          data-announcement-message
        >
          {%- if block.settings.link != blank -%}
            <a href="{{ block.settings.link }}">
              {{ block.settings.text | escape }}
            </a>
          {%- else -%}
            <p>{{ block.settings.text | escape }}</p>
          {%- endif -%}
        </div>
      {%- endfor -%}
    </div>

    {%- if section.settings.show_close -%}
      <button
        class="announcement-bar__close"
        aria-label="{{ 'accessibility.close' | t | default: 'Close' }}"
        data-announcement-close
      >
        โœ•
      </button>
    {%- endif -%}
  </div>

  <style>
    .announcement-bar {
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0.5rem 2rem;
      font-size: 0.8125rem;
      font-weight: 500;
      text-align: center;
      position: relative;
      min-height: 40px;
    }

    .announcement-bar__content {
      position: relative;
      flex: 1;
      overflow: hidden;
    }

    .announcement-bar__message {
      display: none;
      animation: fadeIn 0.4s ease;
    }

    .announcement-bar__message--active {
      display: block;
    }

    .announcement-bar__message p,
    .announcement-bar__message a {
      margin: 0;
      text-decoration: none;
      font-weight: 500;
    }

    .announcement-bar__message a:hover {
      text-decoration: underline;
    }

    .announcement-bar__close {
      background: none;
      border: none;
      color: inherit;
      cursor: pointer;
      font-size: 1rem;
      padding: 0.25rem 0.5rem;
      opacity: 0.7;
      transition: opacity 0.2s;
      position: absolute;
      right: 0.75rem;
    }

    .announcement-bar__close:hover {
      opacity: 1;
    }

    .announcement-bar[hidden] {
      display: none;
    }

    @keyframes fadeIn {
      from { opacity: 0; transform: translateY(-4px); }
      to { opacity: 1; transform: translateY(0); }
    }
  </style>

  {%- if section.blocks.size > 1 and section.settings.auto_rotate -%}
    <script>
      (function() {
        const bar = document.querySelector('[data-announcement-bar]');
        if (!bar) return;

        const messages = bar.querySelectorAll('[data-announcement-message]');
        if (messages.length <= 1) return;

        let current = 0;
        const speed = {{ section.settings.rotate_speed | default: 5 }} * 1000;

        setInterval(function() {
          messages[current].classList.remove('announcement-bar__message--active');
          current = (current + 1) % messages.length;
          messages[current].classList.add('announcement-bar__message--active');
        }, speed);
      })();
    </script>
  {%- endif -%}

  <script>
    document.addEventListener('click', function(e) {
      const closeBtn = e.target.closest('[data-announcement-close]');
      if (!closeBtn) return;

      const bar = closeBtn.closest('[data-announcement-bar]');
      if (bar) {
        bar.hidden = true;
        sessionStorage.setItem('announcement-dismissed', 'true');
      }
    });

    if (sessionStorage.getItem('announcement-dismissed') === 'true') {
      const bar = document.querySelector('[data-announcement-bar]');
      if (bar) bar.hidden = true;
    }
  </script>
{%- endif -%}

{% schema %}
{
  "name": "Announcement Bar",
  "limit": 1,
  "settings": [
    {
      "type": "color",
      "id": "bg_color",
      "label": "Background color",
      "default": "#1a1a2e"
    },
    {
      "type": "color",
      "id": "text_color",
      "label": "Text color",
      "default": "#ffffff"
    },
    {
      "type": "checkbox",
      "id": "show_close",
      "label": "Show close button",
      "default": true
    },
    {
      "type": "checkbox",
      "id": "auto_rotate",
      "label": "Auto-rotate messages",
      "default": true,
      "info": "Only when multiple messages are added"
    },
    {
      "type": "range",
      "id": "rotate_speed",
      "label": "Rotation speed",
      "min": 3,
      "max": 10,
      "step": 1,
      "default": 5,
      "unit": "sec"
    }
  ],
  "blocks": [
    {
      "type": "message",
      "name": "Message",
      "settings": [
        {
          "type": "text",
          "id": "text",
          "label": "Text",
          "default": "Free shipping on orders over $50!"
        },
        {
          "type": "url",
          "id": "link",
          "label": "Link (optional)"
        }
      ]
    }
  ],
  "max_blocks": 5,
  "enabled_on": {
    "groups": ["header"]
  },
  "presets": [
    {
      "name": "Announcement Bar",
      "blocks": [
        {
          "type": "message",
          "settings": {
            "text": "๐ŸŽ‰ Free shipping on orders over $50!"
          }
        }
      ]
    }
  ]
}
{% endschema %}

Exercise 5: Mega Menu Concept

๐Ÿงช
Exercise 5

Create a Mega Menu Dropdown

โฑ๏ธ 35 minutes ๐Ÿ“ snippets/mega-menu.liquid ๐Ÿ“ assets/component-mega-menu.css

Learning objective: Build a multi-column dropdown navigation that renders child links from a Shopify navigation menu. This is a critical component in premium themes.

snippets/mega-menu.liquid
{%- comment -%}
  ============================================================
  Mega Menu Dropdown
  ============================================================
  Accepts:
  - link: {Object} A linklist link that has child links
  - featured_image: {Object} Optional featured image

  Usage (inside header nav loop):
  {% if link.links.size > 0 %}
    {% render 'mega-menu', link: link %}
  {% endif %}
  ============================================================
{%- endcomment -%}

<div class="mega-menu" data-mega-menu>
  <div class="mega-menu__inner page-width">

    <!-- Link Columns -->
    <div class="mega-menu__links">
      {%- for child_link in link.links -%}
        <div class="mega-menu__column">
          <h3 class="mega-menu__column-title">
            {%- if child_link.url != '#' -%}
              <a href="{{ child_link.url }}">{{ child_link.title | escape }}</a>
            {%- else -%}
              {{ child_link.title | escape }}
            {%- endif -%}
          </h3>

          {%- if child_link.links.size > 0 -%}
            <ul class="mega-menu__list">
              {%- for grandchild_link in child_link.links -%}
                <li>
                  <a
                    href="{{ grandchild_link.url }}"
                    class="mega-menu__link{% if grandchild_link.active %} mega-menu__link--active{% endif %}"
                  >
                    {{ grandchild_link.title | escape }}
                  </a>
                </li>
              {%- endfor -%}
            </ul>
          {%- endif -%}
        </div>
      {%- endfor -%}
    </div>

    <!-- Optional: Featured image or promotion -->
    {%- if featured_image -%}
      <div class="mega-menu__featured">
        <img
          src="{{ featured_image | image_url: width: 400 }}"
          alt="{{ featured_image.alt | escape }}"
          width="400"
          loading="lazy"
        >
      </div>
    {%- endif -%}

  </div>
</div>
assets/component-mega-menu.css
.header__nav-item {
  position: relative;
}

.mega-menu {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  width: 100vw;
  margin-left: calc(-50vw + 50%);
  background: #fff;
  border-top: 1px solid #e5e7eb;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-4px);
  transition: all 0.25s ease;
  z-index: 100;
}

.header__nav-item:hover .mega-menu,
.header__nav-item:focus-within .mega-menu {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.mega-menu__inner {
  display: flex;
  gap: 2rem;
  padding: 2rem 0;
}

.mega-menu__links {
  display: flex;
  gap: 2.5rem;
  flex: 1;
}

.mega-menu__column {
  min-width: 160px;
}

.mega-menu__column-title {
  font-size: 0.8125rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-bottom: 0.75rem;
  color: #1a1a2e;
}

.mega-menu__column-title a {
  color: inherit;
  text-decoration: none;
}

.mega-menu__column-title a:hover {
  color: var(--color-primary, #6366f1);
}

.mega-menu__list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.mega-menu__list li {
  margin-bottom: 0.375rem;
}

.mega-menu__link {
  font-size: 0.875rem;
  color: #4a5568;
  text-decoration: none;
  transition: color 0.15s ease;
  display: block;
  padding: 0.125rem 0;
}

.mega-menu__link:hover,
.mega-menu__link--active {
  color: var(--color-primary, #6366f1);
}

.mega-menu__featured {
  flex-shrink: 0;
  width: 280px;
  border-radius: 8px;
  overflow: hidden;
}

.mega-menu__featured img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 8px;
}
๐Ÿ’ก Navigation Menu Setup

The mega menu reads from Shopify's navigation menu structure. In your Shopify admin, create a menu with three levels of nesting: Main Menu โ†’ Category โ†’ Subcategory. The mega menu will automatically render each top-level item's children as columns, and grandchildren as links within those columns.

Exercise 6: Sticky Add-to-Cart Bar

๐Ÿงช
Exercise 6

Add a Sticky Add-to-Cart Bar

โฑ๏ธ 30 minutes ๐Ÿ“ snippets/sticky-atc.liquid ๐Ÿ“ assets/component-sticky-atc.css ๐Ÿ“ assets/sticky-atc.js

Learning objective: Build a fixed bottom bar that appears when the main product form scrolls out of view. This is a premium feature found in themes like Kalles.

snippets/sticky-atc.liquid
{%- comment -%}
  ============================================================
  Sticky Add-to-Cart Bar
  ============================================================
  Must be rendered inside a product template/section.
  Requires: product object in scope.

  Usage (inside sections/main-product.liquid, before endtag):
    {% render 'sticky-atc', product: product %}
  ============================================================
{%- endcomment -%}

{{ 'component-sticky-atc.css' | asset_url | stylesheet_tag }}

<div class="sticky-atc" data-sticky-atc hidden>
  <div class="sticky-atc__inner page-width">

    <!-- Product info -->
    <div class="sticky-atc__product">
      {%- if product.featured_image -%}
        <img
          class="sticky-atc__image"
          src="{{ product.featured_image | image_url: width: 60, height: 60, crop: 'center' }}"
          alt="{{ product.title | escape }}"
          width="60"
          height="60"
        >
      {%- endif -%}
      <div class="sticky-atc__details">
        <span class="sticky-atc__title">{{ product.title | escape }}</span>
        <span class="sticky-atc__price">
          {%- if product.compare_at_price > product.price -%}
            <s>{{ product.compare_at_price | money }}</s>
          {%- endif -%}
          {{ product.price | money }}
        </span>
      </div>
    </div>

    <!-- Action -->
    <div class="sticky-atc__action">
      {%- if product.available -%}
        {%- if product.variants.size == 1 -%}
          <form action="/cart/add" method="post">
            <input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
            <button type="submit" class="sticky-atc__btn">
              {{ 'products.product.add_to_cart' | t | default: 'Add to Cart' }}
            </button>
          </form>
        {%- else -%}
          <a href="#ProductForm-{{ product.id }}" class="sticky-atc__btn sticky-atc__btn--scroll">
            {{ 'products.product.select_options' | t | default: 'Select Options' }}
          </a>
        {%- endif -%}
      {%- else -%}
        <button class="sticky-atc__btn sticky-atc__btn--disabled" disabled>
          {{ 'products.product.sold_out' | t | default: 'Sold Out' }}
        </button>
      {%- endif -%}
    </div>

  </div>
</div>

<script src="{{ 'sticky-atc.js' | asset_url }}" defer="defer"></script>
assets/component-sticky-atc.css
.sticky-atc {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  background: #fff;
  border-top: 1px solid #e5e7eb;
  box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
  z-index: 90;
  transform: translateY(100%);
  transition: transform 0.3s ease;
}

.sticky-atc.is-visible {
  transform: translateY(0);
}

.sticky-atc[hidden] {
  display: none;
}

.sticky-atc__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.75rem 0;
  gap: 1rem;
}

.sticky-atc__product {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  flex: 1;
  min-width: 0;
}

.sticky-atc__image {
  width: 48px;
  height: 48px;
  border-radius: 6px;
  object-fit: cover;
  flex-shrink: 0;
}

.sticky-atc__details {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.sticky-atc__title {
  font-size: 0.875rem;
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.sticky-atc__price {
  font-size: 0.8125rem;
  color: #4a5568;
}

.sticky-atc__price s {
  color: #9ca3af;
  margin-right: 0.5rem;
}

.sticky-atc__btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.75rem 1.75rem;
  background-color: #1a1a2e;
  color: #fff;
  border: none;
  border-radius: 6px;
  font-size: 0.875rem;
  font-weight: 600;
  cursor: pointer;
  text-decoration: none;
  white-space: nowrap;
  transition: background-color 0.2s;
}

.sticky-atc__btn:hover {
  background-color: #2d2d4e;
}

.sticky-atc__btn--disabled {
  background-color: #d1d5db;
  cursor: not-allowed;
}

@media (max-width: 549px) {
  .sticky-atc__image { display: none; }
  .sticky-atc__title { font-size: 0.8125rem; }
}
assets/sticky-atc.js
/**
 * Sticky Add-to-Cart Bar
 * Shows a fixed bar when the main product form scrolls out of viewport
 */
(function() {
  'use strict';

  const stickyBar = document.querySelector('[data-sticky-atc]');
  if (!stickyBar) return;

  // Find the main product form or buy button as the trigger element
  const productForm = document.querySelector('product-form')
    || document.querySelector('[data-product-form]')
    || document.querySelector('.product-form__submit');

  if (!productForm) {
    // No product form found โ€” don't show sticky bar
    return;
  }

  // Remove the hidden attribute so CSS transitions can work
  stickyBar.removeAttribute('hidden');

  // Create an Intersection Observer
  const observer = new IntersectionObserver(
    function(entries) {
      entries.forEach(function(entry) {
        if (entry.isIntersecting) {
          // Product form is visible โ€” hide sticky bar
          stickyBar.classList.remove('is-visible');
        } else {
          // Product form scrolled away โ€” show sticky bar
          stickyBar.classList.add('is-visible');
        }
      });
    },
    {
      root: null,
      threshold: 0,
      rootMargin: '0px'
    }
  );

  observer.observe(productForm);

  // Scroll-to-form for multi-variant products
  const scrollBtn = stickyBar.querySelector('.sticky-atc__btn--scroll');
  if (scrollBtn) {
    scrollBtn.addEventListener('click', function(e) {
      e.preventDefault();
      const targetId = this.getAttribute('href').substring(1);
      const target = document.getElementById(targetId);
      if (target) {
        target.scrollIntoView({ behavior: 'smooth', block: 'center' });
      }
    });
  }
})();

Exercise 7: Customize the Dawn Product Page

๐Ÿงช
Exercise 7

Enhance Dawn's Product Page

โฑ๏ธ 30 minutes ๐Ÿ“ sections/main-product.liquid (modify) ๐Ÿ“ snippets/sticky-atc.liquid (include)

Learning objective: Modify Dawn's existing product page to add custom features โ€” demonstrating how to extend an existing theme rather than replacing it.

Instead of building a product page from scratch, professional developers extend the existing main-product section. Here are specific modifications you can make:

Modification 1: Add Trust Badges Block

Add this block definition to the blocks array in sections/main-product.liquid's schema:

Add to main-product.liquid schema โ†’ blocks array
{
  "type": "trust_badges",
  "name": "Trust badges",
  "limit": 1,
  "settings": [
    {
      "type": "text",
      "id": "badge_1_text",
      "label": "Badge 1 text",
      "default": "๐Ÿ”’ Secure checkout"
    },
    {
      "type": "text",
      "id": "badge_2_text",
      "label": "Badge 2 text",
      "default": "๐Ÿšš Free shipping"
    },
    {
      "type": "text",
      "id": "badge_3_text",
      "label": "Badge 3 text",
      "default": "โ†ฉ๏ธ Easy returns"
    }
  ]
}

Then add the rendering logic inside the block loop:

Add to block rendering loop in main-product.liquid
{%- when 'trust_badges' -%}
  <div class="trust-badges" {{ block.shopify_attributes }}>
    {%- if block.settings.badge_1_text != blank -%}
      <span class="trust-badge">{{ block.settings.badge_1_text }}</span>
    {%- endif -%}
    {%- if block.settings.badge_2_text != blank -%}
      <span class="trust-badge">{{ block.settings.badge_2_text }}</span>
    {%- endif -%}
    {%- if block.settings.badge_3_text != blank -%}
      <span class="trust-badge">{{ block.settings.badge_3_text }}</span>
    {%- endif -%}
  </div>

Modification 2: Add the Sticky Add-to-Cart

At the bottom of sections/main-product.liquid, just before the {% schema %} tag, add:

Add at bottom of sections/main-product.liquid
{%- if section.settings.enable_sticky_atc -%}
  {% render 'sticky-atc', product: product %}
{%- endif -%}

And add the setting to the schema:

Add to main-product.liquid schema โ†’ settings
{
  "type": "checkbox",
  "id": "enable_sticky_atc",
  "label": "Enable sticky add-to-cart bar",
  "default": true,
  "info": "Shows a fixed bar at the bottom when the buy button scrolls out of view"
}
โœ… Extending vs. Replacing

Professional developers extend existing sections rather than rewriting them from scratch. This approach:
1. Preserves all existing functionality
2. Makes it easy to update the base theme later
3. Reduces the chance of introducing bugs
4. Is faster than building from scratch

JavaScript in Shopify Themes

Shopify themes use vanilla JavaScript โ€” no React, no Vue, no jQuery by default. Dawn uses Web Components (Custom Elements) for interactive features. Here's the pattern:

Dawn's Web Component Pattern
// Define a custom element
class QuantityInput extends HTMLElement {
  constructor() {
    super();
    this.input = this.querySelector('input');
    this.changeEvent = new Event('change', { bubbles: true });

    this.querySelector('[name="minus"]')
      .addEventListener('click', this.onButtonClick.bind(this, -1));
    this.querySelector('[name="plus"]')
      .addEventListener('click', this.onButtonClick.bind(this, 1));
  }

  onButtonClick(direction) {
    const previousValue = this.input.value;
    if (direction === 1) {
      this.input.stepUp();
    } else {
      this.input.stepDown();
    }

    if (previousValue !== this.input.value) {
      this.input.dispatchEvent(this.changeEvent);
    }
  }
}

// Register the custom element
customElements.define('quantity-input', QuantityInput);

Then use it in Liquid like a regular HTML element:

Using Custom Elements in Liquid
<quantity-input class="quantity">
  <button name="minus" type="button" aria-label="Decrease quantity">โˆ’</button>
  <input type="number" name="quantity" value="1" min="1" max="10">
  <button name="plus" type="button" aria-label="Increase quantity">+</button>
</quantity-input>
๐Ÿ’ก Why Web Components?

Web Components are perfect for Shopify themes because:
1. No framework dependencies โ€” zero bundle overhead
2. Encapsulated โ€” each component manages its own state
3. Native browser support โ€” no polyfills needed
4. Works naturally with Liquid โ€” just use custom HTML tags
5. Progressive enhancement โ€” page works without JS, JS adds interactivity

Key Takeaways

  • Always handle responsive design from the start โ€” test at multiple breakpoints
  • Use snippets for reusable components (product cards, icons, price display)
  • Use sections for page-level components with theme editor settings
  • Provide placeholder content for empty states
  • Use Intersection Observer for scroll-based behaviors (sticky bars, lazy loading)
  • Extend Dawn's existing sections rather than rewriting from scratch
  • Web Components (Custom Elements) are the preferred JS pattern in Shopify themes
  • Always make components accessible (aria labels, keyboard navigation, focus management)
  • Test your components with real product data in a development store