Skip to content

Shopify

WaveformPlayer is a single CSS file and a single browser-global script — no build step, no bundler. That makes it a natural fit for Shopify themes, where you drop the assets into your theme, then render players declaratively with data-* attributes generated by Liquid (a product audio file, a metafield, a section setting, and so on).

This page covers both hosting options (theme assets/ or CDN), the Liquid markup contract, a reusable snippet, a full section with schema settings, and the Theme Editor lifecycle hooks you need so players keep working when merchants add or remove sections live.

The IIFE build (dist/waveform-player.min.js) exposes a window.WaveformPlayer global and auto-initialises on DOMContentLoaded: it scans the DOM for any element carrying data-waveform-player and instantiates a player for it. You never have to write JavaScript for the common case — emit the right markup from Liquid and the script does the rest.

WaveformPlayer.init() is idempotent (it marks each element with data-waveform-initialized="true" and skips already-initialised nodes), which matters for Shopify’s Theme Editor — see Theme Editor lifecycle.

You have two options. Self-hosting in the theme’s assets/ folder is recommended for production (one origin, no third-party dependency, served from Shopify’s CDN). A public CDN is fine for a quick prototype.

  1. Get the two dist files. Install the package and copy them out, or download them from a CDN.
Terminal window
npm install @arraypress/waveform-player

You need exactly two files from node_modules/@arraypress/waveform-player/dist/:

  • waveform-player.min.js
  • waveform-player.css
  1. Upload both to your theme’s Assets (Online Store → Themes → ⋯ → Edit code → Assets → Add a new asset), or via the Shopify CLI / GitHub integration. Shopify’s assets/ folder is flat — no subfolders — so they live as assets/waveform-player.min.js and assets/waveform-player.css.

  2. Reference them in layout/theme.liquid, inside <head> for the stylesheet and before </body> for the script.

    {{ 'waveform-player.css' | asset_url | stylesheet_tag }}
    <script src="{{ 'waveform-player.min.js' | asset_url }}" defer></script>

The asset_url filter rewrites the filename to the theme’s CDN URL, and stylesheet_tag emits a proper <link rel="stylesheet">. defer lets the script load without blocking render; auto-init still fires on DOMContentLoaded.

For prototypes or themes where you would rather not manage asset files, point at jsDelivr or unpkg. Pin a major version (@1) so you get patches without surprise breakage.

