Custom Digits for Elementor Counter
Display Elementor's native Counter widget in any numeral system by supplying your own set of ten digit characters.
by Tarek Nabil · github.com/tareknabil/custom-digits-for-elementor-counter · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/tareknabil/custom-digits-for-elementor-counter/archive/refs/heads/main.zipDisplay and animate Elementor's native Counter widget in any numeral system, by supplying your own set of ten digit characters.
Elementor's Counter always counts in Latin numerals — 1, 2, 3. This plugin adds a Custom Digits field to that same widget so you can supply ten characters of your choosing and have the counter display and animate with those instead. It does not add a new widget: your existing layout, styling and animation settings are untouched.
Features
- Extends the widget you already use. A Custom Digits section is added to the Counter widget's Content tab — no new widget, no migration.
- No flash of Latin numerals. Digits are substituted server-side, so the counter's very first paint already uses your characters.
- Converted throughout the count-up. A
MutationObserverkeeps converting while Elementor's animation rewrites the number, not just at the final value. - Live validation in the editor. The field turns red as you type whenever the value isn't exactly ten single characters.
- Any characters, not just numerals. Arabic-Indic, Devanagari, Thai — or arbitrary symbols, if that's what you want.
- Safe by default. An empty or invalid value converts nothing, so existing counters are never altered unexpectedly.
- Works with late-rendered widgets — popups, AJAX content and the editor
preview, via Elementor's
frontend/element_readyhook.
Requirements
| WordPress | 6.8 or later (Elementor itself requires 6.8) |
| PHP | 7.4 or later |
| Elementor | installed and active |
If Elementor is missing, the plugin stays dormant and shows an admin notice rather than causing an error.
Installation
- Copy or clone the plugin into
wp-content/plugins/custom-digits-for-elementor-counter. - Activate Custom Digits for Elementor Counter on the WordPress Plugins screen.
- Make sure Elementor is installed and active.
Usage
- Edit a page with Elementor and select (or add) a Counter widget.
- In the Content tab, open the Custom Digits section.
- Enter ten comma-separated characters, in order from zero to nine.
- Update the page. The counter renders and animates with those characters.
Ready-made digit sets
Copy any of these straight into the field:
| Numeral system | Used for | Value to paste | Renders as |
|---|---|---|---|
| Arabic-Indic | Arabic | ٠,١,٢,٣,٤,٥,٦,٧,٨,٩ |
٠١٢٣٤٥٦٧٨٩ |
| Extended Arabic-Indic | Persian, Urdu | ۰,۱,۲,۳,۴,۵,۶,۷,۸,۹ |
۰۱۲۳۴۵۶۷۸۹ |
| Devanagari | Hindi, Marathi, Nepali | ०,१,२,३,४,५,६,७,८,९ |
०१२३४५६७८९ |
| Bengali | Bengali, Assamese | ০,১,২,৩,৪,৫,৬,৭,৮,৯ |
০১২৩৪৫৬৭৮৯ |
| Thai | Thai | ๐,๑,๒,๓,๔,๕,๖,๗,๘,๙ |
๐๑๒๓๔๕๖๗๘๙ |
What counts as a valid value
The value must be exactly ten entries of exactly one character each, ordered
zero through nine. Spaces around each character are ignored, so
٠, ١, ٢, … works too.
Anything else is rejected as a whole rather than applied partially — a malformed set can never produce a counter with some digits converted and others left Latin. A rejected value is outlined in red in the editor and simply ignored when the page renders, so the counter keeps its Latin digits.
How it works
Conversion happens twice, on purpose: once on the server so the first paint is already correct, and again in the browser so Elementor's animation can't undo it.
PHP render → markup already contains ٧٨٦
plus data-custom-digits-counter="yes"
and data-custom-digits-counter-map='["٠","١",…]'
Elementor's JS → animates the number, writing Latin values
Frontend script → MutationObserver re-converts each write
| File | Responsibility |
|---|---|
custom-digits-for-elementor-counter.php |
Plugin header, constants, Elementor dependency check, bootstrap. |
includes/class-digits.php |
Pure digit logic — parsing, validation, substitution. No WordPress or Elementor, which is what makes it directly testable. |
includes/class-plugin.php |
Registers the control and the render/asset hooks, reads widget settings, and injects the data attributes. Also clears Elementor's element cache when the plugin version changes, since Elementor otherwise serves stored markup without re-running the render filter. |
assets/js/custom-digits-counter.js |
Frontend. Finds flagged counters, reads the digit map from the data attribute, and keeps the text converted through the count-up. |
assets/js/custom-digits-counter-editor.js |
Editor only. Mirrors the PHP validation so an invalid value is flagged as it's typed. The rules are passed from PHP to the script, so the two validators cannot drift apart. |
assets/css/custom-digits-counter-editor.css |
Editor only. The invalid-field state. |
Note that data-to-value is deliberately left in Latin: the frontend script
parses it to drive the animation, so converting it would double-convert.
Development
composer install # PHP dev dependencies (PHPUnit)
npm install # JS dev dependencies (Jest, Playwright, wp-env)
Test suites
Four layers, each answering a question the others cannot:
| Command | Covers | Needs Docker |
|---|---|---|
composer test |
27 PHPUnit tests over the pure digit logic in Digits — parsing, validation, substitution. |
no |
npm test |
39 Jest tests over both browser scripts in jsdom — editor validation, animation re-conversion, Elementor hook registration. | no |
npm run test:smoke |
28 Jest tests that boot real WordPress + Elementor and assert a rendered page actually shows custom digits. | yes |
npm run test:e2e |
Playwright specs that load the page in real Chromium, Firefox and WebKit and watch the count-up animation live. | yes |
The first two are fast and need nothing installed beyond dependencies. Run them constantly; run the other two before opening a pull request.
Smoke test
The unit suites exercise the pieces in isolation. The smoke test answers what
they cannot: do the pieces work together inside real WordPress? It boots
WordPress and Elementor via wp-env, publishes a page with two Counter widgets,
requests it over HTTP, and asserts the markup:
- the configured counter renders
٧٨٦, not786 - it carries
data-custom-digits-counter="yes"and the JSON digit map data-to-valuestays Latin, so the frontend script can still parse it- a second counter with no digit set keeps Latin digits — the control that catches code converting everything unconditionally
- the frontend script is enqueued
debug.logholds no errors from this plugin
npm run test:smoke # reuses the running environment, starting one if needed
It is a Jest suite (jest.smoke.config.js), kept separate
from npm test so the unit tests stay fast and need no Docker.
npm run test:smoke -- --verbose --reporters=default lists each assertion, and
-t filters as usual.
Artifacts land in tests/smoke/output/ (gitignored) — the fetched HTML, the
captured debug.log, wp-env's log, and the run's state.json. That's the first
place to look when an assertion fails.
This is not an end-to-end test: the page is fetched, not executed, so it proves the scripts are enqueued, never that they run.
End-to-end test
This suite closes that gap. It boots its own wp-env environment, loads the
published page in real Chromium, Firefox and WebKit, and samples the counter
element on every animation frame while Elementor's own JavaScript animates it:
- the counter already shows custom digits on its first rendered frame, not just once the animation settles
- across the whole count-up (786 → 2025) every painted value is Arabic-Indic, and no Latin digit is ever visible
- the control counter (no digit set, 512 → 1999) stays Latin throughout
- the frontend script raises no console errors
Sampling uses requestAnimationFrame rather than a MutationObserver on
purpose. Observer callbacks are microtasks, so a second observer would see the
Latin value that exists between Elementor's write and this plugin's correction —
a state the browser never paints. requestAnimationFrame runs after the
microtask queue drains, so every sample is a value about to be shown.
npx playwright install --with-deps # once, installs the three browsers
npm run test:e2e
Artifacts (HTML report, traces, videos, wp-env log) land in playwright-report/,
test-results/ and tests/e2e/output/ — all gitignored.
Local WordPress environment
.wp-env.json configures @wordpress/env:
npm run env:start # start it
npm run env:update # restart, re-fetching the latest Elementor
npm run env:stop # stop it
That gives you a WordPress site with Elementor and the Hello Elementor theme installed from wordpress.org, and this plugin mounted and active.
Elementor and the theme track the latest wordpress.org releases, and wp-env
caches each download — so you stay on the version you last fetched until
npm run env:update. "Latest" refreshes deliberately, never silently.
To pin a specific Elementor while reproducing a bug, put a versioned URL in
an .wp-env.override.json
(gitignored). wp-env replaces arrays rather than merging them, so repeat ".":
{ "plugins": [ ".", "https://downloads.wordpress.org/plugin/elementor.4.2.4.zip" ] }
Continuous integration
Four independent workflows run on every push to main and every pull request, so
a failure in one never masks another:
| Workflow | What it answers |
|---|---|
| Tests | Do the units pass on PHP 7.4 – 8.5, and in jsdom? Uploads coverage to Codecov. |
| Plugin Check | Is the distributable zip publishable to wordpress.org? |
| Smoke Test | Does it work inside real WordPress + Elementor? |
| E2E Test | Does it work in a real browser, mid-animation? |
Plugin Check runs against the pruned file set — the checkout is reduced using
.distignore first, so CI judges the plugin users actually
receive rather than the repository.
Testing upcoming releases
Those four all test current stable. This plugin reads Elementor's counter markup with a regex and hooks its render attributes, so a release that is perfectly valid for Elementor can still break it — and the first run against a new release would otherwise happen after users already have it.
Pre-release Test runs the smoke and e2e suites against the nightly builds of both — weekly, so an upstream change is caught even when nothing here changed, and on every pull request, so your own changes are checked against the nightlies before they merge:
| Source | |
|---|---|
| WordPress | trunk nightly, via WP_ENV_CORE |
| Elementor | nightly rolling release, unpacked and mounted via a generated .wp-env.override.json |
Both URLs are fixed and always serve the newest build, so there is nothing to
resolve. The Elementor zip is unpacked rather than passed to wp-env as a URL:
wp-env names a plugin directory after the zip filename, so elementor-nightly.zip
would install under the slug elementor-nightly, which does not satisfy this
plugin's Requires Plugins: elementor header — WordPress would then refuse to
activate it at all. Note that Elementor's nightly tracks main, which is further ahead than
the next release — breakage shows up early, and occasionally for something that
gets fixed before it ships.
The job checks that both nightlies actually took effect before trusting the result —
WordPress by its -alpha/-beta/-RC marker, Elementor by comparing against the
version wordpress.org currently ships. Without that, reusing an already-running
environment would let a run pass while quietly testing current stable.
It is advisory: the job is continue-on-error, so a break in an unreleased
WordPress or Elementor reports neutral and never blocks a merge. Current stable is
what the other four workflows guard. Expect an occasional neutral run in the days
after an Elementor release, while wordpress.org catches up with the nightly's
version.
License
GPL-3.0-or-later — see https://www.gnu.org/licenses/gpl-3.0.html