Tariq Manon
ShopifyArticle

Building a modern Shopify product template with variant tabs and a specification accordion

How to ship a premium product page in Shopify — variant tabs instead of dropdowns, a clean spec accordion, and the Liquid + CSS + JS split that keeps it maintainable.

9 minBy Tariq Manon
Modern Shopify product page for a lounge chair with variant tabs (Small, Medium, Large, XL) and an expanded specifications accordion

Most Shopify product pages still ship with a size dropdown and a wall of unstructured copy. That worked in 2015. In 2026, the brands that convert — Apple, Tesla, Herman Miller, the better Shopify themes — have moved to variant tabs and a tight specification accordion. This post is the architecture I use when I build one of those product pages on Shopify, end-to-end: Liquid, CSS, JavaScript, and the file structure that keeps it maintainable.

The architecture in one picture

Before any code, the structure matters. Liquid handles data, CSS handles design, JavaScript handles interaction. The single biggest mistake on beginner themes is one enormous Liquid file that does all three. Professional themes split responsibilities into three folders:

Shopify theme — product page structure
Sections01
Page-level building blocks
  • main-product.liquid
Snippets02
Reusable Liquid partials
  • product-gallery.liquid
  • variant-tabs.liquid
  • product-accordion.liquid
Assets03
Styles & behaviour
  • product-template.css
  • product-template.js
Recommended project structure
theme/
├─ sections/
│ └─ main-product.liquid// page composition
├─ snippets/
│ ├─ product-gallery.liquid// image grid + thumbs
│ ├─ variant-tabs.liquid// size / colour tabs
│ └─ product-accordion.liquid// spec & policy blocks
├─ assets/
│ ├─ product-template.css
│ └─ product-template.js
└─ templates/
└─ product.json

1. The section — main-product.liquid

The section file is the page composition. It loads its own assets, renders the snippets, and exposes a {% schema %} so merchants can add blocks from the theme editor. Note the use of {% form 'product' %}— that's the Shopify-native way to submit add-to-cart, and it preserves analytics, line item properties and selling-plan support out of the box.

sections/main-product.liquidLiquid
{{ 'product-template.css' | asset_url | stylesheet_tag }}
<script src="{{ 'product-template.js' | asset_url }}" defer></script>

<section class="modern-product-page" data-section-id="{{ section.id }}">
  <div class="product-container page-width">

    {% render 'product-gallery', product: product %}

    <div class="product-info-wrapper">
      <nav class="breadcrumb" aria-label="Breadcrumb">
        <a href="{{ routes.root_url }}">Home</a> /
        <a href="{{ product.type | url_for_type }}">{{ product.type }}</a> /
        <span>{{ product.title }}</span>
      </nav>

      <h1 class="product-title">{{ product.title }}</h1>

      <p class="product-price" id="ProductPrice">
        {{ product.selected_or_first_available_variant.price | money }}
      </p>

      {% render 'variant-tabs', product: product %}
      {% render 'product-accordion', product: product %}

      {% form 'product', product, id: 'AddToCartForm' %}
        <input type="hidden" name="id" id="VariantIdInput"
               value="{{ product.selected_or_first_available_variant.id }}">

        <div class="product-actions">
          <button type="submit" class="add-to-cart-button"
                  {% unless product.available %}disabled{% endunless %}>
            {% if product.available %}Add to Cart{% else %}Sold out{% endif %}
          </button>
          <button type="button" class="buy-now-button">Buy it now</button>
        </div>
      {% endform %}
    </div>
  </div>
</section>

{% schema %}
{
  "name": "Modern product",
  "settings": [],
  "presets": [{ "name": "Modern product" }]
}
{% endschema %}

2. The snippet — variant tabs

Variants live on product.variants. Each one has id, title, price, available and (when set) image. Looping over them gives you a tab per variant. The two things most tutorials skip: aria-selected on the active tab, and aria-disabled on sold-out variants. Both are non-negotiable for accessibility and Shopify checkout assistive tech.

snippets/variant-tabs.liquidLiquid
<div class="variant-tabs-wrapper">
  <p class="variant-label">{{ product.options.first }}</p>

  <div class="variant-tabs" role="tablist">
    {% for variant in product.variants %}
      <button
        type="button"
        role="tab"
        class="variant-tab {% if variant == product.selected_or_first_available_variant %}is-active{% endif %}"
        data-variant-id="{{ variant.id }}"
        data-price="{{ variant.price | money }}"
        data-available="{{ variant.available }}"
        aria-selected="{% if variant == product.selected_or_first_available_variant %}true{% else %}false{% endif %}"
        {% unless variant.available %}aria-disabled="true"{% endunless %}
      >
        {{ variant.title }}
      </button>
    {% endfor %}
  </div>
</div>

Styling the tabs

Use CSS variables tied to the theme's tokens so the tabs follow brand colours automatically when the merchant changes them. Keep the active state a flat inverted swatch — busy gradients age badly.

