Forms
Composable form elements using Field/Label pattern — inputs, selects, checkboxes, radios, switches, range sliders, input groups, and validation states.
Preview
Text input
import { Field, Label, Description, Input } from '@/registry/ui-kit/tailwind-forms/react'
function Example() {
return (
<Field>
<Label>Email</Label>
<Input type="email" placeholder="you@example.com" />
<Description>We'll never share your email.</Description>
</Field>
)
}Select
import { Field, Label, Select } from '@/registry/ui-kit/tailwind-forms/react'
function Example() {
return (
<Field>
<Label>Country</Label>
<Select>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
</Select>
</Field>
)
}Checkbox
import { Checkbox } from '@/registry/ui-kit/tailwind-forms/react'
function Example() {
return (
<Checkbox
label="Accept terms"
description="You agree to our Terms of Service."
/>
)
}Radio group
import { RadioGroup } from '@/registry/ui-kit/tailwind-forms/react'
function Example() {
return (
<RadioGroup
name="plan"
options={[
{ value: 'free', label: 'Free', description: 'Up to 5 projects' },
{ value: 'pro', label: 'Pro', description: 'Unlimited projects' },
]}
/>
)
}Switch with label
import { Field, Label, Description, Switch } from '@/registry/ui-kit/tailwind-forms/react'
function Example() {
const [enabled, setEnabled] = useState(false)
return (
<Field>
<div className="flex items-center justify-between">
<Label>Notifications</Label>
<Switch checked={enabled} onChange={setEnabled} />
</div>
<Description>Receive push notifications.</Description>
</Field>
)
}Range slider
import { Field, Label, Range } from '@/registry/ui-kit/tailwind-forms/react'
function Example() {
return (
<Field>
<Label>Volume</Label>
<Range min={0} max={100} />
</Field>
)
}Input group
Inputs with leading or trailing addons.
import { Field, Label, InputGroup } from '@/registry/ui-kit/tailwind-forms/react'
function Example() {
return (
<div className="space-y-4">
<Field>
<Label>Website</Label>
<InputGroup leadingAddon="https://" placeholder="example.com" />
</Field>
<Field>
<Label>Price</Label>
<InputGroup trailingAddon="USD" placeholder="0.00" />
</Field>
</div>
)
}Validation
Use the invalid prop with ErrorMessage for error states.
import { Field, Label, ErrorMessage, Input } from '@/registry/ui-kit/tailwind-forms/react'
function Example() {
return (
<Field>
<Label>Password</Label>
<Input type="password" invalid />
<ErrorMessage>Must be at least 8 characters.</ErrorMessage>
</Field>
)
}Component API
| Prop | Default | Description |
|---|---|---|
Field | — | Wrapper that provides shared id via context. Renders a div with space-y-1.5. |
Label | — | Reads id from Field context and renders a label element with htmlFor. |
Description | — | Helper text below the input. |
ErrorMessage | — | Error message text — renders in red. |
invalid | false | Turns the border red on Input, Select, Textarea, and InputGroup. |
shortcut | "⌘K" | Keyboard shortcut badge (SearchInput only). |
leadingAddon | — | Left-side addon text (InputGroup only). |
trailingAddon | — | Right-side addon text (InputGroup only). |
checked | false | Controlled checked state (Switch only). |
options | — | Options array for RadioGroup. |
Source Code
"use client";
import { forwardRef, createContext, useContext, useId } from "react";
/* ── Field Context ── */
const FieldContext = createContext<{ id?: string; error?: boolean }>({});
/* ── Field ── */
interface FieldProps {
children: React.ReactNode;
className?: string;
}
function Field({ children, className = "" }: FieldProps) {
const id = useId();
return (
<FieldContext.Provider value={{ id }}>
<div className={`space-y-1.5 ${className}`}>{children}</div>
</FieldContext.Provider>
);
}
/* ── Label ── */
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
children: React.ReactNode;
}
function Label({ children, className = "", ...props }: LabelProps) {
const { id } = useContext(FieldContext);
return (
<label
htmlFor={id}
className={`block text-sm font-medium text-zinc-900 dark:text-zinc-100 ${className}`}
{...props}
>
{children}
</label>
);
}
/* ── Description ── */
function Description({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return <p className={`text-sm text-zinc-500 dark:text-zinc-400 ${className}`}>{children}</p>;
}
/* ── ErrorMessage ── */
function ErrorMessage({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return <p className={`text-sm text-red-600 ${className}`}>{children}</p>;
}
/* ── Input ── */
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
invalid?: boolean;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ invalid = false, className = "", ...props }, ref) => {
const { id } = useContext(FieldContext);
return (
<input
ref={ref}
id={id}
className={`block w-full rounded-lg border bg-white px-3.5 py-2 text-sm text-zinc-900 shadow-sm outline-none transition-colors placeholder:text-zinc-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 ${
invalid
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20"
: "border-zinc-300 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
} ${className}`}
aria-invalid={invalid || undefined}
{...props}
/>
);
}
);
Input.displayName = "Input";
/* ── Search Input ── */
interface SearchInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
shortcut?: string;
}
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ shortcut = "⌘K", className = "", ...props }, ref) => {
const { id } = useContext(FieldContext);
return (
<div className={`relative ${className}`}>
<svg
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={ref}
id={id}
type="search"
className="block w-full rounded-full border border-zinc-300 bg-white py-2 pl-10 pr-16 text-sm text-zinc-900 shadow-sm outline-none transition-colors placeholder:text-zinc-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
{...props}
/>
{shortcut && (
<kbd className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 select-none rounded border border-zinc-200 bg-zinc-100 px-1.5 py-0.5 text-xs font-medium text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
{shortcut}
</kbd>
)}
</div>
);
}
);
SearchInput.displayName = "SearchInput";
/* ── Select ── */
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
invalid?: boolean;
}
const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ invalid = false, className = "", children, ...props }, ref) => {
const { id } = useContext(FieldContext);
return (
<div className="relative">
<select
ref={ref}
id={id}
className={`block w-full appearance-none rounded-lg border bg-white py-2 pl-3.5 pr-10 text-sm text-zinc-900 shadow-sm outline-none transition-colors dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 ${
invalid
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20"
: "border-zinc-300 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
} ${className}`}
aria-invalid={invalid || undefined}
{...props}
>
{children}
</select>
<svg
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
);
}
);
Select.displayName = "Select";
/* ── Textarea ── */
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
invalid?: boolean;
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ invalid = false, className = "", ...props }, ref) => {
const { id } = useContext(FieldContext);
return (
<textarea
ref={ref}
id={id}
className={`block w-full rounded-lg border bg-white px-3.5 py-2 text-sm text-zinc-900 shadow-sm outline-none transition-colors placeholder:text-zinc-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 ${
invalid
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20"
: "border-zinc-300 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
} ${className}`}
aria-invalid={invalid || undefined}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
/* ── Checkbox ── */
interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label: string;
description?: string;
}
const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ label, description, className = "", ...props }, ref) => {
return (
<label className={`flex cursor-pointer items-start gap-3 ${className}`}>
<input
ref={ref}
type="checkbox"
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-zinc-300 text-indigo-600 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-900"
{...props}
/>
<div>
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{label}</span>
{description && (
<p className="text-sm text-zinc-500 dark:text-zinc-400">{description}</p>
)}
</div>
</label>
);
}
);
Checkbox.displayName = "Checkbox";
/* ── Radio Group ── */
interface RadioOption {
value: string;
label: string;
description?: string;
}
interface RadioGroupProps {
name: string;
options: RadioOption[];
value?: string;
onChange?: (value: string) => void;
className?: string;
}
function RadioGroup({ name, options, value, onChange, className = "" }: RadioGroupProps) {
return (
<div className={`space-y-2.5 ${className}`}>
{options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-start gap-3">
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={() => onChange?.(opt.value)}
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer border-zinc-300 text-indigo-600 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-900"
/>
<div>
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{opt.label}</span>
{opt.description && (
<p className="text-sm text-zinc-500 dark:text-zinc-400">{opt.description}</p>
)}
</div>
</label>
))}
</div>
);
}
/* ── Switch / Toggle ── */
interface SwitchProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
function Switch({ checked = false, onChange, disabled = false, className = "" }: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange?.(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 disabled:cursor-not-allowed disabled:opacity-50 ${
checked ? "bg-indigo-600" : "bg-zinc-200 dark:bg-zinc-700"
} ${className}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform ${
checked ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
);
}
/* ── Range Slider ── */
interface RangeProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {}
const Range = forwardRef<HTMLInputElement, RangeProps>(
({ className = "", ...props }, ref) => {
const { id } = useContext(FieldContext);
return (
<input
ref={ref}
type="range"
id={id}
className={`w-full cursor-pointer accent-indigo-600 ${className}`}
{...props}
/>
);
}
);
Range.displayName = "Range";
/* ── Input Group ── */
interface InputGroupProps extends React.InputHTMLAttributes<HTMLInputElement> {
leadingAddon?: string;
trailingAddon?: string;
invalid?: boolean;
}
const InputGroup = forwardRef<HTMLInputElement, InputGroupProps>(
({ leadingAddon, trailingAddon, invalid = false, className = "", ...props }, ref) => {
const { id } = useContext(FieldContext);
return (
<div className={`flex overflow-hidden rounded-lg border shadow-sm ${invalid ? "border-red-500" : "border-zinc-300 dark:border-zinc-700"} focus-within:border-indigo-500 focus-within:ring-2 focus-within:ring-indigo-500/20`}>
{leadingAddon && (
<span className="flex items-center border-r border-zinc-300 bg-zinc-50 px-3.5 text-sm text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
{leadingAddon}
</span>
)}
<input
ref={ref}
id={id}
className={`block w-full bg-white px-3.5 py-2 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 ${className}`}
aria-invalid={invalid || undefined}
{...props}
/>
{trailingAddon && (
<span className="flex items-center border-l border-zinc-300 bg-zinc-50 px-3.5 text-sm text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
{trailingAddon}
</span>
)}
</div>
);
}
);
InputGroup.displayName = "InputGroup";
export { Field, Label, Description, ErrorMessage, Input, SearchInput, Select, Textarea, Checkbox, RadioGroup, Switch, Range, InputGroup };
export default Input;