Shortcodes.js is a completely front-end solution that uses sequences of DOM elements for templating dynamic and beautiful layouts while preserving SEO where backend development is limited, e.g. Shopify.
Being completely frontend solution means there will be some CLS, or the page will flicker on initial load, but it's a small price to pay for the flexibility and ease of use.
Shortcodes.js works by interpreting sequences of different text formatting, set in WYSIWYG editor, under the shortcode tag, predefined for each tag via provided binding descriptor and it's respective HTML template.
For example, a simple marquee shortcode will interpret an H1 (Header size 1) as a title of the marque and an image inserted into the text as a background image.
The binding descriptor is a set of instructions on what sequence to expect and how to perform the binding of the HTML template. It is the second parameter of the shortcode.register() function, being that the first parameter is the name of the shortcode.
Load the script, make a Shortcodes object, register each shortcode with its template and descriptor, then run it on the container that holds the editor's output.
<scriptsrc="https://unpkg.com/shortcodes.js"></script><script>varshortcodes=newShortcodes()</script>main is a browser build that sets window.Shortcodes. Under a bundler import { Shortcodes } from 'shortcodes.js' gets the ES module source. The package is "type": "module", so require() throws ERR_REQUIRE_ESM. book-of-spells is the only dependency.
The template sits in the page hidden, marked with the template class, and is cloned per use. The landing is where the result goes:
<sectionclass="hero template" hidden><h1class="title-landing"></h1><divclass="text-landing"></div></section><divclass="shortcode-landing"></div>The descriptor says what to take from the text and where to put it — here the first image becomes the background, the first heading the title, everything else the text:
shortcodes.register('hero',{template: 'section.hero',anchor: '.shortcode-landing',bind_fn: 'append',elements: {img: {extract: 'src',bind_extracted: {style_property: 'background-image',extracted_property: 'src'},anchor_element: 'template'},h1: {extract: 'html',bind_extracted: 'html',anchor: '.title-landing',anchor_element: 'template'},rest: {extract: 'self',bind_extracted: 'append',anchor: '.text-landing',anchor_element: 'template'},},})shortcodes.execute(document.querySelector('.rte'))What the end user types in the WYSIWYG editor:
<p>[hero]</p><p><imgsrc="sunrise.jpg"></p><h1>Morning</h1><p>First paragraph.</p><p>Second paragraph.</p><p>[/hero]</p>section.hero lands in .shortcode-landing with the image as its background, "Morning" as the title and both paragraphs as the text. The closing [/hero] is for when the text goes on after the hero — without it, everything up to the next tag belongs to the hero.
The rules for the text. The end user never sees a descriptor.
- A tag is a paragraph that says nothing else.
[hero],[hero placement="top" wide]. A sentence that mentions[hero]is a sentence; two tags in one paragraph are a sentence too. - Only registered names count.
[shortcodes.js]as a heading stays a heading unless something is registered asshortcodes. - Everything after the tag belongs to it, up to the next tag or its closing tag
[/hero]. Text before the first tag is left alone. - A closing tag that closes nothing stays on the page as
[/hero], so the mistake is visible where it was made. - Empty paragraphs are skipped. An
<img>or an<hr>is never empty. - A paragraph holding only images is each of those images.
<p><img><img></p>is two. - A paragraph is unwrapped only when the element is all it holds.
<p><a>Buy</a></p>is the link;<p>Read the <a>docs</a> first</p>is a paragraph and goes torest— unwrapping it would drop the sentence. A line break beside the element does not count. - Keys are tried in the descriptor's order.
<p><a><img></a></p>is the image under a descriptor that listsimgbeforea, the link under one that listsafirst. - A list keeps its images.
<ul><li><img></li></ul>is a list. - Code is never a tag. A
<pre>, or a paragraph containing one, is not read for tags. - In a repeater,
{red}on a line of its own starts the next item and gives it the classred;{red big}gives it two. A repeated key starts the next item as well — a second image is a second slide.
On the tag: [hero placement="top" color="white" wide].
| Attribute | Does |
|---|---|
placement="top" | renders into .shortcode-landing-top; the prefix is the placement_class_prefix option |
placement="content" | renders in place, where the tag was typed. Wants a [/hero] when text follows |
body-class="x" | adds the class to <body> |
header-class="x" | adds the class to the descriptor's header_selector, else <header>, else <body> |
background-color, background-image, color | inline style on the rendered template; background-image is quoted and escaped on its way into url() |
| anything else, with or without a value | a class on the rendered template, by name: wide → wide, highlighted="yes" → highlighted |
Values arrive typed — "3" is 3, "false" is false, entities decoded. A descriptor's attribute_parsers adds parsers by attribute name, or replaces the built-in ones.
| Key | Does |
|---|---|
template | selector of the template to clone, found together with the template_class: section.hero.template. None matching throws, naming the selector |
anchor | where the rendered template goes: a selector, or 'self' for where the tag stood. placement on the tag overrides it |
bind_fn | how it goes there: append, prepend, html, text, style — copies the bind_property styles from the template onto the anchor — or function(templates, anchors, bind_property, shortcode_obj) |
bind_property | the style properties bind_fn: 'style' copies |
item_template, item_anchor | a repeater: item_template is cloned once per item and bound into item_anchor inside the template |
elements | what to take from the text, by selector — below |
pre(shortcode_obj) | runs before templating, attributes already applied |
callback(template, shortcode_obj) | runs after; template is the rendered clone, or the anchor element when there is no template |
attribute_parsers | { name: function(shortcode_obj, value) }, by attribute name |
header_selector | where header-class lands |
detachElements | remove the collected elements from the source; overrides the option of the same name |
Each key is a selector — img, h1, 'h2,h3,h4,h5,h6', .pull-quote — tried against the text's elements in the order written. rest takes what matched none.
| Key | Does |
|---|---|
count | how many the key takes: a number, 'many', 1 when unset; rest takes all. Past the count an element is left in the source, uncollected, and the console says which |
extract | what to read from the element: html, text, self (the element itself), an attribute name, { name, extract_fn(element) }, { name: 'style', properties: [...] } — or an array of these. Nothing to extract, nothing bound |
parse_extracted(extracted, element, dest, props, shortcode_obj, num) | edits the extract before it is bound; a string names a global function |
bind_extracted | where each extract goes: html, text, append or prepend of self, an attribute name — set from the extract of that name, skipped when the source had none — { style_property, extracted_property }, or function(destination, extracted, props, shortcode_obj, num). Or an array of these |
anchor | selector of the landing inside the template, or the template itself when it matches. Unset, the template |
anchor_element | when present, the landing is searched inside the template — the item template in a repeater. Without it, inside the page anchor, for binding into something already on the page. The value is not read |
In a repeater every key takes one element per item. count still caps how many are collected in total, so leave it 'many' there.
new Shortcodes({ ... }):
| Option | Default | Does |
|---|---|---|
template_class | 'template' | marks templates; the clone loses it, and hidden and aria-hidden with it |
self_anchor_class | 'self-anchor' | class of the <div> left where a tag stood — the landing for placement="content" |
placement_class_prefix | 'shortcode-landing' | placement="top" → .shortcode-landing-top |
detachElements | false | remove the collected elements from the source. false leaves them, and extract: 'self' moves them anyway |
shopify_full_size | true | strips the _600x600 size Shopify's editor puts on an image src, so the template gets the original. Touches *.shopify.* URLs only |
| Call | Does |
|---|---|
register(name, descriptor) | [name] renders by the descriptor |
execute(container, callback) | renders every tag under container, the element holding the editor's output; the container is hidden while it runs. callback(shortcodes_by_uid, exec_fns) after |
reinitialize(container, callback) | clear(), then execute() |
clear() | removes every rendered template (.shortcode-js) and every self anchor of the configured class from the document |
getTemplate(selector) | a clone of the template, the way the engine takes one |
shopifyImageLink(src, width) | the same Shopify image src at width |
execute consumes the tags: each tag paragraph is removed and a <div class="self-anchor shortcode-hero hero-sc-0"> left in its place. Run it once per container, or on a fresh copy each time — the demo re-renders from a cloneNode(true) of the editor. Afterwards the source still holds what was copied out of it (html, text, an attribute) and not what was moved (self); hide the container, or set detachElements.
- The console says what went missing: an element past its key's
count, and where it was left; aplacementoranchornothing on the page matches; an elementanchoror anitem_anchorthe template lacks. - A descriptor is checked when registered. A key that is not one, or one from the old per-element api (
extract_fn,bind_fn),extract: 'attr', acountthat is not a number or'many', a repeater with noitem_anchor— one console line each, with the path and the fix. The descriptor is used as given. - A missing template throws —
shortcodes.js: no template matches "section.hero.template"— and the container is shown again before the throw leavesexecute. - No script, no layout. The text stays as typed, tag lines included.
💡 How about optional caching in local storage? We can cache the whole DOM, and then just trigger the callback.
Every descriptor key in one place, with what each one does. The sections above have the details. NOTE: Descriptor nomenclature might change with every major release.
varexample_descriptor={template: "section.carousel",anchor: ".carousel-landing",// or "self" if you use a closing tag for shortcode this will inject the shortcode at the place it was founditem_template: ".slide.section-carousel",item_anchor: ".slides-landing",pre: function(shortcode_obj){/*...*/},//callback before templatingattribute_parsers: {custom: function(shortcode_obj,value){}/*...*/},//parsers by attribute name, overriding the built-in ones (placement, body-class, header-class, background-color, background-image, color). This example runs for custom="..."callback: function(template,shortcode_obj){/*...*/},//callback after templating is complete, with arguments sufficient for doing some manual additional programmingbind_fn: "append",// html, text, append, prepend, style, or a function (source, destination, property_name, shortcode_obj, num)elements: {img: {count: "many",bind_extracted: function(destination,extracted,properties,shortcode_obj,num,){// console.log('whoa');},extract: ["src","alt"],//attribute names, a string or an arrayanchor: ".image-landing",anchor_element: "item",},"h1, h2, h3, h4": {count: "many",// count = 'many' or numbind_extracted: "html",// html, text, append, prepend, a { style_property, extracted_property } object, or a function (destination, extracted, props, shortcode_obj, num)extract: "html",// html, text, self, an attribute name, or { name, extract_fn }anchor: ".title-landing",// where on element should it landanchor_element: "item",// when present, bind inside the template (the item template in a repeater); without it, inside the page anchor},rest: {//rest items are usedextract: "self",bind_extracted: "append",anchor: ".rest-landing",anchor_element: "item",},},};shortcodes.register("example",example_descriptor);//will register [example] shortcode based on provided descriptor, which contains all the information required for parsing DOM and templates with the instructions for templating.- 2016: Started on a Shopify site where the style was demanding and I didn't want to update the site by hand for the client every few weeks. Shopify had no way to let them do it.
- Later that year Shopify shipped sections — homepage only. Custom PDP layouts and banners between paragraphs still needed a developer, so
shortcodes.jswent from project to project as a single file, growing as each site needed. - 2021: Online Store 2.0, sections on every page. 2023: metaobjects. Per-product content became native — in slots a developer defines. The writer still cannot put a third image where the theme gave two, and a different layout per product means an alternate template each, assigned by hand, 1,000 per theme.
- 2023: published to npm, on the last Shopify project I took.
- 2026: back on that site three years later. Everything native fills fixed slots; a writer composing components inside their own text, in any number and order, still has nothing else. For that reason this old code still deserves a bit of love.