Character Limit Field
A textarea with a small ring that fills toward the limit, switching to a countdown near the end and flagging overflow.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/char-limit-field.json'use client'
import { useId, useState } from 'react'
import './char-limit-field.css'
export interface CharLimitFieldProps {
limit?: number
value?: string
defaultValue?: string
onValueChange?: (v: string) => void
placeholder?: string
label?: string
fill?: 'dark' | 'light'
className?: string
}
const R = 10, CIRC = 2 * Math.PI * R
/** Textarea with a filling limit ring. */
export function CharLimitField({ limit = 280, value, defaultValue = '', onValueChange, placeholder = 'What’s new?', label = 'Post', fill = 'dark', className }: CharLimitFieldProps) {
const [inner, setInner] = useState(defaultValue)
const v = value ?? inner
const id = useId()
const left = limit - [...v].length
const near = left <= 20
const over = left < 0
return (
<div className={['sd-clf', className].filter(Boolean).join(' ')} data-fill={fill} data-near={near || undefined} data-over={over || undefined}>
<textarea className="sd-clf__ta" aria-label={label} aria-describedby={id} aria-invalid={over} placeholder={placeholder} value={v} onChange={(e) => { setInner(e.target.value); onValueChange?.(e.target.value) }} />
<div className="sd-clf__foot">
{over && <span className="sd-clf__over">{-left} over the limit</span>}
<span id={id} className="sd-clf__n" aria-live={near ? 'polite' : 'off'}>{near ? left : ''}<span className="sr">{near ? ' characters left' : ''}</span></span>
<svg className="sd-clf__ring" viewBox="0 0 26 26" aria-hidden>
<circle className="bg" cx="13" cy="13" r={R} />
<circle className="fg" cx="13" cy="13" r={R} strokeDasharray={CIRC} strokeDashoffset={CIRC * Math.max(0, left / limit)} />
</svg>
<button type="button" className="sd-clf__post" disabled={!v.trim() || over}>Post</button>
</div>
</div>
)
}.sd-clf { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-clf[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-clf button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-clf :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-clf .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-clf { width: min(100%, 320px); padding: 12px; border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); transition: box-shadow 200ms; }
.sd-clf:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-clf__ta { display: block; width: 100%; min-height: 84px; max-height: 180px; resize: none; border: 0; outline: 0; background: none; color: var(--fg); font: inherit; font-size: 15px; line-height: 1.45; field-sizing: content; }
.sd-clf__ta::placeholder { color: var(--mute); }
.sd-clf__foot { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding-top: 10px; border-top: 1px solid var(--line); margin-top: 6px; }
.sd-clf__ring { width: 26px; height: 26px; transform: rotate(-90deg); transition: transform 300ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-clf[data-near] .sd-clf__ring { transform: rotate(-90deg) scale(1.15); }
.sd-clf__ring circle { fill: none; stroke-width: 2.5; }
.sd-clf__ring .bg { stroke: var(--soft); }
.sd-clf__ring .fg { stroke: var(--fg); stroke-linecap: round; transition: stroke-dashoffset 150ms; }
.sd-clf[data-over] .sd-clf__ring .fg { stroke-dasharray: 3 3 !important; }
.sd-clf__n { min-width: 26px; text-align: right; font: 600 12.5px/1 ui-monospace, monospace; font-variant-numeric: tabular-nums; color: var(--mute); }
.sd-clf[data-over] .sd-clf__n { color: var(--fg); }
.sd-clf__over { font-size: 12px; color: var(--fg); margin-right: auto; animation: sd-clf-in 200ms ease; }
@keyframes sd-clf-in { from { opacity: 0; transform: translateY(3px); } }
.sd-clf__post { height: 32px; padding: 0 14px; border-radius: 999px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 13px; }
.sd-clf__post:disabled { opacity: 0.35; cursor: default; }
@media (prefers-reduced-motion: reduce) { .sd-clf *, .sd-clf *::before, .sd-clf *::after { animation: none !important; transition: none !important; } }Overview
The ring stays quiet until you get close; inside the last twenty characters it shows the number left, and past the limit the ring turns dashed, the count goes negative and Post disables.
The field grows with its content up to a max height.
When to use
- Bios, posts, SMS, meta descriptions.
When not to use
- Long-form text without a limit.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/char-limit-field.json$
npx shadcn@latest add https://sundaeui.dev/r/char-limit-field.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/char-limit-field.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/char-limit-field.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy char-limit-field.tsx and char-limit-field.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { CharLimitField } from '@/components/sundae/char-limit-field'
<CharLimitField />Props
| Prop | Type | Default |
|---|---|---|
limitMax characters. | number | 280 |
value / defaultValueText. | string | — |
onValueChangeCalled on input. | (v: string) => void | — |
placeholderPlaceholder. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The remaining count is linked with aria-describedby and announced only near the limit.
- aria-invalid is set when over the limit.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-clf; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Character Limit Field free?
- Yes. Character Limit Field is free for personal and commercial projects, with full source on this page.
- How do I install Character Limit Field?
- Run npx shadcn@latest add https://sundaeui.dev/r/char-limit-field.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Character Limit Field support keyboard and screen readers?
- The remaining count is linked with aria-describedby and announced only near the limit. aria-invalid is set when over the limit.
- Does Character Limit Field respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →- Pro
Mention Input
Forms · Refined Markdown Editor
Forms · RefinedThread Composer
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined