<div data-controller="char-counter" data-char-counter-max-value="280">
<%= form.text_area :body, rows: 6, data: { char_counter_target: 'input' }, class: 'w-full rounded border p-2' %>
<div class="mt-1 text-sm text-gray-500">
<span data-char-counter-target="output"></span> characters remaining
</div>
</div>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "output"]
static values = { max: Number }
connect() {
this.update()
this.inputTarget.addEventListener("input", this.update)
}
disconnect() {
this.inputTarget.removeEventListener("input", this.update)
}
update = () => {
const length = this.inputTarget.value.length
const remaining = this.hasMaxValue ? Math.max(this.maxValue - length, 0) : length
this.outputTarget.textContent = remaining
}
}
A character counter is small, but it removes uncertainty for users (especially when there’s a limit). I implement it with Stimulus so it stays reusable: attach to a wrapper, declare input + output targets, and compute remaining characters based on an optional max value. This keeps the markup clean and avoids custom JS per form. I also like to update on connect() so the counter is accurate when editing existing text. In a Turbo app, this is stable across visits because Stimulus reconnects automatically after frame replacements. Finally, I tend to add a subtle warning style when remaining chars drop below a threshold.