Appearance
Icon picker
The theme ships the full Lucide icon library — ≈1,960 icons grouped into 44 categories — wherever a section or block setting exposes an icon. It is the largest icon library shipped by a Theme Store theme today; leading themes typically ship 50–150 curated icons (Be Yours ≈ 80, Refresh ≈ 50, Impulse ≈ 60, Prestige ≈ 100, Dawn ≈ 15).
Where it appears
The icon picker is used by four sections and blocks today:
| Section / block | Setting |
|---|---|
| Announcement bar → Message blocks | Icon — optional icon shown before each scheduled message |
| Announcement message (block) | Icon — same field, exposed when the block is used outside the bar |
| Purchase reassurance (block) | Icon — symbol next to a reassurance row ("Free delivery", "Easy returns", …) |
| Feeding chart → Species blocks | Icon — symbol per filter chip (dog / cat / puppy / senior …) |
If a section is not listed here, it does not use the icon picker — it may instead expose an image_picker (merchant-uploaded carrier logos, trust marks) or a checkbox toggle (show / hide a hardcoded icon). Those fields are intentional product decisions, not gaps; see Out of scope.
How merchants browse
Shopify renders icon selects as native HTML <select> widgets. Two affordances make 1,961 options usable:
Category headings. Icons are grouped under their Lucide category (Accessibility, Account, Animals, Arrows, Buildings, Charts, Communication, Connectivity, Cursors, Design, Development, Devices, Documents, Education, Emoji, Files, Finance, Food, Furniture, Gaming, Layout, Mail, Maps, Math, Medical, Multimedia, Nature, Notifications, Other, Photography, Plants, Science, Security, Shapes, Shopping, Social, Sports, Sustainability, Text, Time, Tools, Transportation, Travel, Weather — 44 categories, plus a "None" pseudo-group for the explicit no-icon option). Headings render as bold, non-selectable dividers inside the dropdown.
Browser type-ahead. Modern browsers (Chrome / Edge / Safari / Firefox) jump to the first option starting with the typed letter as you type. Typing
cljumps to "Clock"; typingairpjumps to "Airplay". This is built into every native select element — no theme JavaScript required.
A merchant who knows the icon name can therefore find it in roughly one second; a merchant who needs to discover the right icon can scroll by category. The flat catalog at https://lucide.dev/icons is the canonical visual browse surface today; a storefront-rendered icon discovery surface is a candidate follow-up if merchant feedback indicates browse-with-visuals beats type-ahead-by-name.
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_groups.<name> | 45 | Localized — every category heading is translated in every locale (English-cognate locales — Design / Emoji / Shopping — are allowlisted explicitly). |
options[].label (inline) | 1,961 | Inline English literal in every affected schema file (e.g. "label": "Airplay"). NOT a t:-key. Per-locale translation deferred — see below. |
Why inline labels (Plan 06.17-A Option D, 2026-05-18)
The picker originally went through a _shared.icons.* locale namespace with 1,961 entries per *.schema.json file (1,960 Lucide icons + 1 none pseudo-option). On deploy, the dropdown rendered raw t:_shared.icons.airplay-style strings — broken.
Discovery: Shopify enforces an undocumented per-file t:-key cap at approximately 2,000 keys per *.schema.json. The 1,961-key fan-out pushed each file from a baseline of 1,949 keys to ≈ 3,910, and Shopify silently dropped the entire new namespace on upload (the CLI reported partial errors that did not name every affected locale, so the issue was easy to miss until the deployed admin was visually inspected).
The Option D pivot writes inline English literals directly into each affected schema file's options[].label. Net effect:
options[].label— inline English literal (e.g."label": "Airplay","label": "Align end vertical","label": "(none)"). Not at:key.options[].group— stayst:_shared.icon_groups.<group>(45 keys, well under the per-file cap, normally translated).- Field-level
label/info— stayt:_shared.icon.label/t:_shared.icon.info(2 keys, normally translated).
Final per-locale *.schema.json size: ≈ 1,996 keys (1,949 baseline + 47 retained = 45 group headings + 2 field-level). Safely under the cap.
Per-locale translation of the 1,960 icon labels is deferred to Phase 7 polish (or to a different mechanism — a storefront discovery surface, an app block, or a Shopify limit raise). The English title-case forms (e.g. "Align end vertical") are recognizable across locale registers and operators pick by visual recognition or by stored value, so the UX cost is low.
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 continues to use t:-key option labels. See CLAUDE.md > Hard Rules for the documented exception and tests/unit/icon-picker-schema-contract.test.js for the contract assertions that pin Option D in place.
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. Inject the new option list into every targeted schema file.
# Writes inline EN literal labels per Plan 06.17-A Option D.
node scripts/inject-icon-options.mjs
# 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 section/block schema, expose an
{ type: "select", id: "icon", label: "t:_shared.icon.label", info: "t:_shared.icon.info", default: "<an-icon-name>", options: [] }field. - Add the file path to
TARGETSinscripts/inject-icon-options.mjs. - Run
node scripts/inject-icon-options.mjsto populate the options array. - Add the file path to
TARGET_SCHEMA_FILESintests/unit/icon-picker-schema-contract.test.js. - In the Liquid template, render the icon via
{%- render 'icon', name: block.settings.icon, size: 24 -%}(oricon: ...— both arg names are supported by the snippet for back-compat). - Gate the render on
block.settings.icon != 'none'so the no-icon pseudo-value doesn't try to look up a missing case.
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 type | Why it stays |
|---|---|---|
sections/shipping-info.liquid | 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 | image_picker | Same — merchants upload Visa / Mastercard / Norton / SSL trust marks. The whole point of these badges is the recognizable brand mark, which is not a Lucide icon. |
blocks/cart-pet-type-indicator.liquid | checkbox | The show_icon toggle decides whether to render the niche-preset's hardcoded pet-type icon, not which icon to render. The icon comes from the Gaoth preset's pet-type metafield mapping, not from a merchant pick. |
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 (per CLAUDE.md the 30 KB per-asset cap applies to
assets/*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 ≈ 50 KB per locale × 50 locales = ≈ 2.5 MB across all locale files. 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.