Skip to content

A tiny ring that fills as you type, then counts down and over.

Formsmotion

01Preview

Visible to everyone at Northwind
19 characters left of 280
33 characters left of 80

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { CharLimitRing, countCharacters } from "@/components/ui/char-limit-ring";

<textarea value={text} onChange={(e) => setText(e.target.value)} aria-describedby="post-count" />
<CharLimitRing id="post-count" count={countCharacters(text)} limit={280} />

04Source

"use client";
import { motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { spring } from "@/lib/motion";

/**
 * Counts what a person sees as characters: "👍🏽" and "é" are one each, not
 * two or four. Falls back to code points where Intl.Segmenter is missing.
 */
export function countCharacters(text: string) {
  if (!text) return 0;
  if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
    return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)].length;
  }
  return Array.from(text).length;
}

type Level = "empty" | "ok" | "near" | "warn" | "over";

export type CharLimitRingProps = Omit<React.ComponentProps<"span">, "children"> & {
  /** Characters typed. Use countCharacters(text) so emoji count as one. */
  count: number;
  limit: number;
  /** Fraction of the limit where the ring turns to the warning color. */
  warnAt?: number;
  /** Remaining characters at which the number appears inside the ring. */
  countFrom?: number;
  size?: "sm" | "md";
  /** Hide the ring until the first character. */
  hideWhenEmpty?: boolean;
};

const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;

export function CharLimitRing({ count, limit, warnAt = 0.9, countFrom = 20, size = "md", hideWhenEmpty = true, className, ...rest }: CharLimitRingProps) {
  const reduce = useReducedMotion();
  const remaining = limit - count;
  const level: Level = count === 0 ? "empty" : remaining < 0 ? "over" : count >= limit * warnAt ? "warn" : remaining <= countFrom ? "near" : "ok";
  const showCount = count > 0 && remaining <= countFrom;
  const fill = Math.min(1, Math.max(0, count / limit));

  // Announce crossings, not keystrokes: entering the last stretch, reaching the limit, going over, coming back.
  const band = remaining < 0 ? "over" : remaining === 0 ? "zero" : remaining <= 10 ? "ten" : remaining <= countFrom ? "near" : "fine";
  const [prev, setPrev] = useState(band);
  const [said, setSaid] = useState("");
  const [bumps, setBumps] = useState(0);
  if (band !== prev) {
    setPrev(band);
    setSaid(
      band === "over"
        ? `${plural(-remaining, "character")} over the limit`
        : band === "zero"
          ? "Character limit reached"
          : band === "ten"
            ? `${remaining} characters left`
            : band === "near"
              ? `${remaining} characters left`
              : prev === "over"
                ? "Back under the limit"
                : "",
    );
    // Going over earns one small pop, so the moment is felt without a shake.
    if (band === "over" && prev !== "over") setBumps((b) => b + 1);
  }

  const base = size === "sm" ? 18 : 22;
  const stroke = size === "sm" ? 2 : 2.25;
  const r = (base - stroke) / 2;
  // The ring grows to make room for the number, then settles back.
  const grow = showCount ? (size === "sm" ? 1.35 : 1.3) : 1;
  const digits = String(remaining).length;

  return (
    <span
      data-slot="char-limit-ring"
      data-level={level}
      className={cn(
        "relative inline-grid shrink-0 place-items-center transition-opacity duration-200 ease-out",
        hideWhenEmpty && count === 0 && "opacity-0",
        className,
      )}
      style={{ width: base * 1.35, height: base * 1.35 }}
      {...rest}
    >
      {/* CSS, not Motion, for the growth: its value renders the same on the server and a reduced-motion client. */}
      <span
        aria-hidden
        className="relative grid place-items-center transition-[scale] duration-300 ease-out-expo motion-reduce:transition-none"
        style={{ width: base, height: base, scale: grow }}
      >
        <motion.span
          key={bumps}
          className="absolute inset-0"
          initial={bumps && !reduce ? { scale: 1.18 } : false}
          animate={{ scale: 1 }}
          transition={spring.bouncy}
        >
          <svg width={base} height={base} viewBox={`0 0 ${base} ${base}`} className="-rotate-90 overflow-visible">
            <circle cx={base / 2} cy={base / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-fg/15" />
            <circle
              cx={base / 2}
              cy={base / 2}
              r={r}
              fill="none"
              strokeWidth={stroke}
              strokeLinecap="round"
              pathLength={1}
              strokeDasharray="1 1"
              className={cn(
                "transition-[stroke-dashoffset,stroke] duration-150 ease-out",
                level === "over" ? "stroke-danger" : level === "warn" ? "stroke-warning" : "stroke-fg-2",
              )}
              style={{ strokeDashoffset: 1 - fill }}
            />
          </svg>
        </motion.span>
        <span
          className={cn(
            "relative font-medium leading-none tabular tracking-[-0.02em] transition-[opacity,color] duration-150",
            showCount ? "opacity-100" : "opacity-0",
            level === "over" ? "text-danger" : level === "warn" ? "text-warning" : "text-fg-2",
          )}
          // Shrinks a touch for three or four characters so "-120" still fits inside.
          style={{ fontSize: (size === "sm" ? 9 : 10) - Math.max(0, digits - 2) }}
        >
          {/* Plain digits: this changes on every keystroke, where rolling numbers would read as lag. */}
          {showCount ? remaining : countFrom}
        </span>
      </span>

      <span className="sr-only">
        {remaining >= 0 ? `${plural(remaining, "character")} left of ${limit}` : `${plural(-remaining, "character")} over the ${limit} limit`}
      </span>
      <span role="status" aria-live="polite" className="sr-only">
        {said}
      </span>
    </span>
  );
}

05Props

CharLimitRing

PropTypeDefaultDescription
count*numberCharacters typed. Use countCharacters(text) so an emoji or an accented letter counts as one.
limit*numberThe most characters allowed.
warnAtnumber0.9Fraction of the limit where the ring turns to the warning color.
countFromnumber20Remaining characters at which the ring grows and shows the number inside.
size"sm" | "md""md"18px for inputs, 22px for composers. The box reserves room for the grown ring either way.
hideWhenEmptybooleantrueFade the ring out while there's nothing typed.

countCharacters

PropTypeDefaultDescription
text*stringCounts grapheme clusters with Intl.Segmenter ("👍🏽" is 1), falling back to code points.

06Notes

Behavior

  • Four stages: a neutral ring that fills; the number appears inside with 20 left; the warning color from 90%; danger and a negative count past the limit, with the ring held full.
  • The ring grows into a fixed box, so the send button beside it never moves as the number appears or reaches three digits ("-120" steps its size down to fit).
  • It only reports; enforcing the limit is the form's job. Disable the submit when count > limit rather than cutting the text off, so pasted text can be edited down.

Motion

  • The arc follows each keystroke over 150ms ease-out and changes color in 150ms; the digits update instantly, because rolling numbers at typing speed read as lag.
  • Entering the last 20 grows the ring 1.3× over 300ms on the expo curve. Crossing the limit gives one pop from 1.18× on a single-overshoot spring; no shake.
  • Reduced motion keeps the size, color and number changes and drops the transitions and the pop.

Accessibility

  • The visuals are hidden from screen readers; a visually hidden line ("34 characters left of 280") is the text to point aria-describedby at from the field.
  • A polite live region speaks only at crossings: 20 left, 10 left, the limit reached, over by how many, and back under.
  • Color is never alone: the number and its minus sign carry the state.