Skip to content

Product

The product page, and the most composable section in Signal. Two columns: a media mosaic on the left, and on the right a buy column built entirely out of theme blocks — nothing in it is hardcoded.

Above both sits a breadcrumb trail. Below the page, the demo template adds two Recommendations rows, Recently viewed and an FAQ.

The buy column is nothing but blocks. Every element a shopper reads — the title, the price, the variant picker, the buy button, the perks, the accordions — is a theme block you drag into whatever order the product needs. A size-led page for apparel and a spec-led page for gear are the same section with the blocks reordered.

Product (section)
├── Breadcrumbs Home / collection / product
├── LEFT .pdp-gal the media mosaic (iterates product.media)
└── RIGHT .pdp-buy {% content_for 'blocks' %} ← merchant-controlled order
├── product-meta
├── product-title
├── price
├── product-description
├── variant-picker (+ the size guide dialog)
├── buy-buttons ← owns the <form>
│ ├── selling plans / quantity rules / volume pricing / gift-card fields
│ ├── quantity (static nested block)
│ ├── add-to-cart (static nested block)
│ └── accelerated-checkout (static nested block)
├── back-in-stock
├── product-inventory
├── pickup-availability
├── perk × 3
├── share
├── product-details
└── spec-list

That tree is the theme’s preset — what a merchant gets when they add the section fresh. The section accepts @theme and @app blocks, with no cap, so any theme block and any app block can go in the column too.

BlockWhat it contributes
Product metaThe line above the title: product type (or the collection), vendor, SKU
Product titleThe <h1>
PricePrice, compare-at price, unit price, optional tax note
RatingStars from the standard reviews metafields, linking to a reviews anchor
Product descriptionA short lede (word-limited) or the full description
Variant pickerSwatches and size pills, plus the size guide
QuantityThe stepper. Nested inside buy-buttons
Buy buttonsThe form, subscriptions, quantity rules, volume pricing, gift-card fields
Add to cartThe submit button and the wishlist heart. Nested inside buy-buttons
Accelerated checkoutShop Pay and friends. Nested inside buy-buttons
Stock counterHow many are left in the run, as a number and a bar
Back in stockSign-up shown only while the selected variant is sold out
Pickup availabilityReal in-store pickup from store_availabilities
Perk rowOne reassurance line with an icon. Stack several
Trust badgesSecure-checkout line plus the payment methods the shop accepts
ShareX, Facebook, email and a copy-link button
AccordionOne collapsible row: the description, or custom text
Spec accordionA spec table from Label | Value lines
Custom LiquidApp snippets or your own Liquid
Group · TextLayout and free text

There are only four, because almost everything else is a block setting.

SettingTypeDefaultWhat it does
Sticky add to bagCheckboxtrueA slim bar slides up when the buy button scrolls out of view
Enable image zoomCheckboxtrueAdds a zoom button to each image and opens a full-screen viewer
Add to bag labelTextAdd to bagLabel on the sticky bar’s button
Top spacingRange 0–160 px16Top spacing
Bottom spacingRange 0–160 px48Bottom spacing

The section is disabled on the header and footer groups.

Home / <collection> / <product title>.

The collection object is only populated when the shopper arrived through a collection URL — anyone landing from search, an ad or a direct link used to get “Home / Product”, which tells them nothing. Signal falls back to the product’s own first collection so the middle rung always says something.

The gallery iterates product.media, not product.images, so images, Shopify-hosted video, YouTube/Vimeo embeds and 3D models all render — video_tag with controls (muted, inline), external_video_tag, and model_viewer_tag respectively.

It is a mosaic, not a carousel: a two-column grid you scroll, rather than a slider with thumbnails.

ConditionLayout
Fewer than 3 media itemsEvery tile goes full width at 4:3
3 or more4:5 tiles in two columns, with the third tile spanning both columns at 16:11
≤ 980pxThe page collapses to one column and the buy panel stops being sticky
≤ 560pxThe gallery itself collapses to one column

Images are served responsive (400–1600w, 34vw desktop / 50vw below) with the first image loading eagerly and the rest lazily. A product with no media at all gets an apparel placeholder.

The first tile carries a flag when it applies: a dark Sold out flag when the selected variant is unavailable, otherwise an On sale flag when there is a compare-at price.

With enable_zoom on, every image tile gets a zoom button — revealed on hover on pointer devices, always visible on touch — and the page gets one full-screen <dialog>.

  • Prev / next buttons and ← / → walk the whole set; both are hidden when there is only one image.
  • A counter reads n / total.
  • Clicking the image toggles 2× zoom, anchored at the point you clicked via transform-origin.
  • Opening paints the already-decoded thumbnail immediately and swaps in the 2400px file when it loads, so the stage is never empty while a large image downloads.
  • Closing returns focus to the tile you opened from. Escape, the backdrop and the close button all work.
  • The <img> is created on first open — an empty src in the markup would ship a phantom request on every product page.

