The SharedLayers script
One script tag turns any page into a place where people can leave comments pinned to specific elements. This is everything a developer installing it needs to know: the snippet, every parameter, and exactly what it does once it is on your page.
Describes the script as served from sharedlayers.com on 1 October 2026.
1Quick start
Paste this before the closing </body> tag of every page you want people to comment on. Pick how the toolbar appears, add your key, and copy:
Stays on this page. Keys are under Install in the dashboard.
<script async
src="https://sharedlayers.com/comments.umd.js"
data-key="sl_embed_…">
</script>
Your key is in the dashboard under Install. Reload the page and the toolbar appears: along the top as a bar, or in the corner you picked. Anyone who visits can enter a name and start commenting. Visitors need no extension and no account. Every option is described under Configuration reference.
Installing by script is part of the Pro plan. On the free plan the script logs one line to the console and does not appear. The Chrome extension shows the same comments on any site and is free. See Script or extension?
That is the whole installation. The rest of this page is for when you want to change how it behaves, or need to know what it is doing.
2Installation
Script tag
The file at https://sharedlayers.com/comments.umd.js is a classic script. When it runs it reads its own tag for data- attributes and initialises itself. A few things follow from that:
- Use a plain
<script>tag, nottype="module". A module script cannot see its own tag, so the attributes would be ignored and nothing would happen. If you want a module, use the ES build and callinityourself. - Position does not matter much. Before
</body>is the convention. If the tag is in<head>or the document is still loading, the script sends its boot request straight away and waits forDOMContentLoadedbefore mounting. - Add
async. The file then downloads while the page is still parsing, and the boot request goes out that much earlier.deferis fine too. Without either, the parser stops for the download; nothing else changes. - Install it once per page. A second copy logs
[Comments] Already initialized.and does nothing. - The file is served with CORS open (
Access-Control-Allow-Origin: *), socrossoriginattributes and subresource integrity hashes are permitted, but the file is updated in place, so an integrity hash will break on the next release. See Versioning.
ES module
The same build is also served as an ES module at https://sharedlayers.com/comments.es.js. It exports one object, Comments, with one method, init. A module has no document.currentScript, so you pass the options yourself:
<script type="module">
import { Comments } from 'https://sharedlayers.com/comments.es.js';
Comments.init({ key: 'sl_embed_…' });
</script>
The same import works from a bundler if you would rather vendor the file. init returns a promise that resolves once the widget has mounted, or once it has decided not to. On a repeat visit within the hour it mounts on the remembered key check and resolves before the fresh answer is back; see Loading and performance. It never rejects: every refusal is a console warning, not an exception, so a broken key cannot take down your page.
There is no destroy. The widget lives for the lifetime of the page.
| Option | Type | Default |
|---|---|---|
key | string | required |
layout | 'bar' | 'button' | 'floating' | 'bar' |
placement | 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'bottom-right' |
pageParams | string[] | none |
snapshots | boolean | true |
projectId | string | legacy, see below |
Frameworks and platforms
The script does not care how the page was built. It needs to end up as a script tag in the served HTML, once, on the pages you want reviewed.
Next.js (App Router)
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Script
src="https://sharedlayers.com/comments.umd.js"
data-key="sl_embed_…"
strategy="afterInteractive"
/>
</body>
</html>
);
}
React, Vue, Svelte, Angular and other single-page apps
Put the tag in the one HTML file the app is served from, usually index.html. Do not mount it inside a component: components unmount and remount, and the widget is meant to be installed once. Client-side navigation is handled for you. See Single-page apps.
Only on staging
Most teams want comments on staging and preview environments, not on production. The cleanest way is to render the tag only in those builds. When you cannot, gate it by hostname:
<script>
if (!/(^|\.)acme\.com$/.test(location.hostname) || location.hostname.startsWith('staging.')) {
const s = document.createElement('script');
s.src = 'https://sharedlayers.com/comments.umd.js';
s.dataset.key = 'sl_embed_…';
document.body.appendChild(s);
}
</script>
A dynamically created tag works because the browser still sets document.currentScript while it runs. Every host the script loads on counts towards the key's domain limit, so gating also keeps production from using up a slot. See Embed keys and domains.
WordPress
Add the tag to your theme's footer.php before </body>, or use a header-and-footer plugin that lets you paste raw HTML into the footer.
Webflow, Squarespace, Wix, Shopify
Each has a place for custom code injected before </body>. In Webflow it is Site settings, Custom code, Footer code. In Shopify it is the theme's theme.liquid. Paste the tag there.
Google Tag Manager
A Custom HTML tag containing the snippet works. Prefer a direct tag in the page when you can: a tag manager adds its own delay, and reviewers on the page have to wait for it.
3Configuration reference
Every option has two spellings that mean the same thing: an attribute on the script tag, and a property passed to Comments.init.
| Attribute | Option | Values | What it does |
|---|---|---|---|
data-key | key | sl_embed_… | Identifies your site. Required. |
data-layout | layout | bar · button · floating | A bar across the top of the page, or a button in a corner that opens into a pill. Default bar. |
data-placement | placement | top-right · top-left · bottom-right · bottom-left | Which corner the button sits in. Default bottom-right. The bar ignores it. |
data-page-params | pageParams | names, or * | Which query parameters make a different page. Default: none. |
data-snapshots | snapshots | off · false | Turns off the picture of the viewport kept with each comment. Default: on. |
data-project-id | projectId | UUID | The identifier snippets used before keys existed. Still works. |
data-key / key
The one thing a customer pastes. The key says which site this is and lets the script check that the account it belongs to may embed. It is meant to be public: it ships in your HTML, and it grants nothing by itself. Reading and writing comments still happens as the visitor's own session, under the database's row-level security.
Get one, label it and revoke it under Install in the dashboard. Details in Embed keys and domains.
data-layout / layout
Bar is a 44 px strip fixed to the top of the page. It is the default for a script install because the script is a guest on a finished site, and a floating control would sit on top of whatever the designer put in that corner, which is usually a chat launcher or a cookie notice. The bar takes its space rather than borrowing it:
<html>gets a custom property,--sl-inset-top: 44px, which your own CSS may read.<body>gets 44 px of extra top padding. Padding rather than margin, so your background still paints behind the bar.- Every
position: fixedorposition: stickyelement with a pixeltopis shifted down by 44 px. Elements with a percentage orcalc()top, or anchored to both top and bottom, are left alone. Headers added to the page later are caught by a mutation observer. - On screens narrower than 640 px the bar is not shown and all of this is undone. It is reapplied if the window grows again.
Any value other than button or floating, including no attribute at all, means bar.
Button is one round button in a corner, with the SharedLayers mark in grey. It takes no space from the page. A click opens it into the same pill the Chrome extension shows: layer, comment, filters, side panel and sign-in, with the mark, now green, at the end nearest the corner. Clicking the mark again, or pressing Alt+H (⌥H on a Mac), folds it back. Choose it when your page has a header the bar ends up covering, or when a strip along the top would break the design being reviewed.
- Each visitor's choice is remembered on your domain, so a reviewer who opened it finds it open on the next page.
- Folding hides the controls, not the comments: pins stay on the page and can still be opened. For a signed-in reader the folded button shows the number of unread comments.
- Anything that needs the controls opens them: Alt+C to start a comment, or a reply that asks a visitor for their name first.
- Sign in, filters and the layer menu open away from the edge, so up from a bottom corner and down from a top one.
<script async
src="https://sharedlayers.com/comments.umd.js"
data-key="sl_embed_…"
data-layout="button">
</script>
Floating is the same pill, already open on a visitor's first view. It folds into the button the same way. Installs that used floating before the button existed keep working unchanged, and can now be folded.
data-placement / placement
Which corner the button and its pill sit in, 20 px from both edges. Pick the corner your own interface leaves free: a chat launcher usually has bottom right, a cookie notice bottom left, and a top corner sits over your header. The pill opens along the edge from its corner, and its menus open towards the middle of the window.
The words can come in either order, and a space works in place of the dash: "left top" is top-left. Anything else is bottom-right. The bar ignores this option.
<script async
src="https://sharedlayers.com/comments.umd.js"
data-key="sl_embed_…"
data-layout="button"
data-placement="top-left">
</script>
The snippet builder at the top of this page writes this tag for you: pick the layout and the corner, paste your key, and copy.
data-page-params / pageParams
Comments are kept per page, and by default a page is its path. /pricing and /pricing?tab=annual&utm_source=x are the same page and show the same comments. The hash is never part of the page.
Some sites route by a query parameter instead: ?page=/inner, ?p=123, ?product=shoe. Name those parameters and they become part of the page's identity:
<!-- ?page=/inner and ?page=/other are different pages, -->
<!-- and ?page=/inner&utm=x is the same page as ?page=/inner -->
<script … data-page-params="page"></script>
<!-- two parameters -->
<script … data-page-params="category, product"></script>
<!-- every parameter counts -->
<script … data-page-params="*"></script>
Named parameters are sorted by name before they join the key, so ?a=1&b=2 and ?b=2&a=1 are one page. Parameters you did not name are ignored even when present.
Changing this setting moves comments. A comment left on /shop?product=shoe while the default was in force is filed under /shop. After you name product, it stays on /shop, which no longer matches the page it was left on. Decide before people start commenting, or accept that older comments will appear on the bare path.
The hostname is part of the site, not the page. www.acme.com and acme.com are treated as the same site. app.acme.com and staging.acme.com are different sites with their own comments.
data-snapshots / snapshots
When someone leaves a comment, the script renders a picture of what they were looking at and stores it with the comment. A reviewer reading the comment later can open the picture, which is what makes "the button here is misaligned" still make sense after the page has changed. The picture is:
- The viewport only, not the whole page, as the commenter saw it.
- Rendered in the browser from the DOM, at most 1280 px wide, as WebP with a JPEG fallback.
- Taken in the background after the comment is saved, with an 8 second budget. If it does not finish, the comment simply has no picture.
- Blank where the page shows cross-origin images or iframes, because the browser will not let a script read those pixels. A video from another origin shows its poster, or nothing. Same-origin iframes are painted.
- Uploaded to SharedLayers storage and shown only through short-lived signed links to people who can see the comment.
Turn it off when your screens carry personal data that should not leave the browser, or when the render is too slow on very large pages:
<script async
src="https://sharedlayers.com/comments.umd.js"
data-key="sl_embed_…"
data-snapshots="off">
</script>
With snapshots off, nothing visual leaves the page. Comments still carry the element's selector and a short text label; see What leaves the page.
data-project-id / projectId
Before embed keys existed the snippet carried a project id. Those snippets keep working and are not going away. Two things are different on this path: the script checks the licence against the project rather than the account, and Google sign-in is not offered, because the sign-in popup verifies the calling site against a key. If you have one of these snippets, swapping the attribute for a data-key from the dashboard is the upgrade. Existing comments stay where they are.
4Embed keys and domains
An embed key is a string starting with sl_embed_. It is created under Install in the dashboard, where you can give it a label such as the site it is for.
What a key does
On every page load the script sends the key and the page's hostname to SharedLayers and gets back the site it stands for, whether the account may embed, and whether this hostname is allowed. Only then does it draw anything. A key that fails this check makes the script log one warning and stop. The check cannot be skipped from the page: without it the script has no site to show comments for.
Domains
Each key covers a fixed number of domains, shown on the key in the dashboard as used / limit. The first time the script loads with a key on a hostname it has not seen, that hostname is recorded against the key. When the key is full, the script refuses to load on any new hostname and says so in the console, with the numbers. Existing domains keep working.
- A domain is the hostname with a leading
www.removed.www.acme.comandacme.comare one domain.staging.acme.comis another. - Every preview and branch deployment with its own hostname is its own domain. If your host mints a hostname per pull request, gate the script so it only loads on the hostnames you mean. See Only on staging.
- Remove a domain from a key in the dashboard to free the slot.
- More domains come as more blocks under Billing. One key per site is a good habit regardless: it is what lets you revoke one site without touching the others.
Revoking and regenerating
Revoking a key makes every snippet that carries it stop loading, immediately on the next page view. Comments are not deleted. Regenerating a key gives it a new string while keeping the same site and comments; update the snippet and the old string stops working.
A revoked key and a mistyped key look the same to the script, and both produce the "not recognised" warning listed under Console messages.
5What the script does to your page
The script is a guest. It is built to leave the page it lands on alone, and this section is the precise account of what it does touch.
DOM footprint
After a successful key check the script adds three things and nothing else:
| Node | Where | What it is |
|---|---|---|
div#comments-widget-host | end of <body> | A zero-size position: fixed element holding an open shadow root. The toolbar, side panel, login panel and menus render inside it. z-index: 2147483646. |
div#comments-widget-pins | end of <body> | A zero-size position: absolute element at the page origin. Pins and comment bubbles are drawn here in page coordinates so they scroll with the content. z-index: 2147483644. Not in a shadow root, so that pins can sit over the real page. |
<style> | <head> | Styles for the pin container, scoped to #comments-widget-pins, plus the print rule below. |
Everything in the shadow root is isolated from your CSS in both directions: your stylesheet cannot restyle the toolbar, and the widget's styles cannot leak into your page. The widget does not load a stylesheet, fonts or images from a third party; its styles are in the script. The bar layout additionally sets a custom property and padding, described under layout.
Both containers have pointer-events: none; only the controls inside them accept clicks. While a visitor is in commenting mode the page receives a click to place a pin and nothing else. The widget does not intercept your forms, links or keyboard input, apart from the Alt shortcuts.
Light or dark theme is chosen per visitor from the account menu and stored in the browser. It is applied as a data attribute on the widget's own containers, never on your document.
Single-page apps
The script follows client-side navigation. When the URL changes, the open comment closes, commenting mode ends, and the pins for the new page are shown. Navigation is detected from popstate, hashchange and a check of the URL every 400 ms. The script deliberately does not patch history.pushState: reaching into the host page's History object is not something a guest should do, and the poll costs nothing measurable.
If your router re-renders large parts of the page, pins re-anchor to the new elements by selector. See the next section for what makes that reliable.
How pins anchor
A pin is not a pair of coordinates. When a comment is placed, the script records the element under the cursor and where within that element the click landed, as fractions of its box. Absolute page coordinates are kept only as a fallback. When the page is shown again, the element is found and the pin is drawn at the same fraction of wherever the element now is, so pins survive responsive layouts, content that grows above them and most redesigns.
The element is identified by a selector, chosen in this order:
- Its
id, if it has one. - Its first non-empty
data-attribute, such asdata-testidordata-section. - A path of up to five levels of tag, class and
nth-of-type, checked to be unique on the page. - Failing all of that,
bodywith the stored coordinates.
This has practical consequences for a page under review:
- Stable ids and data attributes give the most durable pins. A hashed class name from a CSS-in-JS library changes on every build and will not be found again. If you already use
data-testidfor tests, pins get that for free. - Same-origin iframes and open shadow roots are supported. A pin can land on an element inside either, and the selector records the boundary it crossed. Cross-origin iframes are opaque: a pin over one anchors to the iframe element itself.
- An element that disappears takes its pins with it until it returns. The comment is still in the side panel; only the pin on the page is missing.
Pin positions are recomputed at most once per animation frame, on resize, on scroll inside any scrollable box on the page, on scroll and resize inside same-origin iframes, and on navigation. There is no scroll listener on your window as a rule: pins live in page coordinates and scroll natively.
Elements that do not scroll with the page are followed too. A pin on anything inside a position: fixed ancestor is drawn fixed, in viewport coordinates, so it stays on its element while the page moves underneath, and so do its comment thread and the form while a comment is being written. A pin on anything inside a position: sticky ancestor is recomputed on the window's scroll for as long as such a pin is on the page, which is the one case where the window's scroll is listened to. A fixed element under a transformed ancestor is not fixed to the viewport, and is treated as the ordinary page content it behaves as.
A pin can be pointed at a different element after the fact: its author drags it, and where it is dropped is re-anchored exactly as the first placement was, element and all — the same outline and selector label appear while it travels, and a same-origin iframe or open shadow root is as valid a destination as the top document. Only the author's drag does anything; for everybody else the pin only opens its thread, which is what a press that never moves does in either case. Escape during the drag puts it back, and so does the Undo on the confirmation.
Small screens and print
Below 640 px wide the widget renders nothing and fetches nothing. Reviewing a phone layout is best done in a desktop browser's device mode, where the window stays wide. The check follows the window, so a desktop window dragged narrow hides the widget and dragged wide brings it back.
Both containers carry display: none under @media print. Nothing of SharedLayers appears on a printed page or in a print-to-PDF.
Loading and performance
- One file. The script is about 185 KB compressed. It carries its own rendering library, styles and database client; nothing else is fetched to draw the toolbar.
- Cached at the edge. The file is served with a 5 minute freshness window and a day of stale-while-revalidate, so repeat views cost nothing and a fix reaches every installation within minutes. Details under Versioning.
- One request to boot. The key check, the layer list and the comments of the current layer come back in a single call, sent the moment the script runs, before the page has finished parsing.
asyncon the tag brings that moment forward. - The first visit waits for that answer before anything is drawn; half-mounting and retracting would be worse than appearing a beat late. Visits within the next hour mount at once on the remembered answer and ask again in the background. If the key has been revoked, the plan has lapsed or the domain limit has been reached since, the widget removes itself again. A loading indicator in the toolbar gives up after 6 seconds and shows the controls regardless.
- Sign-in is read alongside, not first. The visitor's session is read from storage while the boot request is in flight, and comments appear as soon as both are back. The script then subscribes to live updates over one websocket per site. New comments from other people appear without reloading.
- Snapshots are rendered after the comment is saved, off the critical path, with a hard 8 second budget. Turn them off on very heavy pages if the render is noticeable.
6People, sign-in and layers
You install the script; the people on the page decide who they are. There are three ways in, all from the toolbar:
- Guest. A display name and nothing else. Guests can read and write comments on the site's public layers. Their session is kept in the browser, so they keep their name on return visits.
- Email and password. A full account, the same one used for the dashboard and the extension.
- Google. Opens a small popup on
sharedlayers.com, which completes sign-in and hands the session back to the page. Available only when the script was installed with an embed key, because the popup verifies the calling site against that key.
Sessions are stored in the visitor's browser on your site's origin. Nothing about your own visitors' accounts on your site is read or touched.
Who can do what
- Anyone on the page can read comments on public layers, signed in or not.
- Anyone signed in, including guests, can leave comments and replies, react, and resolve or reopen any comment on a layer they can see. Resolving is the one change a non-author may make.
- Editing and deleting a comment is for its author.
- Creating a new layer requires an account, not a guest session.
Layers
A site starts with one set of comments. Layers let a team keep separate rounds apart, such as "Redesign QA" and "Copy review". The layer chip at the left of the toolbar switches between the layers the reader may see, and remembers the choice per site in the browser. The menu lists public layers first, then your layers: a layer marked public is visible to everyone on the page; a private layer, including one made with the browser extension on the same site, is visible to its members, who join through the dashboard or an invitation link. The layer a link points to can be preselected with the sl-layer parameter described next.
7URL parameters and deep links
The script reads two query parameters on your pages and removes them from the address bar as soon as it has read them, using history.replaceState. A refresh does not reopen the comment, and a copied URL does not carry someone else's notification.
| Parameter | Meaning |
|---|---|
sl-comment | A comment id. Once comments have loaded, if the comment is on the current page it is scrolled into view, about a third of the way down the viewport, and opened. If it is not on this page or the reader cannot see it, nothing happens. |
sl-layer | A layer id. Selects that layer before comments load, so an invitation to one layer does not land on another. |
Notification emails and the dashboard build these links for you. Query parameters were chosen over the hash because the hash is the one part of a URL a single-page app is likely to route on, and an unknown query parameter is ignored by every router. Your own application should not use these two names.
8Keyboard shortcuts
| Shortcut | Action |
|---|---|
| Alt + C | Start a comment, or cancel the one being placed. |
| Alt + P | Show or hide the side panel. |
| Alt + H | Fold the toolbar into its button, or open it again. Only with button or floating; the bar does not fold. |
| Esc | Close the open comment, menu or panel. |
On a Mac the shortcuts show as ⌥C, ⌥P and ⌥H. They are matched on the physical key, so they work on keyboards where Alt+C types a different character. Alt was chosen because Ctrl and Cmd are where browsers and the pages they show keep their own shortcuts, several of them destructive. The extension adds Alt + L for a new layer, which the script install does not have, and uses Alt + H to hide SharedLayers altogether.
If your application already uses Alt+C, Alt+P or Alt+H, both handlers will fire: the widget calls preventDefault on a match but does not stop propagation.
9Security, privacy and CSP
There is no SharedLayers server between the page and the database. The script talks directly from the visitor's browser to a Supabase project operated by SharedLayers, using a public client key that is part of the script. Every read and write is authorised in the database by row-level security against the visitor's own session, which is why neither the embed key nor the client key is a secret.
What leaves the page
When a visitor leaves a comment, the following is stored:
- The comment text, replies, reactions and mentions.
- The author: display name and, for accounts, email. Public reads return authors without email.
- The page: hostname and the page key described under pageParams. The hash is never stored.
- The anchor: the element's CSS selector, the fractional position within it, absolute page coordinates as a fallback, and a short label of the form
<button.primary> "Add to cart". The label includes up to the first 40 characters of the element's text for headings, paragraphs, links, buttons and similar text-bearing elements. If those elements can show personal data on your pages, know that this much of it travels with the pin. - A snapshot of the viewport, unless snapshots are off.
Nothing is collected from visitors who never open the toolbar: no analytics, no fingerprint, no cookies. The SharedLayers privacy policy covers the service side.
Content Security Policy
If your site sends a Content-Security-Policy header, add the following. Origins are exact; there is no wildcard SharedLayers domain to allow.
script-src https://sharedlayers.com;
connect-src https://mtgpolxyqamundksrksi.supabase.co
wss://mtgpolxyqamundksrksi.supabase.co;
img-src https://mtgpolxyqamundksrksi.supabase.co
https://sharedlayers.com
https://*.googleusercontent.com
data: blob:;
style-src 'unsafe-inline';
Why each line:
- script-src: the script itself. If you vendor the ES build, this line is not needed.
- connect-src: the database over HTTPS for reads, writes, sign-in and snapshot upload, and over a websocket for live updates. Both schemes are needed.
- img-src: snapshot images and avatars are served from the database host. Avatars for accounts and the sign-in flow can come from
sharedlayers.com, and Google avatars from Google.data:andblob:are needed while a snapshot is being rendered: the page is serialised to an SVG data URL and drawn to a canvas. Drop them if snapshots are off. - style-src: the script writes its styles into
<style>elements and sets inline styles on its two containers, which a strictstyle-srcblocks. A nonce cannot be applied to a third-party script's injected styles, so'unsafe-inline'for styles is the practical setting. This is a policy on your review environment; production, if it does not carry the script, does not need it.
Google sign-in opens a popup window on sharedlayers.com. Popups are not governed by CSP, but a browser popup blocker will stop it unless it is opened from a click, which it is.
Browser storage
The script writes to localStorage on your site's origin. It sets no cookies.
| Key | Holds |
|---|---|
comments-widget-auth | The visitor's SharedLayers session, guest or account. |
sl-theme-widget | The light or dark choice made in the account menu. |
sl-layer:<site id> | The layer last chosen on this site. |
sl-key:<key>@<host> | The site this key stood for on this host, kept for an hour so that a repeat visit draws the bar before the key check answers. |
sl-toolbar-collapsed | Whether the visitor folded the button layout away (1) or opened it (0). Only the button and floating layouts write it. |
sl-upgrade-pending | Set briefly while a guest is turning into an account. |
Clearing site data for your origin signs the visitor out of SharedLayers on that site and forgets their theme, layer and whether the button was open. It does not delete anything they wrote.
10Troubleshooting
Open the browser console first. Every reason the script declines to appear is printed there, prefixed [SharedLayers], and each message says what to do.
Console messages
The tag has neither data-key nor data-project-id. Usually a templating variable that rendered empty, or a type="module" tag, which cannot see its own attributes.
The key does not match any active key. Compare it character by character with the dashboard; keys that were revoked or regenerated produce this too.
The key is on as many hostnames as it allows and this is a new one. Remove a domain from the key in the dashboard, make a second key for this site, or gate the script so preview hostnames do not load it. See Embed keys and domains.
The account that made the key is on the free plan. Upgrade, or use the Chrome extension, which shows the same comments and is free.
The request to the database host failed before it could answer. Almost always a Content Security Policy missing connect-src, a corporate proxy, or an ad blocker with an aggressive list. The error object logged with it says which. This is the one refusal that cannot be lenient: without an answer there is no site to show comments for.
Legacy data-project-id installs only. The licence check did not get an answer, and the script chose to load anyway rather than hide a paying customer's comments. Nothing to fix unless it is constant, in which case see the message above.
The script ran twice on one page: two tags, or a tag plus an init call, or a component that mounts the tag and re-renders. Install it once, outside any component.
Symptoms
Nothing appears and the console is clean
- The window is narrower than 640 px. Widen it.
- The script never ran: check the network panel for
comments.umd.js. A 404 means a typo insrc; a blocked request means CSPscript-srcor an ad blocker. - The page has a fixed element with a very high
z-indexcovering the top strip. The bar is atz-index: 2147483646, so this is rare, but a full-screen overlay withpointer-eventscan still sit above it. Trydata-layout="button"to confirm.
The bar appeared for a moment and vanished
The widget mounted on the key check remembered from an earlier visit, and the fresh answer overruled it: the key has been revoked, the account has left the Pro plan, or the key has reached its domain limit. The console says which. The remembered answer is dropped the moment it is overruled, so once the cause is fixed a reload is enough.
The bar covers my header, or my header jumped
The bar shifts fixed and sticky elements that have a pixel top. A header positioned with top: 0 is moved down 44 px, which is intended. One positioned with a percentage, calc(), or with both top and bottom set is left where it was and the bar may overlap it. Give it a pixel top, read var(--sl-inset-top, 0px) in your own CSS, or switch to data-layout="button".
Comments show on the wrong page, or every page shows the same comments
Your site distinguishes pages by a query parameter and the script does not know which. Set data-page-params. The reverse, comments that vanish when a tracking parameter is added, cannot happen unless you set data-page-params="*".
Pins drift or disappear after a deploy
The elements they were anchored to changed their selector. Typically hashed class names, or a wrapper that was added or removed. Give reviewable elements stable ids or data- attributes. See How pins anchor.
The snapshot is blank or partial
Cross-origin images, videos and iframes cannot be read and render blank; that is the browser, not the script. An image that fails to load is drawn blank too, rather than costing the whole picture. Fonts hosted cross-origin without CORS headers fall back to a system font in the picture. A snapshot that never appears usually ran out of its 8 second budget on a very large page. Set window.__SL_SNAP_DEBUG = true before commenting and the script logs how long each step took.
Google sign-in is not offered
The install uses data-project-id. Google requires an embed key, which lets the sign-in popup verify the site. Switch the attribute to data-key.
Live updates do not arrive
The websocket to the database host is blocked. Add wss://mtgpolxyqamundksrksi.supabase.co to connect-src, and check any proxy that terminates websockets. Comments still save; they just need a reload to appear for other people.
11Versioning and browser support
The script is served from one fixed URL and updated in place. There is no versioned URL to pin to, and no way to opt out of updates. This is deliberate: the snippet a customer pasted a year ago must keep working, and a fix must reach every installation without anyone editing footers.
- The file is cached for 5 minutes, then served stale for up to a day while a fresh copy is fetched in the background. A new release is on every site within minutes and repeat page views never wait for it.
- The version a visitor is running is shown at the bottom of the account menu in the toolbar. Quote it when reporting a problem.
- Because the file changes, a subresource integrity hash on the tag will fail at the next release. Do not add one.
- Behaviour a page can depend on, meaning the attributes on this page, the two container ids, the custom property, the URL parameters and the storage keys, is treated as a public contract and is not changed without notice here.
Browsers
The script is compiled to ES2020 and runs in the current versions of Chrome, Edge, Firefox and Safari, and the last couple of releases before them. It uses shadow DOM, MutationObserver and computedStyleMap, all of which those browsers have shipped for years. Internet Explorer is not supported. Screens narrower than 640 px, whatever the browser, render nothing.
12Script or extension?
Both show the same comments from the same layers. They differ in who has to do something.
| Script install | Chrome extension | |
|---|---|---|
| Who installs | The site owner, once, in the page | Each reviewer, in their own browser |
| Who can comment | Every visitor to the page, no setup | Anyone with the extension on |
| Which sites | Only sites you control the HTML of | Any page the reviewer can open, including sites you do not own |
| Plan | Pro | Free and Pro |
| Layout | Bar by default, or a button in any corner | Floating |
| Comment types beyond plain comments | No | Yes, on paid layers |
| Browsers | All current browsers | Chrome only, for now |
The extension is on the Chrome Web Store. Reviewers install it themselves; nothing on your site changes.
A common arrangement is both: the script on staging so clients and stakeholders can comment with nothing to install, and the extension for the team, who also review production and third-party sites. When a reviewer with the extension lands on a page that has the script, the extension recognises the widget and stays out of its way, and the two share one session.