Я нашел следующее решение (спойлер - грязный хак с добавлением прозрачного div с двумя span):
<input type="number" class="counter" />
<div class="symbols-wrapper">
<div class="symbols-hidden"></div>
<div class="symbols" contenteditable="true">12345abc</div>
</div>
.symbols-wrapper {
position: relative;
width: 220px;
height: 120px;
}
.symbols {
position: absolute;
top: 0;
left: 0;
z-index: 100;
}
.symbols-hidden {
position: absolute;
top: 0;
left: 0;
z-index: 10;
color: transparent;
}
.symbols, .symbols-hidden {
border: none;
padding: 2px;
width: 220px;
height: 120px;
}
const text = document.querySelector('.symbols')
const hiddenText = document.querySelector('.symbols-hidden')
text.addEventListener('input', () => {
const counter = document.querySelector('.counter');
// сохраняем текст разрешенной длины
let validText = text.outerText.substring(0, Number(counter.value))
// сохраняем остальной текст
let invalidText = text.outerText.substring(Number(counter.value));
// обнуляем красный span
hiddenText.innerHTML = ""
// добавляем белый span
let span1 = document.createElement('span');
span1.style.background = "";
span1.innerHTML = validText;
// если длина больше указанной в ограничителе, то добавляем красный span
if (invalidText.length > 0) {
let span2 = document.createElement('span');
span2.style.background = "rgba(252, 100, 59, 0.4)";
span2.innerHTML = invalidText;
hiddenText.innerHTML = span1.outerHTML + span2.outerHTML;
} else {
hiddenText.innerHTML = span1.outerHTML;
}
})