Skip to content
  1. Extras
  2. Reactions
  3. JavaScript widget

JavaScript widget ​

Files:

FilePurpose
assets/components/reactions/js/web/reactions.jsWidget logic
assets/components/reactions/js/web/reactions.cssStyles

Include ​

html
<link rel="stylesheet" href="[[++assets_url]]components/reactions/js/web/reactions.css">
<script src="[[++assets_url]]components/reactions/js/web/reactions.js" defer></script>
html
<link rel="stylesheet" href="{'assets_url' | config}components/reactions/js/web/reactions.css">
<script src="{'assets_url' | config}components/reactions/js/web/reactions.js" defer></script>

The Reactions snippet outputs ready markup. The widget initializes automatically when the DOM loads.

Auto-init ​

The script finds elements with the .reactions-widget class and mounts a widget on each. Re-init of the same element is blocked by data-mounted="true".

Container data attributes ​

AttributeRequiredDescription
data-class-keyyesObject class (modResource, msProduct…)
data-object-idyesObject ID (integer > 0)
data-setnoSet key, default updown
data-contextnoContext, default web
data-csrfnoCSRF token. If empty, the widget requests one
data-apino*API URL. In snippet SSR filled from [[+api_url]]. Without the attribute: Reactions.config.api, otherwise a path derived from reactions.js (same-origin)
data-typesnoType names, comma-separated. Attribute missing → catalog from data-set. Empty string → 0 buttons. Extra names outside the set are ignored
data-exclusiveno1 when the set is exclusive (SSR from the snippet)
data-allow-multipleno1 when reactions_allow_multiple is on (SSR)
data-layoutnoauto (default), picker, or bar. auto → picker when there are more than 3 types

API URL resolution order:

  1. data-api on the element (snippet sets [[+api_url]])
  2. window.Reactions.config.api
  3. path from same-origin <script src="…/components/reactions/js/web/reactions.js"> → …/components/reactions/api.php

The Reactions snippet fills data-types with the types actually shown (after the types / reactions_full_types filter). An empty filter result → data-types="", and the widget does not fall back to the full set. Markup example (manual chunk / AJAX):

html
<div
    class="reactions-widget"
    data-api="[[++assets_url]]components/reactions/api.php"
    data-class-key="modResource"
    data-object-id="[[*id]]"
    data-set="full"
    data-types="like,love,fire,star"
    data-context="[[*context_key]]"
></div>
html
<div
    class="reactions-widget"
    data-api="{'assets_url' | config}components/reactions/api.php"
    data-class-key="modResource"
    data-object-id="{$_modx->resource.id}"
    data-set="full"
    data-types="like,love,fire,star"
    data-context="{$_modx->context.key}"
></div>

Override (CDN or non-standard path to api.php):

html
<script>
  window.Reactions = window.Reactions || {};
  window.Reactions.config = { api: '[[++assets_url]]components/reactions/api.php' };
</script>

or on a specific widget: data-api="[[++assets_url]]components/reactions/api.php".

The Reactions snippet fills required data attributes via the tpl.Reactions.outer chunk. With a custom chunk, keep the reactions-widget class and correct data attributes. data-api is optional.

Manual init ​

Global object window.Reactions:

javascript
// All widgets on the page
Reactions.init();

// Only inside a container (after AJAX load)
const container = document.getElementById('comments');
Reactions.init(container);

init(root) returns an array of ReactionsWidget instances.

Lifecycle ​

  1. Parse data attributes.
  2. Request CSRF (GET ?action=csrf) if no token was passed.
  3. Load counters (GET ?action=counts).
  4. Render: bar (all types in a row) or picker (chips + popover).
  5. On click / pick in the popover: optimistic UI → POST ?action=react or DELETE ?action=react.
  6. On error: roll back state, refresh CSRF.

Sets in JS ​

The widget knows emoji for built-in sets:

SetTypes
updownlike 👍, dislike 👎
githublike 👍, dislike 👎, love ❤️, funny 😂, wow 😮, sad 😢, angry 😡, hooray 🎉
fullgithub + rocket 🚀, eyes 👀, fire 🔥, clap 👏, thinking 🤔, party 🥳, star ⭐, beer 🍺, sparkles ✨, hundred 💯, pray 🙏, muscle 💪, cool 😎, heart_eyes 😍, confused 😕, raised_hands 🙌

For custom sets, server validation still works through the API, but JS renders buttons only when the set matches REACTION_SETS in the bundle. For fully custom sets use server render via the Reactions snippet without JS, or extend the frontend.

Exclusive and sync ​

The server treats the mode as exclusive when the set is exclusive or reactions_allow_multiple is off. The snippet writes this into data-exclusive / data-allow-multiple. The widget mirrors the same logic: under exclusive, another button clears the previous one (optimistic UI).

Several widgets for the same object (class_key + object_id + context) sync via the reactions:updated event on window after a successful POST/DELETE.

The same event updates sibling .reactions-count nodes (ReactionsCount snippet) for that object — no page reload.

Layout: bar and picker ​

ModeWhenUI
barupdown or layout=barAll types in a row
pickergithub / full with layout=auto, or layout=pickerChips for current reactions + + button and popover grid

Escape and outside click close the popover. Snippet:

modx
[[!Reactions? &set=`github` &layout=`picker`]]
[[!Reactions? &set=`full` &layout=`bar`]]
fenom
{'!Reactions' | snippet : ['set' => 'github', 'layout' => 'picker']}
{'!Reactions' | snippet : ['set' => 'full', 'layout' => 'bar']}

Accessibility ​

  • Container: role="group", aria-label="Reactions".
  • Reaction buttons: aria-pressed, aria-label with type name and count.
  • Picker: + button with aria-expanded / aria-haspopup. Popover closes on Escape and outside click.
  • Errors: role="alert".
  • Keyboard: Enter and Space activate the button.

Styling ​

BEM classes:

ClassElement
.reactions-widgetContainer (data-loading, aria-busy while loading)
.reactions-widget__buttonsButton group (bar layout)
.reactions-widget__shellSummary + trigger wrapper (picker layout)
.reactions-widget__summaryChips for selected / non-zero reactions
.reactions-widget__trigger+ button (.is-open when the popover is open)
.reactions-widget__popoverPopover panel
.reactions-widget__pickerType grid inside the popover
.reactions-widget__picker-buttonType button in the popover
.reactions-widget__buttonReaction button (bar or chip in summary)
.reactions-widget__button.is-active / [aria-pressed="true"]Selected reaction
.reactions-widget__emojiEmoji
.reactions-widget__countCounter (hidden at 0)
.reactions-widget__errorError message

SSR and JS share the same classes. Override via CSS custom properties: --reactions-border, --reactions-bg, --reactions-active-border, --reactions-muted, and others.

AJAX after dynamic load ​

Tickets and infinite scroll inject HTML without a full reload. After inserting new comments, call:

javascript
document.getElementById('new-comments').addEventListener('load', () => {
    Reactions.init(document.getElementById('new-comments'));
});

The widget sends requests with credentials: include. For the ip_cookie strategy the browser must accept the reactions_fid cookie. CSRF is bound to the PHP session.

Build from sources ​

Sources: frontend/src/. With php _build/build.php the widget builds automatically (assets() step). Manually:

bash
cd frontend
npm install
npm run build

Vite writes artifacts to assets/components/reactions/js/web/ (reactions.js, reactions.css). Those files go into the transport package.