Product Page Implementation Intermediate 12 min read

Dynamic Content: Metafields and Tabs

Display rich product information using metafields, organize content with tabs and accordions, and create dynamic product pages.

Products often need more than titles and descriptions. Metafields let you add custom data, while tabs and accordions organize that information for easy scanning.

Understanding Metafields

Metafields store custom data beyond standard Shopify fields:

{# Access product metafields #}
{{ product.metafields.custom.care_instructions }}
{{ product.metafields.custom.materials }}
{{ product.metafields.specifications.weight }}

Metafields follow this pattern:

resource.metafields.namespace.key

Metafield Types

Shopify supports various metafield types:

{# Single-line text #}
{{ product.metafields.custom.subtitle.value }}
{# Multi-line text / Rich text #}
{{ product.metafields.custom.features.value }}
{# Number (integer or decimal) #}
{{ product.metafields.specs.weight.value }} kg
{# URL #}
<a href="{{ product.metafields.custom.manual.value }}">Download Manual</a>
{# File/Image #}
<img src="{{ product.metafields.custom.size_chart.value | image_url: width: 800 }}" alt="Size chart">
{# JSON #}
{%- assign nutrition = product.metafields.custom.nutrition.value -%}
Calories: {{ nutrition.calories }}
{# List of values #}
{%- for feature in product.metafields.custom.features.value -%}
<li>{{ feature }}</li>
{%- endfor -%}

Displaying Metafield Content

Simple Text Metafield

{%- if product.metafields.custom.subtitle -%}
<p class="product__subtitle">
{{ product.metafields.custom.subtitle.value }}
</p>
{%- endif -%}

Rich Text Metafield

{%- if product.metafields.custom.details -%}
<div class="product__details rte">
{{ product.metafields.custom.details.value }}
</div>
{%- endif -%}

Metafield with Fallback

{%- assign care_info = product.metafields.custom.care_instructions.value
| default: 'Machine wash cold. Tumble dry low.' -%}
<p>{{ care_info }}</p>

Building a Tabbed Interface

Organize product information in tabs:

{# sections/product-tabs.liquid #}
<product-tabs class="product-tabs">
<div class="product-tabs__nav" role="tablist">
<button
class="product-tabs__tab is-active"
role="tab"
id="tab-description"
aria-controls="panel-description"
aria-selected="true"
>
Description
</button>
{%- if product.metafields.custom.specifications -%}
<button
class="product-tabs__tab"
role="tab"
id="tab-specs"
aria-controls="panel-specs"
aria-selected="false"
>
Specifications
</button>
{%- endif -%}
{%- if product.metafields.custom.shipping_info -%}
<button
class="product-tabs__tab"
role="tab"
id="tab-shipping"
aria-controls="panel-shipping"
aria-selected="false"
>
Shipping
</button>
{%- endif -%}
<button
class="product-tabs__tab"
role="tab"
id="tab-reviews"
aria-controls="panel-reviews"
aria-selected="false"
>
Reviews
</button>
</div>
<div class="product-tabs__content">
<div
class="product-tabs__panel is-active"
role="tabpanel"
id="panel-description"
aria-labelledby="tab-description"
>
<div class="rte">
{{ product.description }}
</div>
</div>
{%- if product.metafields.custom.specifications -%}
<div
class="product-tabs__panel"
role="tabpanel"
id="panel-specs"
aria-labelledby="tab-specs"
hidden
>
{{ product.metafields.custom.specifications.value }}
</div>
{%- endif -%}
{%- if product.metafields.custom.shipping_info -%}
<div
class="product-tabs__panel"
role="tabpanel"
id="panel-shipping"
aria-labelledby="tab-shipping"
hidden
>
{{ product.metafields.custom.shipping_info.value }}
</div>
{%- endif -%}
<div
class="product-tabs__panel"
role="tabpanel"
id="panel-reviews"
aria-labelledby="tab-reviews"
hidden
>
{%- render 'product-reviews', product: product -%}
</div>
</div>
</product-tabs>

Tabs CSS

.product-tabs__nav {
display: flex;
gap: var(--spacing-xs);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.product-tabs__tab {
padding: var(--spacing-md) var(--spacing-lg);
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-text-light);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.product-tabs__tab:hover {
color: var(--color-text);
}
.product-tabs__tab.is-active {
color: var(--color-text);
border-bottom-color: var(--color-primary);
}
.product-tabs__content {
padding: var(--spacing-lg) 0;
}
.product-tabs__panel {
display: none;
}
.product-tabs__panel.is-active {
display: block;
}
.product-tabs__panel[hidden] {
display: none;
}

Tabs JavaScript

class ProductTabs extends HTMLElement {
connectedCallback() {
this.tabs = this.querySelectorAll('[role="tab"]');
this.panels = this.querySelectorAll('[role="tabpanel"]');
this.tabs.forEach((tab) => {
tab.addEventListener('click', () => this.switchTab(tab));
tab.addEventListener('keydown', (e) => this.onKeydown(e, tab));
});
}
switchTab(selectedTab) {
// Deactivate all tabs
this.tabs.forEach((tab) => {
tab.classList.remove('is-active');
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('tabindex', '-1');
});
// Hide all panels
this.panels.forEach((panel) => {
panel.classList.remove('is-active');
panel.hidden = true;
});
// Activate selected tab
selectedTab.classList.add('is-active');
selectedTab.setAttribute('aria-selected', 'true');
selectedTab.setAttribute('tabindex', '0');
// Show corresponding panel
const panelId = selectedTab.getAttribute('aria-controls');
const panel = this.querySelector(`#${panelId}`);
panel.classList.add('is-active');
panel.hidden = false;
}
onKeydown(event, tab) {
const tabs = Array.from(this.tabs);
const index = tabs.indexOf(tab);
let newIndex;
switch (event.key) {
case 'ArrowRight':
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
tabs[newIndex].focus();
this.switchTab(tabs[newIndex]);
}
}
customElements.define('product-tabs', ProductTabs);

Accordion Alternative

On mobile, accordions often work better than tabs:

{# snippets/product-accordions.liquid #}
<div class="product-accordions">
<details class="product-accordion" open>
<summary class="product-accordion__header">
<span class="product-accordion__title">Description</span>
<span class="product-accordion__icon">+</span>
</summary>
<div class="product-accordion__content rte">
{{ product.description }}
</div>
</details>
{%- if product.metafields.custom.specifications -%}
<details class="product-accordion">
<summary class="product-accordion__header">
<span class="product-accordion__title">Specifications</span>
<span class="product-accordion__icon">+</span>
</summary>
<div class="product-accordion__content">
{{ product.metafields.custom.specifications.value }}
</div>
</details>
{%- endif -%}
{%- if product.metafields.custom.care_instructions -%}
<details class="product-accordion">
<summary class="product-accordion__header">
<span class="product-accordion__title">Care Instructions</span>
<span class="product-accordion__icon">+</span>
</summary>
<div class="product-accordion__content">
{{ product.metafields.custom.care_instructions.value }}
</div>
</details>
{%- endif -%}
</div>

Accordion CSS

.product-accordions {
border-top: 1px solid var(--color-border);
}
.product-accordion {
border-bottom: 1px solid var(--color-border);
}
.product-accordion__header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--spacing-md) 0;
font-weight: 500;
cursor: pointer;
list-style: none;
}
.product-accordion__header::-webkit-details-marker {
display: none;
}
.product-accordion__icon {
font-size: 1.25rem;
transition: transform 0.2s;
}
.product-accordion[open] .product-accordion__icon {
transform: rotate(45deg);
}
.product-accordion__content {
padding-bottom: var(--spacing-md);
}

Responsive Tabs/Accordion

Switch between tabs on desktop and accordions on mobile:

{# Tabs for desktop #}
<div class="product-tabs--desktop">
{%- render 'product-tabs', product: product -%}
</div>
{# Accordions for mobile #}
<div class="product-tabs--mobile">
{%- render 'product-accordions', product: product -%}
</div>
.product-tabs--desktop {
display: none;
}
.product-tabs--mobile {
display: block;
}
@media (min-width: 768px) {
.product-tabs--desktop {
display: block;
}
.product-tabs--mobile {
display: none;
}
}

Specification Tables

Display structured specifications:

{%- assign specs = product.metafields.custom.specifications.value -%}
{%- if specs -%}
<table class="specs-table">
<tbody>
{%- for spec in specs -%}
<tr>
<th>{{ spec.label }}</th>
<td>{{ spec.value }}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
{%- endif -%}

For JSON metafield:

[
{ "label": "Weight", "value": "250g" },
{ "label": "Dimensions", "value": "10 x 5 x 3 cm" },
{ "label": "Material", "value": "Cotton" }
]

Icon-Based Features

Display features with icons:

{%- assign features = product.metafields.custom.features.value -%}
{%- if features -%}
<ul class="product-features">
{%- for feature in features -%}
<li class="product-feature">
{%- case feature.icon -%}
{%- when 'shipping' -%}
{% render 'icon-truck' %}
{%- when 'returns' -%}
{% render 'icon-refresh' %}
{%- when 'warranty' -%}
{% render 'icon-shield' %}
{%- endcase -%}
<span>{{ feature.text }}</span>
</li>
{%- endfor -%}
</ul>
{%- endif -%}

Dynamic Content Blocks

Use section blocks for flexible content:

{
"blocks": [
{
"type": "description",
"name": "Description",
"limit": 1
},
{
"type": "metafield",
"name": "Metafield Content",
"settings": [
{
"type": "text",
"id": "title",
"label": "Title"
},
{
"type": "text",
"id": "namespace",
"label": "Metafield namespace",
"default": "custom"
},
{
"type": "text",
"id": "key",
"label": "Metafield key"
}
]
},
{
"type": "custom_html",
"name": "Custom HTML",
"settings": [
{
"type": "text",
"id": "title",
"label": "Title"
},
{
"type": "html",
"id": "content",
"label": "Content"
}
]
}
]
}
{%- for block in section.blocks -%}
{%- case block.type -%}
{%- when 'description' -%}
<div class="content-block" {{ block.shopify_attributes }}>
<h3>Description</h3>
{{ product.description }}
</div>
{%- when 'metafield' -%}
{%- assign metafield = product.metafields[block.settings.namespace][block.settings.key] -%}
{%- if metafield -%}
<div class="content-block" {{ block.shopify_attributes }}>
<h3>{{ block.settings.title }}</h3>
{{ metafield.value }}
</div>
{%- endif -%}
{%- when 'custom_html' -%}
<div class="content-block" {{ block.shopify_attributes }}>
<h3>{{ block.settings.title }}</h3>
{{ block.settings.content }}
</div>
{%- endcase -%}
{%- endfor -%}

Practice Exercise

Create a product content section that:

  1. Shows description in a tab
  2. Displays specifications metafield as a table
  3. Shows care instructions from metafield
  4. Switches to accordions on mobile
  5. Supports keyboard navigation

Test with:

  • Products with all metafields
  • Products missing some metafields
  • Keyboard-only navigation

Key Takeaways

  1. Metafields store custom product data
  2. Access pattern: product.metafields.namespace.key.value
  3. Check existence before displaying metafields
  4. Tabs organize content for scanning
  5. Accordions work better on mobile
  6. ARIA attributes make tabs accessible
  7. Keyboard navigation for accessibility
  8. Blocks let merchants customize content order

What’s Next?

The final product page lesson covers Recommendations and Complementary Products for cross-selling.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...