Text Effects

Char Count Down

Composer-style character limit: a circular ring depletes as you type, turns amber then destructive near the limit, and the count appears only when it matters.

Install

npx shadcn@latest add @paragon/char-count-down

char-count-down.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

/** Amber warning stroke (tailwind amber-500) — legible in both themes. */
const AMBER = "oklch(0.769 0.188 70.08)";

export interface CharCountDownProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** The current input value. */
  value: string;
  /** Character limit. */
  limit: number;
  /** Fraction of the limit remaining at which the ring turns amber. */
  warnAt?: number;
  /** Fraction remaining at which the numeric count appears. */
  showCountAt?: number;
}

/**
 * Composer-style character countdown: a circular ring depletes as you type,
 * turns amber inside the warning band and destructive at the edge, and the
 * remaining count appears only near the limit — popping 0.9 → 1 — then goes
 * negative past it. Pair with any input by passing its value.
 *
 * Counts Unicode code points, renders in tabular-nums, and announces limit
 * overruns to screen readers. Reduced motion drops the pop and the ring's
 * sweep transition; colors still change.
 */
export function CharCountDown({
  value,
  limit,
  warnAt = 0.1,
  showCountAt = 0.2,
  className,
  ...props
}: CharCountDownProps) {
  const reducedMotion = useReducedMotion() ?? false;

  const length = React.useMemo(() => Array.from(value).length, [value]);
  const remaining = limit - length;
  const fraction = limit > 0 ? Math.max(0, remaining) / limit : 0;
  const over = remaining < 0;
  const danger = over || fraction <= warnAt / 2;
  const warn = !danger && fraction <= warnAt;
  const showCount = limit > 0 && remaining / limit <= showCountAt;

  const stroke = danger
    ? "var(--color-destructive)"
    : warn
      ? AMBER
      : "var(--color-muted-foreground)";
  const countColor = danger
    ? "var(--color-destructive)"
    : warn
      ? AMBER
      : "var(--color-muted-foreground)";

  return (
    <div
      className={cn("inline-flex items-center gap-1.5", className)}
      {...props}
    >
      {/* Announce only the important crossing, not every keystroke. */}
      <span role="status" className="sr-only">
        {over ? `${-remaining} characters over the limit` : ""}
      </span>
      <svg viewBox="0 0 20 20" className="size-5 -rotate-90" aria-hidden>
        <circle
          cx="10"
          cy="10"
          r="8"
          fill="none"
          strokeWidth="2"
          stroke={
            over
              ? "color-mix(in oklab, var(--color-destructive) 35%, transparent)"
              : "var(--color-border)"
          }
        />
        <circle
          cx="10"
          cy="10"
          r="8"
          fill="none"
          strokeWidth="2"
          strokeLinecap="round"
          pathLength={100}
          strokeDasharray="100"
          strokeDashoffset={100 - fraction * 100}
          stroke={stroke}
          style={{
            transition: reducedMotion
              ? "stroke 200ms linear"
              : "stroke-dashoffset 150ms var(--ease-out), stroke 200ms linear",
          }}
        />
      </svg>
      <AnimatePresence initial={false}>
        {showCount && (
          <motion.span
            aria-hidden
            initial={
              reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9 }
            }
            animate={{ opacity: 1, scale: 1 }}
            exit={
              reducedMotion
                ? { opacity: 0, transition: { duration: 0.1 } }
                : { opacity: 0, scale: 0.9, transition: { duration: 0.1 } }
            }
            transition={
              reducedMotion
                ? { duration: 0.15 }
                : { type: "spring", duration: 0.3, bounce: 0 }
            }
            className={cn("text-xs tabular-nums", danger && "font-medium")}
            style={{ color: countColor }}
          >
            {remaining}
          </motion.span>
        )}
      </AnimatePresence>
    </div>
  );
}