Browsers ship a perfectly capable spell checker, but they hand control to the developer, not the user. A textarea with spellcheck="true" hard-codes that preference for everyone. A writer composing a formal letter wants red squiggles; someone pasting in a snippet of code does not. The fix is simple: expose a toggle, and wire it to the textarea's spellcheck attribute so the browser responds in real time.
This guide walks through building exactly that — a styled textarea with a live character count and a user-controlled spellcheck toggle — using Tailwind CSS v4 and Alpine.js. The finished component is a single self-contained HTML fragment with no build step beyond what you already have.
What the browser's spellcheck attribute actually does
The HTML spellcheck attribute is an enumerated global attribute that accepts "true" or "false" as string values. Browsers treat it as a hint, not a mandate, but in practice every major browser respects it on <textarea> elements. When set to "true", the browser underlines suspected misspellings with a wavy red line and offers corrections in the right-click context menu. When set to "false", those indicators disappear entirely.
Two things are worth knowing before you ship:
- The default for
<textarea>is browser-defined, not reliablytrue. If you want spellcheck on by default, say so explicitly. - Some browser spellcheck implementations send text to a third-party service. For fields containing sensitive data — passwords, account numbers, private notes — set
spellcheck="false"unconditionally.
Prerequisites
The component below assumes you have both libraries available on the page. The fastest approach for prototyping or a simple Astro page is CDN links; for a production build, install them via npm.
CDN approach — add to your <head>:
<!-- Tailwind CSS v4 via CDN -->
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<!-- Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
For a production Astro project, install both packages and import Tailwind via your global.css:
npm install tailwindcss@latest @tailwindcss/vite alpinejs
/* src/styles/global.css */
@import "tailwindcss";
@theme {
--color-brand-500: oklch(0.62 0.19 250);
--radius-textarea: 0.5rem;
}
The component, step by step
Step 1: the Alpine data model
Everything lives in one x-data object on the wrapper element. Alpine's x-data turns a plain div into a reactive scope — any property defined there is available to every child element via Alpine's directives.
You need three pieces of state:
body— the textarea's current text value.spellCheck— a boolean that controls the native attribute.maxLength— the character limit, used to display a live counter and prevent overflow.
<div x-data="{ body: '', spellCheck: true, maxLength: 500 }">
<!-- component markup goes here -->
</div>
Step 2: the textarea
Bind the textarea's value with x-model, and wire the spellcheck attribute with Alpine's dynamic binding shorthand (:spellcheck is shorthand for x-bind:spellcheck). Alpine evaluates the expression and writes the result as the attribute value, so when spellCheck is true the browser sees spellcheck="true", and when it flips to false the browser sees spellcheck="false".
<textarea
x-model="body"
:spellcheck="spellCheck"
:maxlength="maxLength"
rows="6"
placeholder="Start typing…"
class="w-full rounded-lg border border-zinc-300 bg-white px-4 py-3
text-sm text-zinc-900 shadow-sm outline-none
placeholder:text-zinc-400
focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20
resize-y"
></textarea>
A few notes on the Tailwind classes here: outline-none suppresses the browser's default focus ring so the custom focus:ring-2 ring can take over cleanly. resize-y lets the user drag the textarea taller, but not wider, which prevents layout breakage in a constrained column.
Step 3: the character counter
Alpine's x-text directive sets an element's text content to the result of a JavaScript expression. Because body is reactive, the counter updates on every keystroke without any event listeners.
<p class="mt-1.5 text-right text-xs text-zinc-400">
<span x-text="body.length">0</span>
<span>/</span>
<span x-text="maxLength"></span>
</p>
If you want the counter to turn red as the user approaches the limit, wrap the count in a conditional class binding:
<span
x-text="body.length"
:class="body.length >= maxLength * 0.9 ? 'text-red-500 font-medium' : 'text-zinc-400'"
>0</span>
Step 4: the spellcheck toggle
A checkbox bound with x-model="spellCheck" is all you need. Alpine keeps the checkbox's checked state and the spellCheck boolean perfectly in sync — no @change handler required.
<label class="mt-3 flex cursor-pointer items-center gap-2 text-sm text-zinc-600">
<input
type="checkbox"
x-model="spellCheck"
class="h-4 w-4 rounded border-zinc-300 text-blue-600
focus:ring-blue-500/20 focus:ring-2"
/>
Enable spellcheck
</label>
The complete component
Putting it all together with a label, a subtle card wrapper, and the submit button disabled when the field is empty:
<div
x-data="{ body: '', spellCheck: true, maxLength: 500 }"
class="w-full max-w-xl rounded-xl border border-zinc-200 bg-white p-6 shadow-sm"
>
<!-- Label -->
<label
for="message"
class="mb-1.5 block text-sm font-medium text-zinc-700"
>
Your message
</label>
<!-- Textarea -->
<textarea
id="message"
x-model="body"
:spellcheck="spellCheck"
:maxlength="maxLength"
rows="6"
placeholder="Start typing…"
class="w-full rounded-lg border border-zinc-300 bg-white px-4 py-3
text-sm text-zinc-900 shadow-sm outline-none
placeholder:text-zinc-400
focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20
resize-y"
></textarea>
<!-- Footer row: toggle + counter -->
<div class="mt-2 flex items-center justify-between">
<!-- Spellcheck toggle -->
<label class="flex cursor-pointer items-center gap-2 text-sm text-zinc-600">
<input
type="checkbox"
x-model="spellCheck"
class="h-4 w-4 rounded border-zinc-300 text-blue-600
focus:ring-2 focus:ring-blue-500/20"
/>
Enable spellcheck
</label>
<!-- Character counter -->
<p class="text-xs text-zinc-400">
<span
x-text="body.length"
:class="body.length >= maxLength * 0.9
? 'font-medium text-red-500'
: 'text-zinc-400'"
>0</span><span>/</span><span x-text="maxLength"></span>
</p>
</div>
<!-- Submit -->
<button
type="button"
:disabled="body.trim().length === 0"
class="mt-4 w-full rounded-lg bg-blue-600 px-4 py-2.5
text-sm font-semibold text-white shadow-sm
hover:bg-blue-700 focus:outline-none focus:ring-2
focus:ring-blue-500/40 disabled:cursor-not-allowed
disabled:opacity-50 transition-colors"
>
Send message
</button>
</div>
How the binding actually works at runtime
When Alpine initialises the component, it reads spellCheck: true and immediately sets spellcheck="true" on the textarea DOM node. When the user unchecks the checkbox, Alpine updates spellCheck to false and then patches the attribute to spellcheck="false". The browser detects the attribute change and suppresses its spell-checking UI — the red underlines vanish from existing text and do not reappear for new input until the attribute flips back.
This is the core pattern Alpine is built for: declare your state, describe how the DOM should look given that state, and let Alpine keep them in sync. You never touch document.getElementById or write a manual setAttribute call.
Adapting for Astro
Drop the component HTML into any .astro file. Because Alpine runs entirely in the browser via its CDN script or a client-side import, you do not need client:load or any Astro island directive — the HTML is already static, and Alpine hydrates it at runtime by scanning for x-data attributes.
---
// src/components/MessageForm.astro
// No imports needed for Alpine if you load it globally in BaseLayout.astro
---
<div x-data="{ body: '', spellCheck: true, maxLength: 500 }" class="...">
<!-- component markup -->
</div>
If you import Alpine as an npm package, initialise it in a global client script in your layout:
<!-- src/layouts/BaseLayout.astro -->
<script>
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
</script>
A note on privacy
As mentioned earlier, browser spellcheck can pipe text to a remote service — Chrome historically routes corrections through Google's servers. For any field that might receive API keys, personal identification, or confidential client data, default spellCheck to false in your x-data object and consider removing the toggle entirely. The correct default is the one that matches your field's purpose.
Wrapping up
The final component is about 40 lines of HTML with no custom JavaScript. The pattern — one x-data object, x-model for two-way binding, :spellcheck for the dynamic attribute, and x-text for the live counter — extends naturally to other textarea features: auto-growing height with Tailwind's field-sizing-content, a word count instead of a character count, or a language selector that swaps the lang attribute to steer the browser's dictionary. Alpine keeps the state; Tailwind keeps the styles; the browser does the rest.