Character Counter Ring
A small ring under the text box fills up as you type. Near the limit it swells, turns amber and counts down what’s left. Go past it and it turns red, gives a jolt and shows how far over you are.
How to use it
- Paste the snippet just before
</head>. - Wrap a textarea (or input) in an element with
data-character-counter-ring. - Set the limit with
data-limit. Don’t usemaxlength, or you can’t go over.
Options
Add any of these attributes to the same element.
data-limit | The character limit. Default 140. |
data-warn | Characters left when it turns amber and starts counting down. Default 20. |
data-color | Ring colour before the warning. Default #6a0dad. |
data-autoplay | It types a message past the limit, then deletes it, over and over while in view. It stops as soon as you use the field. |
data-text | What autoplay types. |
Listen for the count event: e.detail.left is the characters left, negative when over. Screen readers hear the count through a polite live region.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Character Counter Ring: a small ring under the text box fills up as you
// type. Near the limit it swells, turns amber and counts down the
// characters left; go past it and it turns red, gives a jolt and shows how
// far over you are.
// Usage: <div data-character-counter-ring data-limit="140"><textarea></textarea></div>
// The ring is added inside the wrapper, under the field. Works with an
// input too. Don't set maxlength, or you can't go over.
// Optional: data-limit (default 140), data-warn (characters left when it
// starts counting down, default 20), data-color (ring colour, default
// #6a0dad), data-autoplay (it types data-text past the limit, then deletes
// it, over and over while in view; it stops as soon as you use the
// field), data-text (what autoplay types).
// Listen for the "count" event: e.detail.left is the characters left
// (negative when over).
var DEFAULTS = {
limit: 140, warn: 20, color: '#6a0dad',
text: 'Shipped the new forms today. Every one of them works on phones as well, and they are tiny. Go and try them!'
};
var AMBER = '#f59e0b', RED = '#dc2626';
var R = 9, C = 2 * Math.PI * R;
var css =
'.ccrg-bar{display:flex;justify-content:flex-end;align-items:center;margin-top:0.55em}' +
'.ccrg-ring{position:relative;display:grid;place-items:center;width:1.7em;height:1.7em}' +
'.ccrg-ring svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg);overflow:visible}' +
'.ccrg-num{position:relative;font-size:0.62em;font-weight:700;font-variant-numeric:tabular-nums;line-height:1;opacity:0}' +
'.ccrg-text{white-space:pre-wrap}' +
'.ccrg-over{color:' + RED + ';background:rgba(220,38,38,0.12);border-radius:0.15em}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function setup(el) {
var d = el.dataset;
var input = el.querySelector('textarea, input');
var field = input || el.querySelector('[data-field]') || el.firstElementChild;
if (!field) return;
var real = !!input;
var limit = parseInt(d.limit, 10) || DEFAULTS.limit;
var warn = parseInt(d.warn, 10) || DEFAULTS.warn;
var color = d.color || DEFAULTS.color;
var bar = document.createElement('div');
bar.className = 'ccrg-bar';
bar.innerHTML = '<span class="ccrg-ring"><svg viewBox="0 0 24 24" aria-hidden="true">' +
'<circle cx="12" cy="12" r="' + R + '" fill="none" stroke="rgba(0,0,0,0.1)" stroke-width="2.4"/>' +
'<circle class="ccrg-arc" cx="12" cy="12" r="' + R + '" fill="none" stroke-width="2.4" stroke-linecap="round" stroke-dasharray="' + C + '" stroke-dashoffset="' + C + '"/>' +
'</svg><span class="ccrg-num"></span></span>';
el.appendChild(bar);
var ring = bar.firstChild, arc = ring.querySelector('.ccrg-arc'), num = ring.querySelector('.ccrg-num');
arc.setAttribute('stroke', color);
if (real) {
ring.setAttribute('role', 'status');
ring.setAttribute('aria-live', 'polite');
}
var text = null, rest = null, value = '';
if (!real) {
text = document.createElement('span');
text.className = 'ccrg-text';
rest = document.createElement('span');
rest.className = 'ccrg-over';
field.textContent = '';
field.appendChild(text);
field.appendChild(rest);
}
var get = function () { return real ? input.value : value; };
var set = function (v) {
if (real) input.value = v;
else {
// In a mock-up, the part past the limit is highlighted.
value = v;
text.textContent = v.slice(0, limit);
rest.textContent = v.slice(limit);
}
update();
};
var zone = null; // 'ok', 'warn' or 'over'
function update() {
var len = Array.from(get()).length;
var left = limit - len;
var z = left < 0 ? 'over' : left <= warn ? 'warn' : 'ok';
var c = z === 'over' ? RED : z === 'warn' ? AMBER : color;
var off = C * (1 - Math.min(1, len / limit));
var fast = reduced();
num.textContent = z === 'ok' ? '' : left;
if (real) ring.setAttribute('aria-label', left < 0 ? -left + ' characters over the limit' : left + ' characters left');
el.dispatchEvent(new CustomEvent('count', { bubbles: true, detail: { left: left } }));
if (fast) {
anime.utils.set(arc, { strokeDashoffset: off, stroke: c });
anime.utils.set(ring, { scale: z === 'ok' ? 1 : 1.3 });
anime.utils.set(num, { opacity: z === 'ok' ? 0 : 1, color: c });
zone = z;
return;
}
anime.animate(arc, { strokeDashoffset: off, duration: 220, ease: 'out(2)' });
if (z === zone) return;
var from = zone;
zone = z;
anime.animate(arc, { stroke: c, duration: 250 });
anime.animate(num, { opacity: z === 'ok' ? 0 : 1, color: c, duration: 200 });
if (z === 'ok') anime.animate(ring, { scale: 1, duration: 300, ease: 'out(3)' });
else if (z === 'warn') anime.animate(ring, { scale: 1.3, duration: 500, ease: 'outElastic(1, .5)' });
else if (from !== null) {
// Crossing the limit: a jolt.
anime.animate(ring, { scale: [{ to: 1.6, duration: 140, ease: 'out(2)' }, { to: 1.3, duration: 400, ease: 'outElastic(1, .5)' }] });
anime.animate(ring, { rotate: [0, -14, 10, -6, 3, 0], duration: 500, ease: 'inOut(2)' });
} else anime.utils.set(ring, { scale: 1.3 });
}
if (real) {
input.addEventListener('input', update);
input.addEventListener('pointerdown', stop);
input.addEventListener('keydown', stop);
input.addEventListener('focus', stop);
}
update();
var timer = null, stopped = false;
function stop() {
if (stopped) return;
stopped = true;
clearTimeout(timer);
}
if (on(d.autoplay) && !reduced()) {
var words = d.text || DEFAULTS.text;
var steps = [];
var i;
for (i = 1; i <= words.length; i++) {
(function (n) { steps.push([function () { set(words.slice(0, n)); }, words[n - 1] === ' ' ? 90 : 35 + Math.random() * 45]); })(i);
}
steps.push([function () {}, 1800]);
for (i = words.length - 1; i >= 0; i -= 2) {
(function (n) { steps.push([function () { set(words.slice(0, n)); }, 16]); })(Math.max(0, i - 1));
}
steps.push([function () {}, 900]);
var run = function (i) {
if (stopped) return;
steps[i][0]();
timer = setTimeout(function () { run((i + 1) % steps.length); }, steps[i][1]);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (stopped) return;
if (entries[0].isIntersecting) { set(''); timer = setTimeout(function () { run(0); }, 600); }
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-character-counter-ring]').forEach(function (el) {
if (el._ccrg) return;
el._ccrg = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-character-counter-ring data-limit="280">
<textarea aria-label="Your post"></textarea>
</div>