
- MODX 3
- PHP 8.2


Files:
| File | Purpose |
|---|---|
assets/components/reactions/js/web/reactions.js | Widget logic |
assets/components/reactions/js/web/reactions.css | Styles |
<link rel="stylesheet" href="[[++assets_url]]components/reactions/js/web/reactions.css">
<script src="[[++assets_url]]components/reactions/js/web/reactions.js" defer></script><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.
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".
| Attribute | Required | Description |
|---|---|---|
data-class-key | yes | Object class (modResource, msProduct…) |
data-object-id | yes | Object ID (integer > 0) |
data-set | no | Set key, default updown |
data-context | no | Context, default web |
data-csrf | no | CSRF token. If empty, the widget requests one |
data-api | no* | 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-types | no | Type names, comma-separated. Attribute missing → catalog from data-set. Empty string → 0 buttons. Extra names outside the set are ignored |
data-exclusive | no | 1 when the set is exclusive (SSR from the snippet) |
data-allow-multiple | no | 1 when reactions_allow_multiple is on (SSR) |
data-layout | no | auto (default), picker, or bar. auto → picker when there are more than 3 types |
API URL resolution order:
data-api on the element (snippet sets [[+api_url]])window.Reactions.config.api<script src="…/components/reactions/js/web/reactions.js"> → …/components/reactions/api.phpThe 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):
<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><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):
<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.
Global object window.Reactions:
// 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.
GET ?action=csrf) if no token was passed.GET ?action=counts).bar (all types in a row) or picker (chips + popover).POST ?action=react or DELETE ?action=react.The widget knows emoji for built-in sets:
| Set | Types |
|---|---|
updown | like 👍, dislike 👎 |
github | like 👍, dislike 👎, love ❤️, funny 😂, wow 😮, sad 😢, angry 😡, hooray 🎉 |
full | github + 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.
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.
| Mode | When | UI |
|---|---|---|
bar | updown or layout=bar | All types in a row |
picker | github / full with layout=auto, or layout=picker | Chips for current reactions + + button and popover grid |
Escape and outside click close the popover. Snippet:
[[!Reactions? &set=`github` &layout=`picker`]]
[[!Reactions? &set=`full` &layout=`bar`]]{'!Reactions' | snippet : ['set' => 'github', 'layout' => 'picker']}
{'!Reactions' | snippet : ['set' => 'full', 'layout' => 'bar']}role="group", aria-label="Reactions".aria-pressed, aria-label with type name and count.+ button with aria-expanded / aria-haspopup. Popover closes on Escape and outside click.role="alert".BEM classes:
| Class | Element |
|---|---|
.reactions-widget | Container (data-loading, aria-busy while loading) |
.reactions-widget__buttons | Button group (bar layout) |
.reactions-widget__shell | Summary + trigger wrapper (picker layout) |
.reactions-widget__summary | Chips for selected / non-zero reactions |
.reactions-widget__trigger | + button (.is-open when the popover is open) |
.reactions-widget__popover | Popover panel |
.reactions-widget__picker | Type grid inside the popover |
.reactions-widget__picker-button | Type button in the popover |
.reactions-widget__button | Reaction button (bar or chip in summary) |
.reactions-widget__button.is-active / [aria-pressed="true"] | Selected reaction |
.reactions-widget__emoji | Emoji |
.reactions-widget__count | Counter (hidden at 0) |
.reactions-widget__error | Error message |
SSR and JS share the same classes. Override via CSS custom properties: --reactions-border, --reactions-bg, --reactions-active-border, --reactions-muted, and others.
Tickets and infinite scroll inject HTML without a full reload. After inserting new comments, call:
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.
Sources: frontend/src/. With php _build/build.php the widget builds automatically (assets() step). Manually:
cd frontend
npm install
npm run buildVite writes artifacts to assets/components/reactions/js/web/ (reactions.js, reactions.css). Those files go into the transport package.