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.
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
Build a Hero Banner Section
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
{{ '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 %}
.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
Create a Reusable Product Card Snippet
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
{%- 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>
.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: Featured Collection Section
Build a Featured Collection Grid
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.
{{ '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 %}
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
Create an Announcement Bar
Learning objective: Build a top-of-page announcement bar with multiple rotating messages, custom colors, and a dismiss button using JavaScript.
{%- 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
Create a Mega Menu Dropdown
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.
{%- 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>
.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;
}
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
Add a Sticky Add-to-Cart Bar
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.
{%- 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>
.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; }
}
/**
* 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
Enhance Dawn's Product Page
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:
{
"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:
{%- 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:
{%- if section.settings.enable_sticky_atc -%}
{% render 'sticky-atc', product: product %}
{%- endif -%}
And add the setting to the schema:
{
"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"
}
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:
// 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:
<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>
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