Component
Input With Character Count
Input with live character count and maxLength support.
- Component
- React
- TSX
- Tailwind CSS
- MIT
What it does
Capabilities
- Semantic HTML
- ARIA
- Associated labels
Preview
Live component
9:41 100%
devsnips.site/library/react/components/inputs/input-with-character-count/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Inputs/input-with-character-count React/Components/Inputs/input-with-character-count import type { ChangeEvent, InputHTMLAttributes, ReactNode } from "react";
import { useId, useState } from "react";
export type InputSize = "sm" | "md" | "lg";
export type InputTone = "default" | "error" | "success";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const INPUT_SIZES: Record<InputSize, string> = {
sm: "h-8 px-2.5 text-[13px]",
md: "h-9 px-3 text-sm",
lg: "h-11 px-3.5 text-sm",
};
const ICON_SIZES: Record<InputSize, string> = {
sm: "[&_svg]:size-[14px]",
md: "[&_svg]:size-4",
lg: "[&_svg]:size-[18px]",
};
function inputClasses(size: InputSize, tone: InputTone, hasLeading = false, hasTrailing = false): string {
return cx(
"w-full rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] text-[var(--ds-color-foreground)] shadow-none transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 read-only:bg-[var(--ds-color-surface-subtle)] read-only:text-[var(--ds-color-muted-foreground)] motion-reduce:transition-none",
tone === "error" ? "border-[var(--ds-color-destructive)]" : tone === "success" ? "border-[var(--ds-color-success)]" : "border-[var(--ds-color-border)] focus:border-[var(--ds-color-border-strong)]",
INPUT_SIZES[size],
hasLeading && "pl-9",
hasTrailing && "pr-9",
);
}
function Spinner() {
return (
<svg className="size-4 animate-spin motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
export interface InputWithCharacterCountProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "prefix"> {
label?: string;
helperText?: string;
error?: string;
success?: string;
size?: InputSize;
tone?: InputTone;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
prefix?: ReactNode;
suffix?: ReactNode;
}
export function InputWithCharacterCount({ label = "Workspace name", helperText = "Keep names short for navigation.", size = "md", tone = "default", id, className, value, defaultValue = "", onChange, maxLength = 64, ...props }: InputWithCharacterCountProps) {
const generatedId = useId(); const inputId = id ?? `input-${generatedId}`; const countId = `${inputId}-count`;
const [internalValue, setInternalValue] = useState(String(defaultValue ?? ""));
const currentValue = value === undefined ? internalValue : String(value);
function handleChange(event: ChangeEvent<HTMLInputElement>) { if (value === undefined) setInternalValue(event.target.value); onChange?.(event); }
return <div className="w-full"><label htmlFor={inputId} className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]">{label}</label><input id={inputId} className={cx(inputClasses(size, tone), className)} value={currentValue} onChange={handleChange} maxLength={maxLength} aria-describedby={countId} {...props} /><div id={countId} className="mt-2 flex justify-between gap-3 text-xs text-[var(--ds-color-muted-foreground)]" aria-live="polite"><span>{helperText}</span><span>{currentValue.length} / {maxLength}</span></div></div>;
}
export default InputWithCharacterCount; /* DevSnips React — JavaScript parity build. Same API and behavior as code.tsx; TypeScript types removed. */
import { useId, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const INPUT_SIZES = {
sm: "h-8 px-2.5 text-[13px]",
md: "h-9 px-3 text-sm",
lg: "h-11 px-3.5 text-sm",
};
const ICON_SIZES = {
sm: "[&_svg]:size-[14px]",
md: "[&_svg]:size-4",
lg: "[&_svg]:size-[18px]",
};
function inputClasses(size, tone, hasLeading = false, hasTrailing = false) {
return cx(
"w-full rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] text-[var(--ds-color-foreground)] shadow-none transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 read-only:bg-[var(--ds-color-surface-subtle)] read-only:text-[var(--ds-color-muted-foreground)] motion-reduce:transition-none",
tone === "error" ? "border-[var(--ds-color-destructive)]" : tone === "success" ? "border-[var(--ds-color-success)]" : "border-[var(--ds-color-border)] focus:border-[var(--ds-color-border-strong)]",
INPUT_SIZES[size],
hasLeading && "pl-9",
hasTrailing && "pr-9",
);
}
function Spinner() {
return (
<svg className="size-4 animate-spin motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
export function InputWithCharacterCount({ label = "Workspace name", helperText = "Keep names short for navigation.", size = "md", tone = "default", id, className, value, defaultValue = "", onChange, maxLength = 64, ...props }) {
const generatedId = useId(); const inputId = id ?? `input-${generatedId}`; const countId = `${inputId}-count`;
const [internalValue, setInternalValue] = useState(String(defaultValue ?? ""));
const currentValue = value === undefined ? internalValue : String(value);
function handleChange(event) { if (value === undefined) setInternalValue(event.target.value); onChange?.(event); }
return <div className="w-full"><label htmlFor={inputId} className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]">{label}</label><input id={inputId} className={cx(inputClasses(size, tone), className)} value={currentValue} onChange={handleChange} maxLength={maxLength} aria-describedby={countId} {...props} /><div id={countId} className="mt-2 flex justify-between gap-3 text-xs text-[var(--ds-color-muted-foreground)]" aria-live="polite"><span>{helperText}</span><span>{currentValue.length} / {maxLength}</span></div></div>;
}
export default InputWithCharacterCount; # Input With Character Count
Input with live character count and maxLength support.
## Usage
```tsx
import { InputWithCharacterCount } from './code';
<InputWithCharacterCount label="Workspace name" placeholder="Acme design" />
```
## JavaScript
```jsx
import { InputWithCharacterCount } from './code';
<InputWithCharacterCount label="Billing email" placeholder="[email protected]" />
```
## Props
| Name | Type | Default | Description |
|---|---|---:|---|
| `label` | `string` | component label | Visible label associated with the input. |
| `helperText` | `string` | `undefined` | Supporting text announced through `aria-describedby`. |
| `error` | `string` | `undefined` | Error message; sets `aria-invalid`. |
| `success` | `string` | `undefined` | Success message associated with the control. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Control height and text sizing. |
| `tone` | `"default" \| "error" \| "success"` | `"default"` | Visual state when no message prop overrides it. |
| `className` | `string` | `undefined` | Adds layout classes to the input element. |
| native input props | `InputHTMLAttributes<HTMLInputElement>` | — | Supports `value`, `defaultValue`, `onChange`, `name`, `id`, `placeholder`, `disabled`, `readOnly`, `required`, and `aria-*`. |
## States
Supports default, hover, focus-visible, disabled, readonly, error, success, and loading where applicable.
## Accessibility
Uses a native `input`, a visible `label`, associated helper/error text, `aria-invalid` for errors, and keyboard-accessible buttons for interactive controls.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. No component-specific CSS file is required.
## Design Tokens
Visual decisions reference `React/DESIGN_TOKENS.md` rather than duplicating the token system. 63 lines UTF-8 · LF · Spaces: 2
DevSnips AI ready Claude · Cursor · Windsurf
Agent skill & Model Context Protocol
Pass this component straight into your AI workflow. DevSnips exposes each resource as a structured, addressable registry entry, so your agent installs the real files instead of approximating them.
devsnips://components/inputs/input-with-character-count I want to use this DevSnips resource in production: Page: https://devsnips.site/library/react/components/inputs/input-with-character-count/ Install: npx devsnips add React/Components/Inputs/input-with-character-count This is the Input With Character Count — Input with live character count and maxLength support. Use this DevSnips resource as the starting point for my project. Adapt it to my project's stack and conventions, fix any issues, and make it production-ready while preserving its intended design and behavior.
Paste into ChatGPT, Claude or your agent — it already carries the page URL and the exact install path.
Resource URI
devsnips://components/inputs/input-with-character-count