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.
npx shadcn@latest add @paragon/char-count-down"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>
);
}