Appearance
Icon picker
The theme ships the full Lucide icon library — 1,960 icons — and every icon setting can reach all of it. What a merchant is offered in the dropdown is a shorter list chosen for that particular field, with a Custom option for anything outside it.
Why the lists are curated
Every icon field used to offer all 1,961 options. Two problems with that, and only one of them was about bytes:
- It was unusable. Scrolling 1,961 entries to find "truck" is not generosity. Across every template, listing and preset the theme ships, exactly fifty distinct icons are actually used.
- It was 3.5 MB. Twelve fields each carried their own copy of the same list — 166 KB apiece. Shopify schema has no way to reference a shared list, so the duplication is unavoidable; carrying less of it is not.
sections/announcement-bar.liquidalone held four copies and reached 670 KB, against Shopify's documented 256 KB limit for a section file.
So each field now offers a list sized to its job, and the full library stays one click away.
Where it appears
Eleven section and block files, across seventeen settings. Each draws on one of seven subsets defined in scripts/lib/icon-subsets.mjs.
| Section / block | Setting | Offers | Chosen for |
|---|---|---|---|
| Announcement bar → Message blocks | Start icon | 412 | what the message is about — delivery, offers, dates, reassurance |
| Announcement bar → Message blocks | End icon | 39 | a closing mark that points somewhere: arrows, chevrons, a dot |
| Announcement message (block) | Start icon / End icon | 412 / 39 | the same two, exposed when the block is used outside the bar |
| Purchase reassurance (block) | Icon | 125 | why it is safe to buy — delivery, returns, guarantees, payment, support |
| Feeding chart → Species blocks | Icon | 22 | which animal the chart is for |
| Collection → metadata capsule | Icon | 386 | a measurable product fact — size, weight, time, care, material, origin |
| Featured collection → metadata capsule | Icon | 386 | the same capsule on homepage cards |
| Cart line items → Line detail | Icon | 386 | the same fact vocabulary, on a cart line |
| Customer order → Line detail | Icon | 386 | the same, on an order line |
| Product facts / Product fact (blocks) | Icon | 386 | the same, on a product page |
| Care conditions → section | Condition 1–4 icon | 156 | the dimension a row measures — light, water, heat, time, weight, care |
| Care conditions → Item blocks | Icon | 350 | what the item is — a bike, a wheel of cheese, a bolt |
Fields doing the same job offer the same list on purpose: a merchant who learns the fact picker on a product page meets the same words on the cart line.
Two of those subsets exist because one list was doing two jobs. Care conditions shipped with a single 165-icon list for both its row marks and its item mark, which is why the row pickers offered a bicycle to mark "Humidity" and the item picker offered a sigma. A row names an axis; an item names a thing. Different vocabularies, different lists.
If a section is not listed here, it does not use this picker. It may expose an image_picker (merchant-uploaded carrier logos, trust marks) or a checkbox toggle — intentional product decisions, not gaps; see Out of scope. A handful of sections also carry small icon_type selects with t:-keyed labels, a separate contract from the Lucide pickers this page governs.
Reaching an icon that is not in the list
Every picker ends with Custom…. Choosing it reveals a text field where any Lucide name works: type paw-print, stethoscope, anchor. The field's helper text links the browsable library on this docs site, where clicking an icon copies its name.
Nothing is unreachable. The dropdown is the fast path for the common case; the text field is the escape hatch for the rest.
How merchants browse
Shopify renders icon selects as native HTML <select> widgets. Two affordances make them quick:
Category headings. Icons are grouped under their Lucide category, rendered as bold, non-selectable dividers inside the dropdown. (none) and Custom… sit together at the top, above the icons.
Browser type-ahead. Modern browsers jump to the first option starting with the typed letter. Typing
cljumps to "Clock". This is built into every native select — no theme JavaScript required.
How a setting resolves
An icon setting is two values: the dropdown pick and the typed custom name. snippets/icon-name.liquid is the single place that combines them, and every consumer goes through it:
none→ nothing is drawncustom→ whatever the merchant typed (blank types draw nothing)- anything else → the icon they picked
Consumers capture the result and use it as a name, which is the shape they all had before the pair existed.
Locale handling
Two retained key namespaces drive the picker. Icon labels themselves are inline English literals written directly into each schema file's options[].label — see "Why inline labels" below.
| Surface | Count | Disposition |
|---|---|---|
_shared.icon.label | 1 | Localized — the field label "Icon" / "Symbol" / "Icône" / etc. |
_shared.icon.info | 1 | Localized — the helper text under the field |
_shared.icon.lookup_help | 1 | Localized — the pointer to the visual icon library on this docs site |
_shared.icon_groups.<name> | 45 | Localized — every category heading is translated in every locale (the whole _shared.icon_groups. namespace is exempted from the English-parity check by prefix, because roughly ten of the headings are legitimate cognates in Latin-script locales). |
_shared.icon.custom_label | 1 | Localized — the label on the "type any icon name" field revealed by Custom |
options[].label (inline) | 22–412 per field | Inline English literal in each schema file (e.g. "label": "Airplay"). NOT a t:-key — see below. |
Why the option labels are inline English
Shopify enforces a per-file limit of 3,400 translations per *.schema.json. VERIFIED BY EXECUTION (2026-09-06, store gaoth-2, Admin API themeCreate + themeFilesUpsert): 3,400 keys upload cleanly; 3,401 is rejected with FILE_VALIDATION_ERROR: "Too many translation keys".
Giving each icon its own key costs 1,961 translations per locale file (1,960 Lucide icons + the none pseudo-option). The theme's other schema keys already occupy most of the budget, so the fan-out does not fit. The labels are therefore inline English literals written directly into each affected schema file's options[].label:
options[].label— inline English literal for every Lucide identifier ("label": "Airplay","label": "Align end vertical"). Not at:key, because the label is the upstream name a merchant looks up on lucide.dev.- The
customoption is the exception to the exception:t:_shared.icon.custom_option. It is prose this theme invented, not an upstream identifier, so it is translated like any other admin string. One key fits where 1,961 do not. - The
noneoption still ships as the inline literal"(none)"in eleven schema files, emitted by four separate copies of one label function. It lost its key when the_shared.icons.*namespace was dropped. Same class ascustomand a known gap — fixing it means touching all eleven, which #570 did not do. options[].group—t:_shared.icon_groups.<group>(45 keys, translated).- Field-level
label/info/lookup_help—t:_shared.icon.label/.info/.lookup_help(3 keys, translated).
The English title-case forms are recognizable across locale registers, and merchants pick an icon by visual recognition or stored value, so the UX cost is low. Translating a curated subset is tracked separately.
The exception is scoped narrowly: it applies only to options whose parent setting is the icon select (id: "icon" with every option value in the canonical Lucide identifier set). Every other select in the theme uses t:-key option labels. See CLAUDE.md > Hard Rules and tests/unit/icon-picker-schema-contract.test.js for the contract assertions.
History. The picker first shipped through a
_shared.icons.*locale namespace. The upload was rejected for exceeding the per-file limit, but the CLI of the day did not surface that as a failure, so the live admin rendered rawt:_shared.icons.airplaystrings. The repo then recorded the cap as "~2,000 keys, silently dropped"; #437 measured it at 3,400 with a loud rejection, and #497 corrected the figure everywhere it was cited.
How to add or swap an icon
The library is regenerated from the lucide-static npm package (devDependency only — zero production npm deps). To pick up new Lucide releases:
bash
# 1. Bump the lucide-static devDependency (e.g. via npm update lucide-static)
npm update lucide-static
# 2. Regenerate the option list (1,961 entries: 1 "none" + 1,960 Lucide icons).
# The generated JSON still carries t:_shared.icons.* strings — these are
# transformed to inline literals at injection time (step 4).
node scripts/regenerate-icon-options.mjs
# 3. Regenerate the snippet (renders every icon's inline SVG)
node scripts/regenerate-icon-snippet.mjs
# 4. Write each field's curated subset into its schema file.
# Inline EN literal labels per Plan 06.17-A Option D; the subsets and
# the per-field mapping live in scripts/lib/icon-subsets.mjs.
node scripts/570-icon-subsets.mjs
# (scripts/inject-icon-options.mjs is RETIRED — it wrote all 1,961
# options into eight files and would re-inflate the theme by ~2.6 MB.)
# 5. Fan out the localized keys ONLY — _shared.icon_groups.* and
# _shared.icon.label/info. The _shared.icons.* namespace is NOT written;
# it was dropped under Option D to stay under Shopify's per-file cap.
node scripts/apply-06-17-A-locales.mjs
# If a previous run left _shared.icons.* in the locale files, clean
# them up first:
node scripts/cleanup-06-17-A-locales.mjs
# 6. Run the contract test to assert everything stays consistent
npx vitest run tests/unit/icon-picker-schema-contract.test.jsSteps 1–5 are idempotent — running them on a clean tree with no upstream changes is a no-op.
To add a new icon-using section or block:
In the schema, expose
{ type: "select", id: "icon", label: "t:_shared.icon.label", default: "none", options: [] }. Leaveoptionsempty — step 3 fills it.Add an entry to
TARGETSinscripts/lib/icon-subsets.mjs: the file, the setting id, which subset fits the field's job, and whether avisible_ifmust address it assectionorblock. If none of the five subsets fits, add a sixth there rather than widening one that does a different job.Run
node scripts/570-icon-subsets.mjs. It writes the options and adds the companion<id>_customtext field beside the picker, so the typed fallback is never forgotten.Nothing to add to the contract test.
tests/unit/icon-picker-schema-contract.test.jsderives its field list from the sameTARGETS, so a new field is covered the moment step 2 lands. (It used to be a hand-kept array, and four icon fields were missing from it — each carrying 166 KB unnoticed. That is why it is derived now.)In the Liquid, resolve the pair before rendering — the picker is two values:
liquid{%- capture icon_name -%} {%- render 'icon-name', pick: block.settings.icon, custom: block.settings.icon_custom -%} {%- endcapture -%} {%- assign icon_name = icon_name | strip -%} {%- if icon_name != blank -%} {%- render 'icon', name: icon_name, size: 24 -%} {%- endif -%}Gate on
!= blank, not on!= 'none'. The resolver already turnsnone, an unsetcustom, and a blank typed value into an empty string — one condition instead of three.
Backward compatibility
The 25 icons that existed in the pre-migration curated selects (announcement-bar / announcement-message / purchase-reassurance / feeding-chart) are all valid Lucide names and still render identically — the migration is purely additive at the option list level. No merchant data is lost, no renames are required.
The feeding-chart.liquid species block previously exposed icon as a text field with no validation — a footgun: merchants could type any string, valid or not, and the snippet would silently render nothing for typos. The Plan 06.17-A upgrade converts that field to a select with the same id, preserving stored merchant values. Stored values that happen to be valid Lucide names (e.g. paw-print, dog, cat) render as before; invalid stored values fall through to the snippet's no-match branch and render nothing, which is the same observable outcome as before the upgrade.
Out of scope
Three settings look like they take an icon but use a different field type by design — they are intentionally not migrated to the Lucide picker:
| File | Field | Field type | Why it stays |
|---|---|---|---|
sections/shipping-info.liquid | icon | image_picker | Merchants upload their actual carrier logos (UPS / FedEx / USPS / Royal Mail / An Post). Lucide does not ship brand marks and inventing line-art substitutes would be a downgrade. |
sections/trust-badges.liquid | icon | image_picker | Same — merchants upload Visa / Mastercard / Norton / SSL trust marks, and the whole point of a payment badge is the recognizable brand mark. Only this upload field is out of scope: the section’s separate Icon setting is a curated 47-option select of Lucide names for badges that do not need a brand mark. |
A planning audit that misses these field-type distinctions and assumes every id: "icon" setting takes a select-ID is a bug; the lesson is documented in the icon-picker schema contract test.
Bundle and performance
snippets/icon.liquidis now a thin per-letter dispatcher (≈ 3.5 KB). The inline-SVG bodies live in 26 per-letter sub-snippets (snippets/icon-a.liquidthroughsnippets/icon-z.liquid), each well under 200 KB. Heaviest sub-snippet:icon-s.liquid≈ 58 KB (259 icons). Why bucketed: Shopify enforces an undocumented ≈ 256 KB per-template byte cap (not the 1 MB previously assumed); a monolithic 1,967-icon snippet — the 1,960 Lucide icons plus 7 legacy theme social/utility icons (chevron,facebook,linkedin,pinterest,stop,twitter,whatsapp) — at ≈ 430 KB is rejected on deploy.- Snippets are not bundle-budgeted — the per-asset byte cap (24,000 B minified, with a single named exemption for
cart-api.jsat 30,000 B) applies toassets/*only. Sub-snippets ship server-side; never to the client. - Per-page rendered HTML is unchanged — only the icons actually used on the rendered page appear in the response. A homepage with 3 announcement messages renders 3 inline
<svg>elements, not 1,967. - Liquid parse cost is a one-time cold-load expense; every sub-snippet is cached after first parse. The dispatcher is O(1) (case on first letter, 26 entries); inside each sub-snippet the inner case-walk averages ≈ 75 iterations — faster than the prior monolithic 1,967-iteration walk. A page with 30 distinct icons adds ≈ 1–2 ms total.
- Locale JSON growth is small, because only the retained keys are written: ≈ 1.8 KB per locale × 50 locales ≈ 90 KB across all locale files (the 45 group headings account for ≈ 1.4 KB of that). The 1,961-entry
_shared.icons.*fan-out is never written — see the steps above. Locale data is read by Shopify infrastructure, not shipped to the client per-request.
Adding icons after a Lucide upgrade
The generator (scripts/regenerate-icon-snippet.mjs) re-buckets automatically. After npm install of a newer lucide-static:
bash
node scripts/regenerate-icon-snippet.mjsemits all 26 sub-snippets + the dispatcher. The contract test (Section 9 of tests/unit/icon-picker-schema-contract.test.js) asserts each sub-snippet stays under a 200 KB local safety guard; if a future Lucide batch pushes one letter over the threshold, the generator and the test will both fail loudly, prompting either a sub-bucketing (e.g. split icon-s.liquid into icon-sa.liquid / icon-sb.liquid / …) or an icon-curation decision.