Ingredients table
section.
A two-column presentation table for product ingredients, supplement facts panels, skincare actives, food nutritional info, or technical spec sheets. Rows can come from manual blocks (label, value, unit, optional highlight) or from a product metafield list, or both combined. Group separators let you organise related rows under a sub-heading. No app, no script — pure Liquid and CSS.
The code
One file. Liquid template, scoped CSS, and schema — paste it all into a new section.
{% comment %}
Jelonyx · Ingredients / Specifications Table Section
Compatible with Dawn 12+, Horizon, and most OS 2.0 themes
Source: jelonyx.com/shopify/sections/ingredients-table
{% endcomment %}
{%- liquid
assign ds = section.settings.data_source
assign mf_ns = section.settings.metafield_namespace
assign mf_key = section.settings.metafield_key
assign has_manual = false
assign has_meta = false
if section.blocks.size > 0
if ds == 'manual' or ds == 'both'
assign has_manual = true
endif
endif
if mf_ns != blank and mf_key != blank
if ds == 'metafield' or ds == 'both'
if product != blank
assign mf_value = product.metafields[mf_ns][mf_key].value
if mf_value != blank
assign has_meta = true
endif
endif
endif
endif
-%}
{%- if has_manual or has_meta -%}
<section
id="jlx-it-{{ section.id }}"
class="jlx-it jlx-it--{{ section.settings.layout | default: 'comfortable' }}"
>
<div class="jlx-it__inner">
{%- if section.settings.heading != blank -%}
<h2 class="jlx-it__heading">{{ section.settings.heading }}</h2>
{%- endif -%}
{%- if section.settings.subheading != blank -%}
<p class="jlx-it__subheading">{{ section.settings.subheading }}</p>
{%- endif -%}
{%- if section.settings.intro != blank -%}
<div class="jlx-it__intro rte">{{ section.settings.intro }}</div>
{%- endif -%}
<dl class="jlx-it__table" role="list">
{%- if has_manual -%}
{%- for block in section.blocks -%}
{%- case block.type -%}
{%- when 'group' -%}
<div class="jlx-it__group" role="listitem" {{ block.shopify_attributes }}>
<span class="jlx-it__group-label">{{ block.settings.group_label }}</span>
</div>
{%- when 'row' -%}
<div
class="jlx-it__row{%- if block.settings.highlight %} jlx-it__row--hl{%- endif -%}"
role="listitem"
{{ block.shopify_attributes }}
>
<dt class="jlx-it__label">{{ block.settings.label }}</dt>
<dd class="jlx-it__value">
<span class="jlx-it__value-text">{{ block.settings.value }}</span>
{%- if section.settings.show_unit and block.settings.unit != blank -%}
<span class="jlx-it__unit">{{ block.settings.unit }}</span>
{%- endif -%}
</dd>
</div>
{%- endcase -%}
{%- endfor -%}
{%- endif -%}
{%- if has_meta -%}
{%- for entry in mf_value -%}
{%- assign entry_label = entry.label | default: entry.name | default: entry.title -%}
{%- assign entry_value = entry.value | default: entry.amount | default: entry.text -%}
{%- assign entry_unit = entry.unit -%}
{%- if entry_label != blank or entry_value != blank -%}
<div class="jlx-it__row" role="listitem">
<dt class="jlx-it__label">{{ entry_label }}</dt>
<dd class="jlx-it__value">
<span class="jlx-it__value-text">{{ entry_value }}</span>
{%- if section.settings.show_unit and entry_unit != blank -%}
<span class="jlx-it__unit">{{ entry_unit }}</span>
{%- endif -%}
</dd>
</div>
{%- endif -%}
{%- endfor -%}
{%- endif -%}
</dl>
</div>
</section>
<style>
#jlx-it-{{ section.id }} {
padding-top: {{ section.settings.padding_top | default: 60 }}px;
padding-bottom: {{ section.settings.padding_bottom | default: 60 }}px;
padding-left: 20px;
padding-right: 20px;
background: var(--color-background, #fff);
--jlx-it-hl: {{ section.settings.highlight_color | default: '#1a9463' }};
}
#jlx-it-{{ section.id }} .jlx-it__inner {
max-width: 760px;
margin: 0 auto;
}
#jlx-it-{{ section.id }} .jlx-it__heading {
font-size: clamp(20px, 3vw, 28px);
font-weight: 700;
line-height: 1.25;
color: var(--color-foreground, #1a1a1a);
margin: 0 0 8px;
}
#jlx-it-{{ section.id }} .jlx-it__subheading {
font-size: 14px;
line-height: 1.55;
color: var(--color-foreground-secondary, #6b6b6b);
margin: 0 0 16px;
}
#jlx-it-{{ section.id }} .jlx-it__intro {
font-size: 14px;
line-height: 1.65;
color: var(--color-foreground, #1a1a1a);
margin: 0 0 24px;
}
#jlx-it-{{ section.id }} .jlx-it__intro p { margin: 0 0 8px; }
#jlx-it-{{ section.id }} .jlx-it__intro p:last-child { margin-bottom: 0; }
#jlx-it-{{ section.id }} .jlx-it__table {
margin: 0;
border-top: 1px solid var(--color-border, rgba(0,0,0,0.12));
border-bottom: 1px solid var(--color-border, rgba(0,0,0,0.12));
}
#jlx-it-{{ section.id }} .jlx-it__row {
display: grid;
grid-template-columns: minmax(120px, 38%) 1fr;
gap: 16px;
align-items: baseline;
border-bottom: 1px solid var(--color-border, rgba(0,0,0,0.08));
color: var(--color-foreground, #1a1a1a);
}
#jlx-it-{{ section.id }} .jlx-it__row:last-child { border-bottom: none; }
#jlx-it-{{ section.id }}.jlx-it--compact .jlx-it__row { padding: 9px 0; }
#jlx-it-{{ section.id }}.jlx-it--comfortable .jlx-it__row { padding: 14px 0; }
#jlx-it-{{ section.id }} .jlx-it__label {
font-size: 13px;
font-weight: 500;
color: var(--color-foreground-secondary, #6b6b6b);
margin: 0;
line-height: 1.45;
}
#jlx-it-{{ section.id }} .jlx-it__value {
margin: 0;
font-size: 14px;
line-height: 1.5;
text-align: right;
}
#jlx-it-{{ section.id }} .jlx-it__value-text { font-weight: 500; }
#jlx-it-{{ section.id }} .jlx-it__unit {
margin-left: 4px;
font-size: 12px;
color: var(--color-foreground-secondary, #6b6b6b);
font-weight: 400;
}
#jlx-it-{{ section.id }} .jlx-it__row--hl {
background: color-mix(in srgb, var(--jlx-it-hl) 8%, transparent);
border-left: 2px solid var(--jlx-it-hl);
padding-left: 12px;
padding-right: 12px;
margin-left: -12px;
margin-right: -12px;
}
#jlx-it-{{ section.id }} .jlx-it__row--hl .jlx-it__value-text {
color: var(--jlx-it-hl);
font-weight: 700;
}
#jlx-it-{{ section.id }} .jlx-it__group {
border-bottom: 1px solid var(--color-border, rgba(0,0,0,0.08));
padding: 18px 0 6px;
}
#jlx-it-{{ section.id }} .jlx-it__group-label {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-foreground, #1a1a1a);
line-height: 1.2;
}
@media (max-width: 540px) {
#jlx-it-{{ section.id }} .jlx-it__row {
grid-template-columns: 1fr;
gap: 4px;
}
#jlx-it-{{ section.id }} .jlx-it__value { text-align: left; }
}
</style>
{%- else -%}
{%- if request.design_mode -%}
<div style="padding:60px 20px;text-align:center;border:2px dashed rgba(0,0,0,0.12);border-radius:8px;">
<p style="color:#888;font-size:14px;margin:0 0 6px;">Ingredients table is empty.</p>
<p style="color:#aaa;font-size:13px;margin:0;">Add Row blocks, or set a metafield namespace + key for a metafield-driven table.</p>
</div>
{%- endif -%}
{%- endif -%}
{% schema %}
{
"name": "Ingredients table",
"tag": "section",
"class": "section",
"max_blocks": 50,
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"placeholder": "Ingredients"
},
{
"type": "text",
"id": "subheading",
"label": "Subheading"
},
{
"type": "richtext",
"id": "intro",
"label": "Intro / note",
"info": "Optional rich-text paragraph above the table."
},
{ "type": "header", "content": "Data source" },
{
"type": "select",
"id": "data_source",
"label": "Row source",
"options": [
{ "value": "manual", "label": "Manual blocks" },
{ "value": "metafield", "label": "Product metafield" },
{ "value": "both", "label": "Both (blocks first, then metafield)" }
],
"default": "manual"
},
{
"type": "text",
"id": "metafield_namespace",
"label": "Metafield namespace",
"default": "specs",
"info": "Used when data source is metafield or both. e.g. \"specs\"."
},
{
"type": "text",
"id": "metafield_key",
"label": "Metafield key",
"default": "rows",
"info": "Key of a list metafield where each entry has label, value, and optional unit."
},
{ "type": "header", "content": "Display" },
{
"type": "checkbox",
"id": "show_unit",
"label": "Show unit beside value",
"default": true
},
{
"type": "select",
"id": "layout",
"label": "Row density",
"options": [
{ "value": "compact", "label": "Compact" },
{ "value": "comfortable", "label": "Comfortable" }
],
"default": "comfortable"
},
{
"type": "color",
"id": "highlight_color",
"label": "Highlight colour",
"default": "#1a9463"
},
{ "type": "header", "content": "Spacing" },
{
"type": "range",
"id": "padding_top",
"label": "Padding top",
"min": 0,
"max": 120,
"step": 4,
"unit": "px",
"default": 60
},
{
"type": "range",
"id": "padding_bottom",
"label": "Padding bottom",
"min": 0,
"max": 120,
"step": 4,
"unit": "px",
"default": 60
}
],
"blocks": [
{
"type": "row",
"name": "Row",
"settings": [
{
"type": "text",
"id": "label",
"label": "Label",
"placeholder": "Vitamin D3"
},
{
"type": "text",
"id": "value",
"label": "Value",
"placeholder": "25"
},
{
"type": "text",
"id": "unit",
"label": "Unit (optional)",
"placeholder": "mcg"
},
{
"type": "checkbox",
"id": "highlight",
"label": "Highlight this row",
"default": false
}
]
},
{
"type": "group",
"name": "Group separator",
"settings": [
{
"type": "text",
"id": "group_label",
"label": "Group label",
"placeholder": "Active ingredients"
}
]
}
],
"presets": [
{
"name": "Ingredients table",
"category": "Product information"
}
]
}
{% endschema %}How to add the section
- 01Open your theme code editor.
In Shopify Admin, go to Online Store → Themes. On your active theme, click the three-dot menu and select Edit code.
- 02Create a new section file.
Under the Sections folder, click Add a new section. Name it
jlx-ingredients-tableand click Done. - 03Paste the full section code.
Delete any placeholder content in the new file, paste the entire code block above, and save.
- 04Add the section to a template.
Open the theme editor (Customize). Navigate to the product or page template where you want the table. Click Add section and select Ingredients table.
- 05Choose a data source and add rows.
Pick Manual blocks to add rows from the section panel: click Add block, choose Row, and enter a label, value, and optional unit. Use Group separator blocks to break the table into labelled sections. To pull rows from a product metafield instead, set the data source to Product metafield and enter the namespace and key. Choose Both to combine.
Schema settings
Section-level settings (apply to the whole table):
headingtextOptional heading above the table.subheadingtextOptional supporting line below the heading.introrichtextOptional rich-text paragraph between the heading and the table. Good for an FDA disclaimer or sourcing note.data_sourceselectmanual, metafield, or both. manual uses section blocks. metafield uses product.metafields.<namespace>.<key>. both renders blocks first, then metafield entries.metafield_namespacetextMetafield namespace. Default "specs". Only used when data source is metafield or both.metafield_keytextMetafield key for a list-of-objects metafield where each entry has label, value, and optional unit. Default "rows".show_unitcheckboxToggle the unit suffix (e.g. mg, mcg, %DV) on each row value.layoutselectRow density: compact (9px vertical padding) or comfortable (14px).highlight_colorcolorAccent colour used for highlighted rows. Defaults to a brand green.padding_toprangeTop padding around the section in pixels. 0–120, step 4. Default 60.padding_bottomrangeBottom padding in pixels. 0–120, step 4. Default 60.Block settings — row block:
labeltextRow label, shown in the left column. e.g. Vitamin D3, Net weight, RAM.valuetextRow value, shown in the right column. Free text.unittextOptional unit suffix appended to the value. e.g. mg, mcg, %DV, GB, kg. Hidden when show_unit is off.highlightcheckboxTints the row background and bolds the value in the highlight colour. Use sparingly for active ingredients or hero specs.Block settings — group block (separator):
group_labeltextSub-heading text rendered in caps as a separator row spanning both columns. e.g. Active ingredients, Other ingredients, Dimensions.How it works
The table is a <dl> with each row a two-column CSS grid — label on the left, value on the right. Using a description list keeps the markup semantic for screen readers and search engines. The role="list" attribute is added explicitly because Safari strips the implicit list role from <dl> when display: grid is applied.
Two block types share the same section. The row block renders a label and value pair. The group block renders a small caps sub-heading that spans both columns, letting you split a long table into logical groups (e.g. active vs. other ingredients) without duplicating the section.
The data source select drives which rows render. With manual, only section.blocks are looped. With metafield, the section reads product.metafields[ns][key].value expecting a list-of-objects metafield where each entry exposes a label, value, and optional unit. With both, blocks render first and metafield entries append below — useful for a fixed boilerplate row plus per-product specs.
The metafield reader uses | default: chains so it works with multiple entry shapes: {label, value, unit}, {name, amount}, or {title, text}. The first non-blank field wins. You can change the field names in the {% assign %} lines if your metafield definition uses different keys.
Below 540 px the row grid collapses from two columns to one, so labels stack above values. This keeps long ingredient names readable on phones without horizontal scroll. The highlight colour is exposed as the CSS custom property --jlx-it-hl, set inline from the section setting, and used via color-mix for the row background tint.
In the theme editor with no rows configured, the section shows a dashed placeholder via request.design_mode. On the live storefront with no rows, it renders nothing — no empty gap.
Compatibility
Tested against Dawn 12+ and Horizon, and works with Sense, Craft, Refresh, and most other OS 2.0 themes. The section reads --color-background, --color-foreground, --color-foreground-secondary, and --color-border from the theme, so it picks up your brand colours and rules automatically.
On themes that do not define --color-foreground-secondary or --color-border, the section falls back to neutral greys (#6b6b6b for muted text, rgba(0,0,0,0.12) for rules). Override .jlx-it__row and .jlx-it__label in the section CSS to hard-code your theme's palette.
The metafield path expects a list-of-objects metafield. Define one under Settings → Custom data → Products with the namespace and key you set in the section, then add a definition with sub-fields label, value, and unit. Each product can then maintain its own ingredient list without editing the theme.
color-mix in CSS is supported in all modern browsers (Safari 16.2+, Chrome 111+, Firefox 113+). Older browsers will simply skip the row tint and show the highlight border only — the table remains readable.
Limitations
- Fifty blocks maximum:
max_blocks: 50is set in the schema, counted acrossrowandgroupblocks combined. For supplement panels and spec sheets this is plenty; for very long material safety data tables, consider splitting into multiple section instances. - Text-only values: values render as plain text. Per-cell formatting (links, icons, bold spans) requires editing the value markup in the section code.
- Metafield shape is fixed: the metafield path looks for
label,value, andunitsub-fields (withname/amountandtitle/textas fallbacks). Different shapes need a small edit to the{% assign %}block. - Metafield needs a product context: metafield-driven rows only render on templates where
productis defined (product templates and product sections). On the homepage or a generic page, switch the data source to Manual blocks. - No row reordering of metafield entries: entries render in metafield list order. Reorder them in the product metafield editor to change display order.
- Headless storefronts: this is a Liquid section and does not apply to Hydrogen or other headless setups.
Need this built for your store?
If you want the ingredients table styled to match your brand, wired into existing product metafields, or extended with expandable groups and per-row icons, we can scope and deliver.