assets/product-template.cssCSS
.variant-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.variant-tab {
  min-width: 110px;
  height: 48px;
  padding: 0 18px;
  background: var(--color-surface);
  color: var(--color-text);
  border: 1px solid var(--color-line);
  border-radius: 10px;
  font-size: 15px;
  cursor: pointer;
  transition: background 200ms ease, color 200ms ease, border-color 200ms ease;
}

.variant-tab:hover {
  border-color: var(--color-text);
}

.variant-tab.is-active {
  background: var(--color-text);
  color: var(--color-surface);
  border-color: var(--color-text);
}

.variant-tab[aria-disabled="true"] {
  opacity: 0.4;
  cursor: not-allowed;
  text-decoration: line-through;
}

Wiring the tabs to the form

Click a tab, switch the hidden id input, update the visible price, and sync the URL so refreshing or sharing the link preserves the chosen variant. That last step is what most themes forget — and it's the difference between a polished store and a fragile one.

assets/product-template.jsJavaScript
// assets/product-template.js
const tabs = document.querySelectorAll('.variant-tab');
const priceEl = document.getElementById('ProductPrice');
const variantInput = document.getElementById('VariantIdInput');

tabs.forEach((tab) => {
  tab.addEventListener('click', () => {
    if (tab.getAttribute('aria-disabled') === 'true') return;

    tabs.forEach((t) => {
      t.classList.remove('is-active');
      t.setAttribute('aria-selected', 'false');
    });

    tab.classList.add('is-active');
    tab.setAttribute('aria-selected', 'true');

    variantInput.value = tab.dataset.variantId;
    priceEl.textContent = tab.dataset.price;

    // keep the URL in sync so refresh/share preserves the choice
    const url = new URL(window.location.href);
    url.searchParams.set('variant', tab.dataset.variantId);
    window.history.replaceState({}, '', url);
  });
});

3. The snippet — specification accordion

Specifications, shipping and warranty are perfect accordion content: useful to a buyer ready to convert, noise to a browser scanning above the fold. Use the native <details> / <summary> elements — they're keyboard-accessible by default and don't need a single line of JavaScript to expand and collapse. Drive the spec rows from a Shopify metafield so merchants can edit them without touching code.

snippets/product-accordion.liquidLiquid
<div class="product-accordions">
  {% for block in section.blocks %}
    <details class="accordion-item" {% if forloop.first %}open{% endif %}>
      <summary class="accordion-header">
        <span>{{ block.settings.title }}</span>
        <span class="accordion-icon" aria-hidden="true">+</span>
      </summary>

      <div class="accordion-content">
        {% if block.type == 'specifications' %}
          <dl class="specification-grid">
            {% for spec in product.metafields.specs.rows.value %}
              <div class="specification-row">
                <dt>{{ spec.label }}</dt>
                <dd>{{ spec.value }}</dd>
              </div>
            {% endfor %}
          </dl>
        {% else %}
          {{ block.settings.content }}
        {% endif %}
      </div>
    </details>
  {% endfor %}
</div>

Tabs vs. dropdowns — why this matters

The dropdown is the default because it's easy to ship, not because it converts. Tabs give buyers all options at a glance, larger tap targets, and a single click to switch — fewer steps before add-to-cart.

Variant UX on a product page
CriterionDropdown (legacy)Variant tabs (modern)
DiscoveryHidden — buyer must open the selectAll options visible at a glance
Tap targetSmall, varies by OSLarge, consistent across devices
Sold-out signalAwkward — needs disabled optionsStrikethrough tab reads instantly
Clicks to switchTwo (open, choose)One
Brand feelGeneric, form-likePremium, controlled, on-brand

The accordion checklist

Use accordions for content that's useful but not urgent:

  • Specifications and materials
  • Shipping and delivery windows
  • Returns and warranty policy
  • Care instructions
  • Size guide
  • Product-specific FAQ

Open the most-requested block by default — usually specifications — so a buyer doesn't have to click before they get value.

Next-level upgrades, once the base ships

  • Variant image switching — listen for the tab click and swap main-product-image when the matching variant has an image set.
  • Sticky add-to-cart on mobile — a thin bar that appears after the hero scrolls past, holding the price and the CTA.
  • Shopify Metaobjects for richer spec content (icons, links, grouped categories).
  • Color swatches rendered from variant.metafields.colour.hex instead of text tabs.
  • Swiper / Embla for the gallery on mobile, with full keyboard and screen-reader support.

What to take away

A modern Shopify product page isn't a CSS trick — it's a discipline. Sections compose the page, snippets keep markup reusable, and assets carry styling and interaction. Variant tabs replace the dropdown. Native <details> drives the accordion. Metafields drive the spec content. Get that foundation right and every product on the store — current and future — inherits a premium product page for free.

Keep reading