Shopify Builds>Three Ships>A-Z ingredient glossary with scroll-spy letter navigation and product cross-sell

A-Z Ingredient Glossary With Scroll-Spy Letter Navigation and Product Cross-Sell

An ingredient encyclopedia authored as theme-editor blocks across 14 section instances on one page template, joined to a sticky letter bar by a single shared attribute, with every entry an accordion that links out to the products it appears in — education page as cross-sell surface.

A Shopify page is one rich-text body, and a section holds at most 50 blocks. Three Ships' ingredient glossary is authored entirely as theme-editor blocks across 14 instances of one section, each entry an accordion carrying type, source country, targets, sourcing copy and up to two linked products with live pricing. A sticky letter bar and the content share nothing but a lowercased data-letter attribute, and a scroll-spy in the theme's shared script joins them.

The Problem

A Shopify page has one field for content: a rich-text body. There's no per-entry structure in it — no way to say that this paragraph is an ingredient, that this line is its country of origin, that these two products contain it. An encyclopedia where every entry carries its type, its source, what it targets, why it's used, how it's sourced and which products it's in can't be a rich-text page without becoming an HTML document someone hand-maintains.

For a clean-beauty brand, that encyclopedia is the trust asset. Three Ships needed it public, complete, and — the part that turns an education page into a selling one — joined to the catalog, so an entry for an ingredient shows the products it appears in with the price they actually carry today.

The Constraint

The content is marketing's, and marketing edits in the theme editor. That ruled out a CMS and ruled out metaobject definitions the team would have had to learn; every entry had to be a block with fields.

Shopify caps a section at 50 blocks. The glossary runs to hundreds of entries, so it can't be one section, which means the letter navigation has to work across section instances that don't know about each other — a section can't reach into a sibling's blocks, and a nav section can't enumerate what the content sections contain.

The theme is Palo Alto 5.8.0 by Presidio Creative, a paid premium theme, and its header is sticky with a height that varies by viewport and state. Any scroll offset had to be measured live, not hard-coded. And the page had to be content first: readable with no script at all, with JavaScript reserved for the two things that genuinely need it, jumping to a letter and opening an entry.

What We Built

ingredient-product-content.liquid is a section with no settings of its own and two block types. A header block is a letter: it emits <div class="ingredient__letter--letter" data-letter="a"> with an <h2> that shows the letter in both cases — "Aa" — as the visible divider. The schema's own help text says what a blank title does: the block becomes a continuation of the previous letter, which is how one letter spans two section instances when it outgrows one.

An ingredient block is an entry. Its trigger is a <button> carrying the ingredient name; its panel holds the record. Ingredient type is an eleven-option select — Active, Hydration Agent, Preservative, Surfactant and so on — and the value drives icon-ingredient-type, a snippet whose {% case %} returns a hand-drawn SVG glyph per type. Source is a 35-country select, and icon-country returns a stroked outline of that country's map from its own {% case %}. Targets are one comma-separated textarea, split in Liquid into a row of chips. Then Verified, Why We Use It, How It's Sourced, a Sustainability Story, and an optional call-to-action button with its own label and URL.

page.ingredients.json · ONE TEMPLATETHEME · SHARED SCRIPT ingredient-filter · sticky bar <button data-letter="c"> … one per letter ingredient-product-content · instance 3 header → data-letter="c" ingredient · ingredient · ingredient … each: button + panel ≤ 50 blocks instance 4 no header block C continues here ingredient · ingredient … ≤ 50 blocks scroll listener measures .site-header height, marks the passed letter's tab active click handler scrolls to an anchor carrying the same data-letter, minus header smooth · no hash change reads tabs next instance data-letter is the whole contract sets .active nav never reads content
The nav section and the content sections never reference each other. A header block writes a lowercased data-letter onto an anchor; a nav button carries the same value; the shared script is the only thing that reads both, and it measures the sticky header at the moment of each scroll rather than assuming a height.

Below the copy sits the cross-sell. "Appears in Products Like:" is followed by up to two product pickers from the block settings. Each one that's set renders a card: the featured image at 150 pixels wide, lazily loaded, the title as a link, the product's my_fields.target metafield as its subtitle, and a price that reads the catalog live — when compare_at_price is greater than price, the current price renders with the compare-at struck through beside it; otherwise a single price. An ingredient entry, in other words, ends in a product card with today's price.

ingredient-filter.liquid is the bar: position: sticky; top: 0, horizontally scrollable with its scrollbar hidden, and a row of <button> elements each carrying a data-letter. It renders nothing about the content and reads nothing from it.

The two are joined in the theme's shared script. A scroll listener measures .site-header's offsetHeight on every scroll, walks every .ingredient__letter--letter anchor, and for each one whose top has passed the header line clears the active tab and marks the tab whose data-letter matches — so the last anchor the reader has scrolled past wins. A click handler on each tab looks for an anchor carrying the same data-letter and, when it finds one, smooth-scrolls to it, offset by the header height measured at that moment. And each entry's <button> toggles .active on itself and flips the display of its next sibling, the panel.

page.ingredients.json composes the page: a marquee, one filter section, and 14 instances of the content section, one to four letters each, with the letter C spanning two.

Why This Way

Blocks instead of metaobjects keeps authoring where the marketing team already works. An ingredient is a block with fields; adding one is "Add block," and the type and source are selects rather than free text, so the icons always resolve. Nothing about the content depends on JavaScript: every entry, every product card and every price is in the server HTML, and the script only adds the jump and the open.

Splitting the glossary across 14 instances is a direct answer to the block cap, and it buys something the cap didn't ask for: each instance is independently reorderable in the editor, and a letter that outgrows an instance continues into the next one by leaving the header off. Making the nav-to-content contract a single attribute is what lets that happen without touching the bar — a new instance with a new header block is navigable the moment it's saved.

