Validation textareas
Five multi-line fields that show validation state: valid, invalid, a non-blocking warning, a neutral hint and a live counter that validates as you type. Each carries a visible label, ties its message in with aria-describedby, and uses the standard ai2 danger invalid look.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/textarea-validationDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install lucide-reactCopy the source
components/ui/textarea-validation.tsx"use client"
import * as React from "react"
import { AlertTriangle, Check, Info, X } from "lucide-react"
import { cn } from "@/lib/utils"
/* Validation textarea family: 5 multi-line fields that show a validation state.
In this family the border sits directly on the <textarea>, so the invalid
appearance stays byte-for-byte identical to the project standard:
aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40
Color comes ONLY from tokens (success/warning/danger + shadcn); ai2 danger is
used, NEVER shadcn destructive. The state visuals are driven by CSS + a small
piece of React state; there is no animation, so motion/react is not needed. Every
field has a visible label (the ids are produced with React.useId). Each one wraps
a real <textarea> and passes through all the native props. Size = min-height. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const height: Record<StyledSize, string> = {
sm: "min-h-16 text-sm",
md: "min-h-20 text-sm",
lg: "min-h-24 text-base",
xl: "min-h-28 text-base",
}
/* Kenarlik alanin kendisinde: aria-invalid standardi dogrudan uygulanabilsin. */
const areaBase =
"w-full resize-y rounded-lg border bg-transparent px-3 py-2 text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40"
const rootBase = "flex w-72 max-w-full flex-col gap-1.5"
const labelBase = "px-0.5 text-xs font-medium text-foreground"
const noteBase =
"flex items-start gap-1.5 px-0.5 text-xs [&>svg]:mt-px [&>svg]:size-3.5 [&>svg]:shrink-0 [&>i]:mt-px [&>i]:text-xs [&>i]:leading-none [&>i]:shrink-0"
type Props = React.ComponentProps<"textarea"> & {
size?: StyledSize
/** Gorunur etiket metni. */
label?: string
}
/* Kontrollu/kontrolsuz fark etmeksizin girilen degeri izler. */
function useTrackedValue(props: Pick<Props, "value" | "defaultValue" | "onChange">) {
const [internal, setInternal] = React.useState(
props.defaultValue != null ? String(props.defaultValue) : ""
)
const value = props.value != null ? String(props.value) : internal
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInternal(e.target.value)
props.onChange?.(e)
}
return { value, onChange }
}
/* Valid: success kenarlik + altta onay notu. */
export function ValidTextarea({ className, size = "md", label = "Message", ...props }: Props) {
const id = React.useId()
const noteId = `${id}-note`
return (
<span data-slot="styled-textarea" data-tone="success" className={rootBase}>
<label htmlFor={id} className={labelBase}>
{label}
</label>
<textarea
id={id}
aria-describedby={noteId}
{...props}
className={cn(
areaBase,
"border-success focus-visible:border-success focus-visible:ring-success/25",
height[size],
className
)}
/>
<span id={noteId} className={cn(noteBase, "text-success")}>
<Check aria-hidden="true" />
Looks good.
</span>
</span>
)
}
/* Invalid: proje standardi gecersiz gorunum (aria-invalid) + danger mesaji. */
export function InvalidTextarea({
className,
size = "md",
label = "Message",
message = "This field is required.",
...props
}: Props & { message?: string }) {
const id = React.useId()
const msgId = `${id}-msg`
return (
<span data-slot="styled-textarea" data-tone="danger" className={rootBase}>
<label htmlFor={id} className={labelBase}>
{label}
</label>
<textarea
id={id}
aria-invalid="true"
aria-describedby={msgId}
{...props}
className={cn(areaBase, "border-field-border", height[size], className)}
/>
<span id={msgId} className={cn(noteBase, "text-danger")}>
<X aria-hidden="true" />
{message}
</span>
</span>
)
}
/* Warning: a non-blocking warning; a warning border + a warning note. This is NOT an
error state, so it does not carry aria-invalid and data-tone stays neutral. */
export function WarningTextarea({
className,
size = "md",
label = "Message",
message = "This may be too short to be useful.",
...props
}: Props & { message?: string }) {
const id = React.useId()
const msgId = `${id}-msg`
return (
<span data-slot="styled-textarea" data-tone="neutral" className={rootBase}>
<label htmlFor={id} className={labelBase}>
{label}
</label>
<textarea
id={id}
aria-describedby={msgId}
{...props}
className={cn(
areaBase,
"border-warning focus-visible:border-warning focus-visible:ring-warning/25",
height[size],
className
)}
/>
<span id={msgId} className={cn(noteBase, "text-warning")}>
<AlertTriangle aria-hidden="true" />
{message}
</span>
</span>
)
}
/* Hint: a neutral border + help text below (guidance, not an error). */
export function HintTextarea({
className,
size = "md",
label = "Message",
message = "Tell us what happened, in your own words.",
...props
}: Props & { message?: string }) {
const id = React.useId()
const msgId = `${id}-msg`
return (
<span data-slot="styled-textarea" data-tone="neutral" className={rootBase}>
<label htmlFor={id} className={labelBase}>
{label}
</label>
<textarea
id={id}
aria-describedby={msgId}
{...props}
className={cn(areaBase, "border-field-border focus-visible:border-ring", height[size], className)}
/>
<span id={msgId} className={cn(noteBase, "text-muted-foreground")}>
<Info aria-hidden="true" />
{message}
</span>
</span>
)
}
/* Live: yazdikca karakter ve kelime sayisini dogrular. Bos iken notr; minLength
altinda veya maxLength ustunde gecersiz (aria-invalid), arada gecerli. Sayac
politede duyurulur. */
export function LiveTextarea({
className,
size = "md",
label = "Message",
minLength = 20,
maxLength = 160,
...props
}: Props) {
const id = React.useId()
const statusId = `${id}-status`
const { value, onChange } = useTrackedValue(props)
const chars = value.length
const words = value.trim() === "" ? 0 : value.trim().split(/\s+/).length
const state: "idle" | "valid" | "invalid" =
chars === 0 ? "idle" : chars < minLength || chars > maxLength ? "invalid" : "valid"
const tone =
state === "valid"
? "border-success focus-visible:border-success focus-visible:ring-success/25"
: state === "invalid"
? "border-field-border"
: "border-field-border focus-visible:border-ring"
const note =
state === "idle"
? `Write at least ${minLength} characters.`
: state === "invalid"
? chars < minLength
? `${minLength - chars} more characters needed.`
: `${chars - maxLength} characters over the limit.`
: `${words} ${words === 1 ? "word" : "words"}, ${chars}/${maxLength} characters.`
return (
<span data-slot="styled-textarea" data-tone={state === "idle" ? "neutral" : state === "valid" ? "success" : "danger"} className={rootBase}>
<label htmlFor={id} className={labelBase}>
{label}
</label>
<textarea
id={id}
minLength={minLength}
aria-invalid={state === "invalid" ? "true" : undefined}
aria-describedby={statusId}
{...props}
onChange={onChange}
className={cn(areaBase, tone, height[size], className)}
/>
<span
id={statusId}
role="status"
aria-live="polite"
className={cn(
noteBase,
"justify-between tabular-nums",
state === "invalid" ? "text-danger" : state === "valid" ? "text-success" : "text-muted-foreground"
)}
>
{note}
</span>
</span>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Valid
A success border with a confirmation note.
import { ValidTextarea } from "@/components/ui/textarea-validation"
<ValidTextarea placeholder="Message" />Invalid
The standard aria-invalid look with a danger message.
import { InvalidTextarea } from "@/components/ui/textarea-validation"
<InvalidTextarea placeholder="Message" />Warning
A non-blocking advisory that is not an error state.
import { WarningTextarea } from "@/components/ui/textarea-validation"
<WarningTextarea placeholder="Message" />Hint
A neutral field with guidance text below it.
import { HintTextarea } from "@/components/ui/textarea-validation"
<HintTextarea placeholder="Message" />Live
Validates characters and words as you type.
import { LiveTextarea } from "@/components/ui/textarea-validation"
<LiveTextarea placeholder="Message" />ai2 Validation textareas: 5 styled variations on the token system
The ai2 Validation textareas are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around multi-line fields that show validation state. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: border and text color changes run on token CSS transitions. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the transitions are disabled and the state colors still apply.
What is in the ai2 Validation textareas?
5 exports in one file: Valid, Invalid, Warning, Hint and Live. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: border and text color changes run on token CSS transitions.
- Reduced-motion aware: Under prefers-reduced-motion, the transitions are disabled and the state colors still apply.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Validation textareas sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.