Component
Textarea With Counter
Textarea with a live character counter (current / maximum) derived from the real value.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/textareas/textarea-with-counter/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Textareas/textarea-with-counter React/Components/Textareas/textarea-with-counter import type { ChangeEvent, ReactNode, TextareaHTMLAttributes } from "react";
import { useId, useState } from "react";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const TEXTAREA_BASE =
"w-full min-h-[80px] resize-y rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] px-3 py-2 text-sm leading-5 text-[var(--ds-color-foreground)] shadow-none transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 read-only:bg-[var(--ds-color-surface-subtle)] read-only:text-[var(--ds-color-muted-foreground)] motion-reduce:transition-none";
const TEXTAREA_BORDER =
"border-[var(--ds-color-border)] focus:border-[var(--ds-color-border-strong)]";
export interface TextareaWithCounterProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
/** Visible label — required. */
label: ReactNode;
/** Optional helper text rendered beside the counter. */
helperText?: ReactNode;
}
/**
* Textarea with a live character counter. The count is derived from the
* real value — the controlled `value` when provided, otherwise the
* uncontrolled text the user has typed — so it always reflects what is
* actually in the field. When `maxLength` is supplied the counter shows
* `current / maximum` and the native attribute enforces the limit; at the
* limit the count gains emphasis (weight + foreground color — no loud
* colors or animation). When no `maxLength` is supplied the counter shows
* a plain character count. The counter region is `aria-live` polite and
* linked with `aria-describedby`.
*/
export function TextareaWithCounter({
label,
helperText,
id,
className,
rows = 3,
value,
defaultValue = "",
onChange,
maxLength,
...props
}: TextareaWithCounterProps) {
const generatedId = useId();
const textareaId = id ?? `textarea-${generatedId}`;
const countId = `${textareaId}-count`;
const [internalValue, setInternalValue] = useState(String(defaultValue ?? ""));
const currentValue = value === undefined ? internalValue : String(value ?? "");
const atLimit = maxLength !== undefined && currentValue.length >= maxLength;
function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
if (value === undefined) setInternalValue(event.target.value);
onChange?.(event);
}
return (
<div className="w-full">
<label
htmlFor={textareaId}
className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]"
>
{label}
</label>
<textarea
id={textareaId}
rows={rows}
value={currentValue}
onChange={handleChange}
maxLength={maxLength}
aria-describedby={countId}
className={cx(TEXTAREA_BASE, TEXTAREA_BORDER, className)}
{...props}
/>
<div
id={countId}
aria-live="polite"
className="mt-2 flex items-baseline justify-between gap-3 text-xs leading-4 text-[var(--ds-color-muted-foreground)]"
>
<span>{helperText}</span>
<span className={cx("shrink-0", atLimit && "font-medium text-[var(--ds-color-foreground)]")}>
{currentValue.length}{maxLength !== undefined ? ` / ${maxLength}` : ""}
</span>
</div>
</div>
);
}
export default TextareaWithCounter; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import { useId, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const TEXTAREA_BASE = "w-full min-h-[80px] resize-y rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] px-3 py-2 text-sm leading-5 text-[var(--ds-color-foreground)] shadow-none transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 read-only:bg-[var(--ds-color-surface-subtle)] read-only:text-[var(--ds-color-muted-foreground)] motion-reduce:transition-none";
const TEXTAREA_BORDER = "border-[var(--ds-color-border)] focus:border-[var(--ds-color-border-strong)]";
export function TextareaWithCounter({
label,
helperText,
id,
className,
rows = 3,
value,
defaultValue = "",
onChange,
maxLength,
...props
}) {
const generatedId = useId();
const textareaId = id ?? `textarea-${generatedId}`;
const countId = `${textareaId}-count`;
const [internalValue, setInternalValue] = useState(String(defaultValue ?? ""));
const currentValue = value === undefined ? internalValue : String(value ?? "");
const atLimit = maxLength !== undefined && currentValue.length >= maxLength;
function handleChange(event) {
if (value === undefined) setInternalValue(event.target.value);
onChange?.(event);
}
return <div className="w-full">
<label
htmlFor={textareaId}
className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]"
>
{label}
</label>
<textarea
id={textareaId}
rows={rows}
value={currentValue}
onChange={handleChange}
maxLength={maxLength}
aria-describedby={countId}
className={cx(TEXTAREA_BASE, TEXTAREA_BORDER, className)}
{...props}
/>
<div
id={countId}
aria-live="polite"
className="mt-2 flex items-baseline justify-between gap-3 text-xs leading-4 text-[var(--ds-color-muted-foreground)]"
>
<span>{helperText}</span>
<span className={cx("shrink-0", atLimit && "font-medium text-[var(--ds-color-foreground)]")}>
{currentValue.length}{maxLength !== undefined ? ` / ${maxLength}` : ""}
</span>
</div>
</div>;
}
export default TextareaWithCounter; # Textarea With Counter
Textarea with a live character counter (current / maximum) derived from the real value.
## Usage
```tsx
<TextareaWithCounter label="Release notes" maxLength={280} helperText="Shown on the changelog page." rows={4} />
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
<TextareaWithCounter label="Release notes" maxLength={280} helperText="Shown on the changelog page." rows={4} />
```
## Props
| Name | Type | Default | Description |
|---|---|---:|---|
| `label` | `ReactNode` (required) | — | Visible label above the control. |
| `value` / `defaultValue` | `string` | — | Controlled / uncontrolled value. |
| `onChange` | `(event) => void` | — | Native change callback. |
| `rows` | `number` | `3` | Visible rows — the natural height floor (with `min-h-[80px]`). |
| `placeholder` | `string` | — | Muted placeholder (never critical information). |
| `disabled` | `boolean` | — | Native disabled — not focusable, not submitted. |
| `readOnly` | `boolean` | — | Native read-only — focusable, selectable, submitted. |
| `required` / `name` / `id` | `boolean` / `string` | — | Native form semantics (`id` also the label `htmlFor`). |
| `minLength` / `maxLength` | `number` | — | Native length constraints. |
| `className` | `string` | — | Extra Tailwind classes merged onto the control. |
| other native props / `aria-*` | — | — | Passed through to the `<textarea>`. |
| `helperText` | `ReactNode` | — | Optional text beside the counter. |
## States
Native textarea with a live character counter under the field. The count is computed from the actual value (controlled `value` or tracked uncontrolled text) — it updates on every keystroke and is never faked. When `maxLength` is supplied the counter reads `current / maximum` and the native attribute enforces the limit; at the limit the count gains weight + foreground color as a quiet, non-color-only cue. Without `maxLength` the counter is a plain character count.
## Accessibility
The counter region is linked with `aria-describedby` and marked `aria-live="polite"`, so screen readers can read the count on demand and hear polite updates without focus theft. The at-limit cue is text weight + color together — not color alone. Native `maxLength` behavior (typing stops at the cap) is preserved.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-input)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This textarea uses the semantic color, radius, spacing, and motion tokens.
## Notes
Use for bounded content: changelog entries, status updates, short bios. For long-form content where a limit would be hostile, drop `maxLength` and keep the plain count, or use the plain `textarea`. 88 lines UTF-8 · LF · Spaces: 2
Continue browsing