What that costs is maintenance in 14 places rather than one, and an ordering the template doesn't enforce: the letters are in order because someone arranged the instances in order, and the template will happily render them shuffled. The icon sets are code — a new country or a new ingredient type is a select option plus a {% case %} branch, not an upload. And accordion state is a display toggle on the panel: nothing is persisted, so a reload closes every entry.

Why Not an App

Glossary and FAQ-accordion apps render a list of entries from the app's own store, in the app's own markup. What they can't do is the second half of this page: pick a Shopify product in the entry's settings, read that product's metafield for the subtitle, and render a price that reflects a sale set in the admin this morning. That join is a theme concern, and it only works because the entry is a Liquid block with a product setting.

The other reason is composition. This is a page template with a marquee above the glossary and the theme's own header above that; the letter bar has to sit under the vendor's sticky header and measure it. An app's embed renders where the app renders it. A section renders where the merchandiser places it.

Implementation Notes

  • page.ingredients.json composes one ingredient-filter section, 14 ingredient-product-content instances and a marquee. Instances carry between one and four letters; the letter C runs across two.
  • Letter anchors and nav tabs are matched purely on a lowercased data-letterblock.settings.title | downcase on the anchor, a literal on the button. Neither section references the other by id, class or position.
  • A header block with a blank title continues the previous letter, and the setting's own info text says so — the schema documents the mechanism where the merchandiser will read it.
  • Targets are authored as one comma-separated textarea and split with | split: ',' into chips; the schema's help text specifies the separator.
  • Product cards read current_product.metafields.my_fields.target for the subtitle and compare compare_at_price against price in Liquid to decide whether to render the strikethrough sale layout.
  • Ingredient type and source country each drive a Liquid {% case %} snippet — icon-ingredient-type and icon-country — returning inline SVG, so the icon can't drift from the value and no image upload is involved.
  • The scroll-spy offsets by the live .site-header height on every scroll event, so the active tab stays correct under the premium theme's sticky header at any viewport.
  • The letter divider prints the letter twice, uppercase then lowercase, as its heading — "Aa", "Bb" — which is the visible signature of a header block.

Edge Cases

  • Both product slots are individually optional. An entry with one product renders one card, and an entry with none renders the label alone.
  • compare_at_price above price switches a product card into the sale layout with the compare-at struck through; otherwise one price renders and no empty strikethrough span is emitted.
  • The accordion toggle treats an empty inline display the same as none, so the first click on a never-opened entry opens it rather than closing something that was already closed.
  • A section instance with no header block at all continues the previous letter's run — that's how the letter C is split across two instances in the live template — and a header block with a blank title does the same by design.
  • An entry with no call-to-action URL omits the button entirely rather than rendering a labeled button that goes nowhere.
  • On narrow viewports the letter bar scrolls horizontally inside its sticky strip, with the scrollbar hidden, rather than wrapping into a second row that would push the content down.

Platform Primitives Used

  • Liquid section schema and settings — two block types; selects for type and country, textareas for the copy, a URL and label for the CTA, and two product pickers for the cross-sell.
  • Theme sections — one settings-free section type, mounted 14 times on a single JSON template to get around the per-section block cap.
  • Metafieldsmy_fields.target on each linked product supplies the card's subtitle, so the product describes itself in the entry.

Where It Runs

One landing page template, page.ingredients, which mounts the filter section once and the content section 14 times, under the theme's own page-title section and a marquee. The product cards link out to the storefront's product pages, but nothing here renders on them — the glossary is a destination, not a component.

What This Demonstrates

  • Product recommendations and cross-sell — the primary capability: an education page whose entries end in product cards with live pricing, the cross-sell curated in the block rather than computed.
  • Ingredient transparency and supply-chain traceability — type, source country, targets, sourcing and sustainability copy per ingredient, authored in the admin.
  • Long-form content navigation — a scroll-spy letter rail over a page that runs to hundreds of entries across 14 sections.

How We Know

Four theme files: the two sections, the page template that composes them and the shared script that binds them, roughly 430 lines between them read for this page. The template is the strongest single piece of evidence — it shows the 14 instances, their block counts and the letter C spanning two of them, which is the block cap being worked around in practice rather than in a comment. Two entries in the client's task register record the glossary as a maintained surface with work tracked against it.

Related Builds

The Buy-vs-Build Question

A glossary app renders entries. This template renders entries that end in products the shopper can buy at today's price, on a page composed with the rest of the theme. If the encyclopedia is content, an app is fine; if it's a selling surface, the join to the catalog is the whole point — and that's the decision we set out in Product recommendations: buy or build?

Provenance & Evidence

  • Client: Three Ships — clean skincare, threeshipsbeauty.ca
  • Surface: Landing page
  • Templates served: One — page.ingredients
  • Complexity: Medium — roughly 430 lines across two sections, a template and the shared script
  • Attribution: Deploi-authored. Both sections, the template and the glossary's behavior in the theme's shared script are ours. They run inside Palo Alto 5.8.0 by Presidio Creative, a paid premium theme the client licenses; the sticky header the script measures, the page-title section above the glossary and the design tokens the entries inherit are the vendor's.
  • Status: Live, verified 2026-09-07
  • Evidence: Four theme files, two client task-register entries
  • Confidence: Strong — code plus client records
  • Primary capability: Product recommendations and cross-sell

Ready to Turn Your Ingredient List Into a Reason to Buy?

If your transparency page is a wall of text nobody can navigate — or a glossary that never mentions a product — Contact us today and we'll build one your marketing team can edit and your shoppers can buy from.

More builds