When the shopper changes variant, if that variant has a featured_media the page smooth-scrolls the matching tile into view. Variants without a featured media leave the gallery alone.

With sticky_atc on, a slim bar containing the product thumbnail, title, variant · price and a button slides in once the real action row (.pdp-act) has scrolled above the viewport. An IntersectionObserver watches that row, so the bar never appears while the real button is still on screen.

The bar’s button does not submit anything itself — it clicks the real add-to-cart button, so quantity, selling plan and every other form field are honoured. Its variant · price line is kept in sync by the same variant script that updates the main price.

The section emits product.variants as JSON and resolves selections in the browser.

  1. The variant picker renders one radio group per option, named option-0, option-1, …

  2. On change, the script reads every checked radio, finds the first variant whose options match, and writes its id into the form’s hidden id input.

  3. It then updates, wherever those elements exist on the page: the price and compare-at price, the add-to-cart button’s label, price and disabled state, the sticky bar’s meta line, the stock counter, the back-in-stock form, the “Option — Value” labels, and the gallery scroll position.

  4. Pickup availability is re-fetched for the new variant through the Section Rendering API, so it reflects the real per-variant, per-location answer rather than a cached one.

  5. The URL’s ?variant= parameter is updated with history.replaceState, so the address bar is shareable without adding history entries.

After every change the script re-walks every option value and asks what variant that value would produce:

  • a value that produces no variant at all is disabled
  • a value that produces an unavailable variant gets an .out class — rendered as a diagonal hatch with muted text

A sold-out size you have actually selected keeps the accent frame as well as the hatch: the hatch says unavailable, the frame says this is what you picked.

The section emits a second JSON blob mapping each variant id to its inventory quantity — null when Shopify is not tracking inventory for that variant — because inventory is not part of the variants JSON, and the counter is a block the section cannot see.

The stock counter block then shows <n> left plus a bar, where the bar is quantity ÷ run size, clamped to 2–100%. Its stock_run setting (default 400) is the run size the bar is measured against, and stock_word / stock_note are the wording around the number.

The counter hides itself entirely when the selected variant is unavailable, untracked, or at zero.

Both come from Shopify’s B2B quantity rules on the selected variant, and both are rendered by buy buttons:

  • Quantity rules — when the variant has a minimum above 1, an increment above 1, or a maximum, a line states them, and the quantity input takes its value, min, step and max from the same rule. The stepper’s + / − buttons move by the increment and clamp to the rule.
  • Volume pricing — when the variant has quantity_price_breaks, a small table lists the base tier (min+ at the unit price) followed by each break as from N at its price.

On a product where gift_card? is true, buy-buttons adds a “send this to the recipient” checkbox plus recipient email, name, message (200 characters) and a send-on date. The field names are the exact ones Shopify reads to deliver the card to the recipient instead of the buyer. The email becomes required only while the toggle is on, so a shopper buying a card for themselves is never blocked.

Owned by the variant picker block, not the section — a block cannot read section settings, and the control belongs beside the option it explains.

SettingWhat it does
size_guide_label (default Size guide)The control’s wording, and the dialog’s heading
size_guide_content (HTML)When filled, the control opens a <dialog> containing it. Tables are supported
size_guide_page (URL)Used only when no content is set — the control becomes a plain link

The control is rendered beside every option that is not treated as a swatch, which is what puts it next to Size rather than next to Colour. Which option names count as swatches comes from the global Swatches → Swatch option names setting (default color, colour, shade, tint).

The back-in-stock block is its own <form>, which is why it must sit outside the product form — nested forms are invalid HTML and the browser silently drops the inner one.

It is hidden while the selected variant is available and revealed when it is not. Sign-ups are tagged back-in-stock and carry a note naming the exact product and variant, kept current as the shopper changes selection.

Submitting posts by fetch rather than letting Shopify’s customer form redirect to /contact — which would take the shopper off the product page and lose the confirmation with it. On success the form is replaced by the confirmation. If Shopify answers the scripted POST with a bot challenge (403 or 429), the theme hands the shopper to the native submit, which carries the full browser context and can be verified.

The variant picker renders a <noscript> <select name="id" form="pd-form"> listing every variant with its price, with unavailable ones disabled. The form is a real product form, so add-to-cart works without the script — and the theme’s global handler upgrades it to AJAX plus the cart drawer when the script is present.