Character Count Limit
A small ring closes as the field fills, shows what is left once the cap is in sight, and tints once when the text runs past it.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Character Count Limit
*
* A small ring closes as the field fills. It stays quiet until the limit
* is in sight, shows the number remaining once it is, and tints — with a
* single pulse, not a repeat — the moment the text goes over.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are mixed
* from the inherited text color, so the field reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `limit`, `autoPlay`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CharacterCountLimitProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field label, also the accessible name. */
label?: string;
/** Characters allowed before the field goes over. */
limit?: number;
/** Fraction of the limit at which the count appears. */
warnAt?: number;
/** Type the sample in on mount so the ring can be watched closing. */
autoPlay?: boolean;
/** Accent for the ring below the warning point. */
accent?: string;
/** Overall width. */
width?: number | string;
/** Fires whenever the text crosses into or out of the over-limit state. */
onLimitChange?: (over: boolean) => void;
};
type VariantConfig = {
/** Seconds for the ring to reach a new fraction. */
arc: number;
/** Seconds for the count to fade in or out. */
fade: number;
/** Peak of the single pulse played when the limit is passed. */
pulse: number;
pulseDuration: number;
};
// Quality rule: the ring is the only thing that moves. The count is text,
// so it fades in at a constant size and stays on tabular figures — a
// number that scales or springs while the user is typing is unreadable,
// which is the one thing a counter cannot afford to be. Going over plays
// exactly one pulse; a repeating throb would sit in the corner of the
// eye for as long as the sentence is too long.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// The ring barely lags the keystroke. For dense forms.
subtle: { arc: 0.16, fade: 0.12, pulse: 1.08, pulseDuration: 0.3 },
// A visible sweep behind each burst of typing. All-purpose.
default: { arc: 0.24, fade: 0.16, pulse: 1.14, pulseDuration: 0.38 },
// A longer sweep and a fuller pulse for a single prominent field.
playful: { arc: 0.3, fade: 0.2, pulse: 1.2, pulseDuration: 0.46 },
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const WARN_COLOR = "#E0A03C";
const OVER_COLOR = "#E5484D";
const SAMPLE =
"Insulated 12oz travel cup with a leakproof lid, machined from a single billet of stainless steel and finished in a soft matte coat that survives a dishwasher.";
/** Characters added per tick while the sample types itself in. */
const TYPE_CHUNK = 3;
const TYPE_MS = 34;
export default function CharacterCountLimit({
variant = "default",
label = "Product description",
limit = 140,
warnAt = 0.75,
autoPlay = true,
accent = "#5B5BD6",
width = 320,
onLimitChange,
}: CharacterCountLimitProps) {
const [value, setValue] = useState("");
const [typing, setTyping] = useState(autoPlay);
const [focused, setFocused] = useState(false);
const fieldId = useId();
const countId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const used = value.length;
const over = used > limit;
const ratio = Math.min(used / limit, 1);
const near = used >= Math.round(limit * warnAt);
const remaining = limit - used;
const color = over ? OVER_COLOR : near ? WARN_COLOR : accent;
// The sample types itself so the ring can be seen closing without
// anyone having to write a paragraph first. Any real input stops it.
// The run ends from inside the tick that lands the last chunk, so the
// effect body only ever schedules — it never sets state synchronously.
useEffect(() => {
if (!typing || value.length >= SAMPLE.length) return;
const timer = setTimeout(() => {
const next = SAMPLE.slice(0, value.length + TYPE_CHUNK);
setValue(next);
if (next.length >= SAMPLE.length) setTyping(false);
}, TYPE_MS);
return () => clearTimeout(timer);
}, [typing, value]);
useEffect(() => {
onLimitChange?.(over);
// Only the crossing matters, not every keystroke that stays over.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [over]);
const size = 20;
const stroke = 2.5;
const radius = (size - stroke) / 2;
const center = size / 2;
return (
<div style={{ width, color: "inherit" }}>
<label
htmlFor={fieldId}
style={{
display: "block",
marginBottom: 6,
fontSize: 11.5,
fontWeight: 650,
letterSpacing: 0.3,
opacity: 0.55,
}}
>
{label.toUpperCase()}
</label>
<textarea
id={fieldId}
value={value}
rows={3}
aria-describedby={countId}
aria-invalid={over || undefined}
onChange={(event) => {
setTyping(false);
setValue(event.target.value);
}}
onFocus={() => {
setTyping(false);
setFocused(true);
}}
onBlur={() => setFocused(false)}
style={{
display: "block",
width: "100%",
padding: "11px 12px",
boxSizing: "border-box",
borderRadius: 12,
border: `1px solid ${over ? OVER_COLOR : focused ? accent : tone(13)}`,
background: tone(5),
color: "inherit",
fontFamily: "inherit",
fontSize: 13,
lineHeight: 1.55,
resize: "none",
outline: "none",
boxShadow: focused
? `0 0 0 3px color-mix(in srgb, ${
over ? OVER_COLOR : accent
} 24%, transparent)`
: "0 0 0 0 transparent",
// Tint is a state change, so it settles on a CSS transition and
// the animation loop stays transform-and-opacity only.
transition: "border-color 180ms ease-out, box-shadow 160ms ease-out",
}}
/>
<div
id={countId}
role="status"
aria-live="polite"
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 7,
minHeight: 22,
marginTop: 8,
}}
>
{/* The count only exists once it is useful. Fading it in at a
constant size keeps the row from reflowing under the typing. */}
<motion.span
initial={false}
animate={{ opacity: near ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : cfg.fade, ease: "easeOut" }}
style={{
fontSize: 11.5,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
color: over ? OVER_COLOR : near ? WARN_COLOR : "inherit",
transition: "color 180ms ease-out",
}}
>
{over
? `${Math.abs(remaining)} over the limit`
: `${remaining} left`}
</motion.span>
<motion.span
// The keyframe target only changes when the text crosses the
// line, so the pulse plays once per trip over it, never on a
// loop and never on every keystroke that stays over.
animate={
over && !reduceMotion
? { scale: [1, cfg.pulse, 1] }
: { scale: 1 }
}
transition={{
duration: cfg.pulseDuration,
times: [0, 0.35, 1],
ease: "easeOut",
}}
style={{ display: "grid", placeItems: "center", flex: "0 0 auto" }}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden>
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={tone(16)}
strokeWidth={stroke}
/>
<g transform={`rotate(-90 ${center} ${center})`}>
<motion.circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: ratio }}
transition={{
duration: reduceMotion ? 0 : cfg.arc,
ease: "easeOut",
}}
style={{ transition: "stroke 180ms ease-out" }}
/>
</g>
</svg>
</motion.span>
</div>
</div>
);
}About this pattern
A counter that shouts from the first keystroke gets ignored by the tenth, so this one earns attention gradually: the ring closes quietly while there is room, the number appears only once the cap is in sight, and the whole indicator tints amber and then red as the text crosses it. Going over plays exactly one pulse — a repeating throb would sit in the corner of the eye for as long as the sentence is too long. The number itself is text and behaves like text: constant size, tabular figures, no spring, because a count that jitters while someone is typing is the one thing a count cannot be. Nothing is truncated; the field reports the overrun and lets the writer fix it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Social feed
A ring that closes as the post grows and turns red past the cap.
Related patterns
- Textarea AutogrowThe field takes one line-height more as the text wraps, easing into it rather than snapping.
- Conditional Section RevealPicking an option unfolds the extra fields it requires, the section easing its height open.
- Form Reset ClearA wash crosses each row in turn and the value is dropped while the row is covered.