Summit Themes
Blog

How to build a character limit textarea with Tailwind CSS and Alpine.js

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 maxlength so 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 the text property. Every keystroke updates the reactive state, which re-evaluates both getters automatically.
  • :maxlength="limit" — binds the HTML maxlength attribute so the browser enforces the cap. Users cannot paste beyond it. You could also hardcode maxlength="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 static class attribute. When isAtLimit is true, the red ring classes are added; when it is false, they are removed. The existing focus ring (focus:ring-indigo-500) is suppressed by outline-none in this state so the red ring wins visually.
  • x-text="remaining + ' / ' + limit" — sets the counter's text content reactively. Because remaining is a getter, Alpine recomputes it on every text change.
  • :class on the counter span — the object syntax lets multiple conditions coexist cleanly. Tailwind's transition-colors on 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. Use aria-live="assertive" only if you want an announcement on every keystroke, which can be disruptive.
  • Add aria-describedby="bio-counter" to the textarea (matching an id="bio-counter" on the span) so screen readers associate the counter with the field.
  • When isAtLimit is 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.