A textarea with a character limit is one of those small UI details that has a large impact on usability. Twitter/X made it famous, but the pattern shows up everywhere: bio fields, SMS templates, meta description editors, review forms. When users can see the counter ticking down in real time — and the field turns red before they slam into the wall — they write better input and submit fewer truncated strings.
The good news is that this component does not need React, a UI library, or a build step beyond what you already have. Alpine.js handles the reactivity with a handful of directives, and Tailwind CSS v4 handles the visual states with utility classes. Together they weigh almost nothing and ship with zero runtime overhead.
What we are building
A textarea that:
- Shows a live character count as the user types (e.g. "142 / 280")
- Changes the counter text color to amber when the user is within 10% of the limit
- Turns the counter red and the textarea ring red once the limit is reached
- Enforces the limit via
maxlengthso no characters sneak past - Works with a plain HTML form — no JavaScript framework, no dependencies beyond Alpine and Tailwind
Prerequisites
These instructions assume you already have Tailwind CSS v4 and Alpine.js available in your project. For Tailwind v4, the setup is a single CSS import:
@import "tailwindcss";
For Alpine, the fastest way is the CDN script. In an Astro project, drop it in your layout's <head>:
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
If you manage Alpine through npm (npm install alpinejs), import and start it in your main script file:
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
The Alpine.js data model
Everything Alpine needs lives in one x-data object. The key is using a JavaScript getter for remaining — it computes a derived value from text and limit without any watchers or event handlers.
x-data="{
text: '',
limit: 280,
get remaining() {
return this.limit - this.text.length;
},
get isNearLimit() {
return this.remaining <= Math.floor(this.limit * 0.10) && this.remaining > 0;
},
get isAtLimit() {
return this.remaining <= 0;
}
}"
isNearLimit is true when the user has used 90% or more of the budget but has not hit zero. isAtLimit is true at exactly zero remaining (which is also when maxlength stops accepting keystrokes). The thresholds are percentages of the limit, so the same component works for a 100-character field or a 2,000-character essay box.
The full component
<div
x-data="{
text: '',
limit: 280,
get remaining() {
return this.limit - this.text.length;
},
get isNearLimit() {
return this.remaining <= Math.floor(this.limit * 0.10) && this.remaining > 0;
},
get isAtLimit() {
return this.remaining <= 0;
}
}"
class="flex flex-col gap-2"
>
<label for="bio" class="text-sm font-medium text-gray-700">
Bio
</label>
<textarea
id="bio"
name="bio"
rows="4"
:maxlength="limit"
x-model="text"
:class="{
'ring-2 ring-red-500 outline-none': isAtLimit
}"
class="w-full resize-none rounded-lg border border-gray-300 px-4 py-3 text-sm
text-gray-900 placeholder-gray-400 shadow-sm transition
focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Tell people about yourself…"
></textarea>
<div class="flex justify-end">
<span
x-text="remaining + ' / ' + limit"
:class="{
'text-amber-500': isNearLimit,
'text-red-600 font-semibold': isAtLimit
}"
class="text-xs text-gray-400 tabular-nums transition-colors"
></span>
</div>
</div>
What each directive does
x-model="text"— two-way binds the textarea value to thetextproperty. Every keystroke updates the reactive state, which re-evaluates both getters automatically.:maxlength="limit"— binds the HTMLmaxlengthattribute so the browser enforces the cap. Users cannot paste beyond it. You could also hardcodemaxlength="280", but binding it keeps the limit in one place.:class="{ 'ring-2 ring-red-500 outline-none': isAtLimit }"— Alpine merges this object with the staticclassattribute. WhenisAtLimitis true, the red ring classes are added; when it is false, they are removed. The existing focus ring (focus:ring-indigo-500) is suppressed byoutline-nonein this state so the red ring wins visually.x-text="remaining + ' / ' + limit"— sets the counter's text content reactively. Becauseremainingis a getter, Alpine recomputes it on everytextchange.:classon the counter span — the object syntax lets multiple conditions coexist cleanly. Tailwind'stransition-colorson the static class makes the color shift animate smoothly rather than snap.
Adjusting the limit per instance
If you have several textareas on the same page with different limits, change the limit value in x-data for each one. There is no shared state to worry about — each x-data block is its own isolated scope.
<!-- Meta description: 160-character limit -->
<div x-data="{ text: '', limit: 160, get remaining() { return this.limit - this.text.length }, ... }">
...
</div>
<!-- Tweet composer: 280-character limit -->
<div x-data="{ text: '', limit: 280, get remaining() { return this.limit - this.text.length }, ... }">
...
</div>
If the repeated getter logic feels verbose, you can extract it into an Alpine component using Alpine.data():
// In your main script (runs before Alpine.start())
Alpine.data('charLimit', (limit = 280) => ({
text: '',
limit,
get remaining() {
return this.limit - this.text.length;
},
get isNearLimit() {
return this.remaining <= Math.floor(this.limit * 0.10) && this.remaining > 0;
},
get isAtLimit() {
return this.remaining <= 0;
}
}));
Then in your HTML, the x-data attribute becomes a one-liner with the limit passed as an argument:
<div x-data="charLimit(160)">
<!-- same textarea markup as above -->
</div>
Accessibility notes
Color alone should not be the only signal for users with low vision or color blindness. A few additions that cost nothing:
- Add
aria-live="polite"to the counter<span>so screen readers announce it periodically as the user types. Usearia-live="assertive"only if you want an announcement on every keystroke, which can be disruptive. - Add
aria-describedby="bio-counter"to the textarea (matching anid="bio-counter"on the span) so screen readers associate the counter with the field. - When
isAtLimitis true, consider adding:aria-invalid="isAtLimit"to the textarea. This signals an error state to assistive technology without relying on color.
<textarea
id="bio"
aria-describedby="bio-counter"
:aria-invalid="isAtLimit"
...
></textarea>
<span
id="bio-counter"
aria-live="polite"
x-text="remaining + ' characters remaining'"
...
></span>
Note the x-text is changed to "characters remaining" rather than "142 / 280" — the prose reads more naturally when spoken aloud, even though the visual display uses the slash format.
Tailwind v4 specifics
All of the utility classes used above exist in Tailwind v4's default theme with no configuration changes. If you have custom brand colors defined with the new @theme directive, substitute them freely:
@import "tailwindcss";
@theme {
--color-brand: oklch(55% 0.22 265);
--color-brand-muted: oklch(75% 0.12 265);
}
You can then use ring-brand and text-brand-muted as Tailwind utilities without any plugin or safelist config — v4 generates utilities directly from @theme custom properties.
Putting it together in Astro
In an Astro component, the entire thing is inert HTML — no <script> tag, no client:load directive. Alpine picks it up from the DOM after the page loads. Drop the markup into any .astro file and it works:
---
// CharLimitTextarea.astro
interface Props {
id: string;
label: string;
limit?: number;
placeholder?: string;
}
const { id, label, limit = 280, placeholder = '' } = Astro.props;
---
<div
x-data={`charLimit(${limit})`}
class="flex flex-col gap-2"
>
<label for={id} class="text-sm font-medium text-gray-700">{label}</label>
<textarea
id={id}
name={id}
rows="4"
:maxlength="limit"
x-model="text"
:aria-invalid="isAtLimit"
:class="{ 'ring-2 ring-red-500 outline-none': isAtLimit }"
class="w-full resize-none rounded-lg border border-gray-300 px-4 py-3 text-sm
text-gray-900 placeholder-gray-400 shadow-sm transition
focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder={placeholder}
></textarea>
<div class="flex justify-end">
<span
aria-live="polite"
x-text="remaining + ' / ' + limit"
:class="{
'text-amber-500': isNearLimit,
'text-red-600 font-semibold': isAtLimit
}"
class="text-xs text-gray-400 tabular-nums transition-colors"
></span>
</div>
</div>
Use it anywhere in your pages:
<CharLimitTextarea
id="bio"
label="Bio"
limit={160}
placeholder="Tell people about yourself…"
/>
Wrapping up
The finished component is around 30 lines of HTML, no custom JavaScript file, and no runtime dependencies beyond what most Astro projects already include. The Alpine getter pattern keeps the derived state clean — no x-on:input handlers, no manual DOM queries, no $watch calls. Tailwind's object-syntax :class binding handles the three visual states (neutral, warning, limit-reached) without any conditional rendering. For a pattern that appears on almost every form, the implementation should be this small — and now it is.