{# jsDelivr #}
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@arraypress/waveform-player@1/dist/waveform-player.css">
<script
src="https://cdn.jsdelivr.net/npm/@arraypress/waveform-player@1/dist/waveform-player.min.js"
defer></script>
{# unpkg (the package's "unpkg" entry resolves to the min build) #}
<script src="https://unpkg.com/@arraypress/waveform-player@1" defer></script>

A player is any element with data-waveform-player plus a source. Every constructor option has a data-* equivalent, parsed by the library at init — so a fully-configured player is pure markup. The minimum is a source URL:

<div
data-waveform-player
data-src="{{ 'demo-track.mp3' | asset_url }}"></div>

data-src is an alias for data-url; if both are present, data-url (canonical) wins. The full list of attributes is on the Data attributes page; the most useful ones for theme work:

Attribute Maps to Notes
data-src / data-url url Audio file URL. data-url wins if both set.
data-title title Falls back to the URL filename if omitted.
data-artist artist Second info line (e.g. artist).
data-artwork artwork 40×40 thumbnail + Media Session art.
data-waveform-style / data-style waveformStyle bars · mirror · line · blocks · dots · seekbar.
data-height height Waveform height in px (default 64).
data-waveform-color waveformColor CSS colour, or a JSON array of gradient stops.
data-progress-color progressColor Played-through colour (or JSON gradient array).
data-color-preset colorPreset dark / light; omit to auto-detect the theme.
data-waveform waveform Pre-computed peaks (CSV / JSON array / .json URL) to skip Web Audio decode.
data-show-bpm showBPM Boolean string "true".
data-bpm bpm Known BPM; wins over auto-detection.
data-autoplay autoplay Boolean string "true".
data-layout layout default or preview.

The source is usually not a hard-coded asset. Common Shopify patterns:

{# A) A file uploaded to Settings → Files, referenced by name #}
<div
data-waveform-player
data-src="{{ 'single-preview.mp3' | file_url }}"
data-title="Single preview"></div>
{# B) A product metafield holding the audio file (file_reference type) #}
{%- assign audio = product.metafields.custom.audio_file -%}
{%- if audio != blank -%}
<div
data-waveform-player
data-src="{{ audio | file_url }}"
data-title="{{ product.title | escape }}"
data-artwork="{{ product.featured_image | image_url: width: 80 }}"
data-waveform-style="mirror"
data-show-bpm="true"></div>
{%- endif -%}
{# C) A URL/text metafield (just output the value) #}
<div
data-waveform-player
data-src="{{ product.metafields.custom.preview_url }}"
data-title="{{ product.title | escape }}"></div>

Render a player per product in a collection (or per line item in a cart), guarding on the metafield so products without audio are skipped:

<ul class="audio-grid">
{%- for product in collection.products -%}
{%- assign audio = product.metafields.custom.audio_file -%}
{%- if audio != blank -%}
<li>
<div
data-waveform-player
data-src="{{ audio | file_url }}"
data-title="{{ product.title | escape }}"
data-artist="{{ product.vendor | escape }}"
data-artwork="{{ product.featured_image | image_url: width: 80 }}"
data-waveform-style="bars"
data-layout="preview"></div>
</li>
{%- endif -%}
{%- endfor -%}
</ul>

By default each player pauses every other instance when it starts (singlePlay, on by default), so a grid behaves like one playlist — only one track plays at a time. Set data-single-play="false" on a player to opt it out.

Extract the markup into snippets/waveform-player.liquid so templates stay clean. Render it with named parameters:

{% comment %} snippets/waveform-player.liquid
Renders a WaveformPlayer. Required: url. Optional: title, artist,
artwork, style, peaks (a .json peaks URL), bpm.
{% endcomment %}
{%- if url != blank -%}
<div
data-waveform-player
data-src="{{ url }}"
{% if title != blank %}data-title="{{ title | escape }}"{% endif %}
{% if artist != blank %}data-artist="{{ artist | escape }}"{% endif %}
{% if artwork != blank %}data-artwork="{{ artwork }}"{% endif %}
{% if peaks != blank %}data-waveform="{{ peaks }}"{% endif %}
{% if bpm != blank %}data-show-bpm="true" data-bpm="{{ bpm }}"{% endif %}
data-waveform-style="{{ style | default: 'mirror' }}"></div>
{%- endif -%}

Call it from a product template:

{% render 'waveform-player',
url: product.metafields.custom.audio_file | file_url,
title: product.title,
artist: product.vendor,
artwork: product.featured_image | image_url: width: 80,
style: 'mirror' %}

A self-contained section with schema settings lets merchants drop a player onto any page from the Theme Editor and configure it visually. Save as sections/audio-player.liquid.

{%- comment -%} sections/audio-player.liquid {%- endcomment -%}
{{ 'waveform-player.css' | asset_url | stylesheet_tag }}
<div class="audio-player-section" style="max-width: {{ section.settings.max_width }}px">
{%- assign source = section.settings.audio_file | default: section.settings.audio_url -%}
{%- if source != blank -%}
<div
data-waveform-player
data-src="{{ source | file_url }}"
data-title="{{ section.settings.title | escape }}"
data-artist="{{ section.settings.artist | escape }}"
data-waveform-style="{{ section.settings.style }}"
data-color-preset="{{ section.settings.preset }}"
data-height="{{ section.settings.height }}"
{% if section.settings.waveform_color != blank %}
data-waveform-color="{{ section.settings.waveform_color }}"{% endif %}
{% if section.settings.show_bpm %}data-show-bpm="true"{% endif %}></div>
{%- else -%}
<p>{{ 'Add an audio file in the section settings.' }}</p>
{%- endif -%}
</div>
<script src="{{ 'waveform-player.min.js' | asset_url }}" defer></script>
{% schema %}
{
"name": "Audio player",
"settings": [
{ "type": "text", "id": "title", "label": "Title" },
{ "type": "text", "id": "artist", "label": "Artist" },
{ "type": "url", "id": "audio_url", "label": "Audio URL" },
{
"type": "select", "id": "style", "label": "Waveform style", "default": "mirror",
"options": [
{ "value": "bars", "label": "Bars" },
{ "value": "mirror", "label": "Mirror" },
{ "value": "line", "label": "Line" },
{ "value": "blocks", "label": "Blocks" },
{ "value": "dots", "label": "Dots" },
{ "value": "seekbar", "label": "Seekbar" }
]
},
{
"type": "select", "id": "preset", "label": "Colour preset", "default": "dark",
"options": [
{ "value": "dark", "label": "Dark" },
{ "value": "light", "label": "Light" }
]
},
{ "type": "color", "id": "waveform_color", "label": "Waveform colour" },
{ "type": "range", "id": "height", "label": "Height", "min": 40, "max": 160, "step": 8, "unit": "px", "default": 64 },
{ "type": "range", "id": "max_width", "label": "Max width", "min": 320, "max": 960, "step": 20, "unit": "px", "default": 640 },
{ "type": "checkbox", "id": "show_bpm", "label": "Show BPM", "default": false }
],
"presets": [{ "name": "Audio player" }]
}
{% endschema %}

The Theme Editor’s color setting returns a CSS colour string, which maps straight onto data-waveform-color. The url setting accepts file picks and external URLs; file_url returns it unchanged for absolute URLs, so it’s safe to apply in both cases.

In the Theme Editor, merchants add, remove, reorder, and re-render sections without a full page reload. Auto-init only runs once (on DOMContentLoaded), so a section injected after load won’t be picked up — and a removed section leaves a player instance behind. Wire the four Shopify theme events to keep players in sync. Put this once in theme.liquid (or in your section, guarded so it only binds once):

<script>
document.addEventListener('shopify:section:load', function (event) {
// A section was (re)rendered — init any new players inside it.
// WaveformPlayer.init() is idempotent, so already-init'd nodes are skipped.
if (window.WaveformPlayer) window.WaveformPlayer.init();
});
document.addEventListener('shopify:section:unload', function (event) {
// Tear down players in the removed section to free audio + listeners.
if (!window.WaveformPlayer) return;
event.target
.querySelectorAll('[data-waveform-player]')
.forEach(function (el) {
var player = window.WaveformPlayer.getInstance(el);
if (player) player.destroy();
});
});
</script>
Event What to do
shopify:section:load Call WaveformPlayer.init() to instantiate newly-rendered players (idempotent — safe to call repeatedly).
shopify:section:unload getInstance(el) then destroy() each player in event.target to release the <audio>, listeners, and observers.
shopify:block:select / shopify:block:deselect Optional: pause or highlight when a merchant selects a block.

destroy() emits waveformplayer:destroy, pauses playback, aborts all listeners, disconnects the ResizeObserver, and empties the container — so removing a section won’t leak audio or leave a track playing.

For interactivity — a sticky bottom bar that follows the customer across the store, “add to playlist” buttons, or syncing a player with a custom control — drop down to the JavaScript API via the window.WaveformPlayer global:

<script>
document.addEventListener('DOMContentLoaded', function () {
var el = document.querySelector('[data-waveform-player]');
var player = window.WaveformPlayer.getInstance(el);
player.container.addEventListener('waveformplayer:play', function (e) {
// e.detail = { player, url } — fire analytics, etc.
});
});
</script>

See:

Methods & events

The runtime API and event map. API →

  1. waveform-player.css and waveform-player.min.js uploaded to assets/ (or a pinned CDN URL).
  2. Stylesheet linked in <head>, script loaded with defer before </body>.
  3. Players rendered with data-waveform-player + data-src from Liquid.
  4. Dynamic strings escaped with | escape; booleans emitted as the string "true".
  5. shopify:section:load / shopify:section:unload wired so the Theme Editor stays in sync.