Generic Input
A reusable form input that integrates with react-hook-form via useFormContext. Supports label auto-generation, description, error display, leading icons, password visibility toggle, and numeric value coercion.
"use client"
import { useForm, FormProvider } from "react-hook-form"
import { Button } from "@/components/ui/button"
import { GenericInput } from "@/registry/new-york/ui/generic-input/generic-input"
interface FormValues {
username: string
email: string
}
export default function GenericInputDemo() {
const form = useForm<FormValues>({
defaultValues: {
username: "",
email: "",
},
})
function onSubmit(data: FormValues) {
console.log(data)
}
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mx-auto w-full max-w-sm space-y-4">
<GenericInput<FormValues>
name="username"
label="Username"
description="This is your public display name."
required
/>
<GenericInput<FormValues>
name="email"
label="Email"
type="email"
optionalLabel="Optional"
/>
<Button type="submit" className="w-full">
Submit
</Button>
</form>
</FormProvider>
)
}Installation
npx shadcn@latest add @izakcode/generic-inputInstall the required shadcn primitives:
npx shadcn@latest add input fieldInstall peer dependencies:
npm install react-hook-form lucide-reactCopy and paste the following code into your project.
"use client"
import { Eye, EyeOff } from "lucide-react"
import * as React from "react"
import { FieldValues, Path, useFormContext } from "react-hook-form"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
type GenericInputProps<TFieldValues extends FieldValues> = Omit<
React.ComponentProps<"input">,
"name" | "onChange" | "onBlur"
> & {
name: Path<TFieldValues>
label?: React.ReactNode
description?: React.ReactNode
required?: boolean
/**
* Label appended to the field label when the field is not required.
* Pass `null` (or an empty string) to suppress the label entirely (useful for dense forms where
* every field is optional and the annotation would be redundant).
* Defaults to `null` - you must opt in by passing e.g. `optionalLabel="Optional"`.
*/
optionalLabel?: React.ReactNode | null
/**
* Visually disables the input without clearing its value on submit.
*
* Unlike passing `disabled` directly to react-hook-form's `register()`,
* this prop only sets the native `disabled` attribute on the `<input>`
* element. The field value is preserved in form state and included in
* submitted data.
*
* @default false
*/
disabled?: boolean
/**
* When `true`, coerces the input value to a `number` via `setValueAs`.
* Empty strings and non-numeric values resolve to `null`.
*
* @default false
*/
returnAsNumber?: boolean
icon?: React.ReactNode
showPasswordLabel?: string
hidePasswordLabel?: string
/** Forwarded to react-hook-form's `register()` onChange handler. */
onChange?: React.ChangeEventHandler<HTMLInputElement>
/** Forwarded to react-hook-form's `register()` onBlur handler. */
onBlur?: React.FocusEventHandler<HTMLInputElement>
}
/**
* Derives a human-readable label from a react-hook-form field path.
*
* - Splits on `.` and discards pure-numeric segments (array indices).
* - Falls back to the last segment if every segment is numeric.
* - Returns the generic string "Field" only as a last resort; callers should
* always supply an explicit `label` prop for paths that might resolve poorly.
*/
function getLabelFromName(name: string): string {
const segments = name.split(".")
// Prefer the last non-numeric segment so "items.0.firstName" -> "First Name"
const lastMeaningful = segments.findLast((s) => !/^\d+$/.test(s)) ?? segments.at(-1) ?? name
const formatted = lastMeaningful
.replace(/\[.*?\]/g, "")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([a-zA-Z])(\d+)/g, "$1 $2")
.replace(/[_-]/g, " ")
.trim()
.replace(/\b\w/g, (letter) => letter.toUpperCase())
return formatted || "Field"
}
function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
return (node) => {
for (const ref of refs) {
if (typeof ref === "function") {
ref(node)
} else if (ref != null) {
;(ref as React.RefObject<T | null>).current = node
}
}
}
}
/**
* A reusable form input that integrates with react-hook-form via `useFormContext`.
*
* Must be rendered inside a `FormProvider`. Supports label auto-generation,
* description, error display, leading icons, password visibility toggle,
* and numeric value coercion.
*
* @example
* // Basic usage
* <GenericInput<MyFormValues> name="email" required />
*
* @example
* // Show "Optional" annotation on non-required fields
* <GenericInput<MyFormValues> name="nickname" optionalLabel="Optional" />
*
* @example
* // Suppress the optional annotation entirely
* <GenericInput<MyFormValues> name="middleName" optionalLabel={null} />
*/
export function GenericInput<TFieldValues extends FieldValues>({
name,
ref,
id,
label,
className,
required,
// Default to null so optional labels are opt-in, not opt-out.
// Consumers who want the annotation must pass e.g. optionalLabel="Optional".
optionalLabel = null,
disabled = false,
description,
returnAsNumber = false,
icon,
type,
showPasswordLabel = "Show password",
hidePasswordLabel = "Hide password",
onBlur,
onChange,
...props
}: GenericInputProps<TFieldValues>) {
// Guard: fail fast with a clear developer error if this component
// is accidentally used outside a FormProvider.
const form = useFormContext<TFieldValues>()
if (!form) {
throw new Error(
"<GenericInput> must be rendered inside a react-hook-form <FormProvider>. " +
`No FormProvider was found in the component tree for field "${name}".`
)
}
const { register, formState } = form
const [showPassword, setShowPassword] = React.useState(false)
const togglePassword = React.useCallback(() => setShowPassword((current) => !current), [])
const fieldName = name as string
const inputId = id ?? fieldName
const fieldState = form.getFieldState(name, formState)
const fieldError = fieldState.error
const errorId = fieldError ? `${inputId}-error` : undefined
const descriptionId = description ? `${inputId}-description` : undefined
const isPasswordField = type === "password"
const inputType = isPasswordField && showPassword ? "text" : type
const shouldRenderOptionalLabel =
!required &&
optionalLabel != null &&
(typeof optionalLabel !== "string" || optionalLabel.trim().length > 0)
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ")
const { ref: registerRef, ...restRegistration } = register(name, {
onBlur,
onChange,
required,
setValueAs: returnAsNumber
? (value) => {
if (typeof value === "string" && value.trim() === "") {
return null
}
const numericValue = Number(value)
return Number.isNaN(numericValue) ? null : numericValue
}
: undefined,
})
return (
<Field data-disabled={disabled ? true : undefined} data-invalid={fieldError ? true : undefined}>
<FieldLabel htmlFor={inputId} className="flex items-center gap-1">
{label ?? getLabelFromName(fieldName)}
{required ? (
<span className="text-destructive" aria-label="required">
*
</span>
) : shouldRenderOptionalLabel ? (
<span className="text-muted-foreground" aria-label="optional">
{optionalLabel}
</span>
) : null}
</FieldLabel>
<div className="relative">
{icon ? (
<div className="text-muted-foreground pointer-events-none absolute inset-s-3 top-1/2 -translate-y-1/2">
{icon}
</div>
) : null}
<Input
id={inputId}
ref={mergeRefs(ref, registerRef)}
type={inputType}
className={cn(icon && "ps-10", isPasswordField && "pe-10", className)}
disabled={disabled}
aria-required={required || undefined}
aria-invalid={fieldError ? true : undefined}
aria-describedby={describedBy || undefined}
{...props}
{...restRegistration}
/>
{isPasswordField ? (
<button
type="button"
onClick={togglePassword}
disabled={disabled}
className="text-muted-foreground hover:text-foreground absolute inset-e-3 top-1/2 -translate-y-1/2 transition-colors disabled:pointer-events-none disabled:opacity-50"
aria-label={showPassword ? hidePasswordLabel : showPasswordLabel}
aria-pressed={showPassword}
aria-controls={inputId}
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
) : null}
</div>
{description ? <FieldDescription id={descriptionId}>{description}</FieldDescription> : null}
{fieldError ? <FieldError id={errorId} errors={[fieldError]} /> : null}
</Field>
)
}Features
Type-safe field paths
Uses Path<TFieldValues> so name autocompletes and refactors with your schema.
Auto-generated labels
Derives a human-readable label from the field name when no label prop is provided.
Built-in error display
Reads field state from useFormContext and renders FieldError automatically.
Password visibility toggle
Renders an accessible show/hide button when type='password'.
Numeric coercion
Opt-in returnAsNumber prop coerces values via setValueAs, with empty/NaN → null.
RTL-ready layout
Logical inset utilities (ps-*, pe-*, inset-s-*, inset-e-*) work in LTR and RTL.
Usage
Import the component and render it inside a FormProvider:
import { useForm, FormProvider } from "react-hook-form"
import { GenericInput } from "@/components/ui/generic-input"
type MyFormValues = {
email: string
}
function MyForm() {
const form = useForm<MyFormValues>({
defaultValues: { email: "" },
})
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(console.log)}>
<GenericInput<MyFormValues> name="email" required />
<button type="submit">Submit</button>
</form>
</FormProvider>
)
}Examples
Basic
Auto-generated labels, explicit labels, descriptions, and optional annotations.
"use client"
import { useForm, FormProvider } from "react-hook-form"
import { GenericInput } from "@/registry/new-york/ui/generic-input/generic-input"
import { Button } from "@/components/ui/button"
type ContactFormValues = {
firstName: string
lastName: string
email: string
phone: string
}
export default function GenericInputBasic() {
const form = useForm<ContactFormValues>({
defaultValues: {
firstName: "",
lastName: "",
email: "",
phone: "",
},
})
function onSubmit(data: ContactFormValues) {
console.log("Form submitted:", data)
}
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mx-auto flex w-full max-w-md flex-col gap-4"
>
{/* Auto-generated label from field name ("firstName" → "First Name") */}
<GenericInput<ContactFormValues> name="firstName" required />
{/* Explicit label override */}
<GenericInput<ContactFormValues> name="lastName" label="Family Name" required />
{/* With description text */}
<GenericInput<ContactFormValues>
name="email"
type="email"
required
description="We'll never share your email with anyone else."
/>
{/* Optional field with opt-in label */}
<GenericInput<ContactFormValues>
name="phone"
type="tel"
optionalLabel="Optional"
placeholder="+1 (555) 000-0000"
/>
<Button type="submit" className="w-full">Submit</Button>
</form>
</FormProvider>
)
}Auto-generated labels
When no label prop is provided, GenericInput derives one from the field path. Camel-case, snake-case, kebab-case, and array indices are all handled.
name | Auto label |
|---|---|
email | Email |
firstName | First Name |
user_name | User Name |
address-line-1 | Address Line 1 |
items.0.firstName | First Name |
Auto-generation is a convenience for prototypes and obvious cases. Always pass an explicit label
for production fields where the field path may not translate cleanly.
Password field visibility toggle
When type="password", GenericInput renders a built-in visibility toggle button with configurable accessible labels.
"use client"
import { FormProvider, useForm } from "react-hook-form"
import { FieldGroup } from "@/components/ui/field"
import { GenericInput } from "@/registry/new-york/ui/generic-input/generic-input"
import { Button } from "@/components/ui/button"
export default function GenericInputPassword() {
const form = useForm<{ password: string }>({
defaultValues: { password: "" },
})
return (
<FormProvider {...form}>
<form
className="mx-auto flex w-full max-w-md flex-col gap-4"
onSubmit={form.handleSubmit(() => undefined)}
>
<FieldGroup>
<GenericInput
name="password"
type="password"
label="Password"
placeholder="Enter a strong password"
description="The built-in toggle swaps between masked and unmasked text while keeping the input registered."
showPasswordLabel="Show password"
hidePasswordLabel="Hide password"
required
/>
</FieldGroup>
<Button type="submit" className="w-full">
Create account
</Button>
</form>
</FormProvider>
)
}With a leading icon
Any React node can be rendered as a leading icon. The component automatically applies ps-10 to the input.
"use client"
import { useForm, FormProvider } from "react-hook-form"
import { Mail } from "lucide-react"
import { GenericInput } from "@/registry/new-york/ui/generic-input/generic-input"
import { Button } from "@/components/ui/button"
type FormValues = {
email: string
}
export default function GenericInputIcon() {
const form = useForm<FormValues>({
defaultValues: {
email: "",
},
})
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(() => undefined)}
className="mx-auto flex w-full max-w-md flex-col gap-4"
>
<GenericInput<FormValues>
name="email"
type="email"
icon={<Mail className="size-4" />}
required
/>
<Button type="submit" className="w-full">
Submit
</Button>
</form>
</FormProvider>
)
}Numeric coercion
By default react-hook-form returns strings from native <input> elements. Pass returnAsNumber to coerce the value to a number via setValueAs. Empty strings and NaN resolve to null.
"use client"
import { FormProvider, useForm } from "react-hook-form"
import { GenericInput } from "@/registry/new-york/ui/generic-input/generic-input"
import { Button } from "@/components/ui/button"
type FormValues = {
age: number | null
}
export default function GenericInputNumeric() {
const form = useForm<FormValues>({
defaultValues: { age: null },
})
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(() => undefined)}
className="mx-auto flex w-full max-w-md flex-col gap-4"
>
<GenericInput<FormValues>
name="age"
type="number"
label="Age"
placeholder="42"
step="1"
min={0}
returnAsNumber
description="With returnAsNumber enabled, empty and invalid values resolve to null instead of an empty string."
/>
<Button type="submit" className="w-full">
Submit
</Button>
</form>
</FormProvider>
)
}| Raw input value | returnAsNumber value |
|---|---|
"42" | 42 |
"" | null |
" " | null |
"abc" | null |
With Zod validation
"use client"
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"
import { FormProvider, useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { FieldGroup } from "@/components/ui/field"
import { GenericInput } from "@/registry/new-york/ui/generic-input/generic-input"
const schema = z.object({
email: z.email("Please enter a valid email."),
password: z.string().min(8, "Must be at least 8 characters."),
})
type FormValues = z.infer<typeof schema>
export default function GenericInputZod() {
const form = useForm<FormValues>({
resolver: standardSchemaResolver(schema),
defaultValues: { email: "", password: "" },
})
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(console.log)}
className="mx-auto flex w-full max-w-md flex-col gap-4"
>
<FieldGroup>
<GenericInput<FormValues> name="email" type="email" required />
<GenericInput<FormValues> name="password" type="password" required />
<Button type="submit" className="w-full">
Sign in
</Button>
</FieldGroup>
</form>
</FormProvider>
)
}Errors from the resolver are read via useFormContext().getFieldState(name, formState) and rendered with <FieldError /> automatically — no manual error wiring required.
Nested and array field paths
name is typed as Path<TFieldValues>, so deeply nested paths and array indices are autocompleted and type-checked.
type FormValues = {
user: { profile: { firstName: string } }
items: { sku: string; quantity: number }[]
}
<GenericInput<FormValues> name="user.profile.firstName" required />
<GenericInput<FormValues> name="items.0.sku" label="SKU" required />
<GenericInput<FormValues> name="items.0.quantity" type="number" returnAsNumber required />Forwarding a ref
GenericInput merges any externally-supplied ref with react-hook-form's internal register ref using a mergeRefs helper.
const inputRef = React.useRef<HTMLInputElement>(null)
<GenericInput<FormValues> ref={inputRef} name="email" />
// Later:
inputRef.current?.focus()API Reference
GenericInput extends every native <input> prop except name, onChange,
and onBlur, which are managed via react-hook-form's register(). The
forwarded versions of onChange / onBlur keep the same register semantics.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
name | Path<TFieldValues> | - | Required. Type-safe field path within the surrounding form. |
label | React.ReactNode | derived from name | Field label. Falls back to a humanized version of name. |
description | React.ReactNode | undefined | Helper text rendered via |
required | boolean | false | Marks the field as required, renders a red |
optionalLabel | React.ReactNode | null | null | Label appended for non-required fields. Pass null or "" to suppress. |
disabled | boolean | false | Visually disables the input but keeps its value in form state and on submit. |
returnAsNumber | boolean | false | Coerces the value to number via setValueAs. Empty / non-numeric -> null. |
icon | React.ReactNode | undefined | Leading icon node. Padding (ps-10) is applied automatically. |
type | React.HTMLInputTypeAttribute | "text" | Native input type. Setting "password" enables the visibility toggle. |
showPasswordLabel | string | "Show password" | aria-label for the toggle when the password is hidden. |
hidePasswordLabel | string | "Hide password" | aria-label for the toggle when the password is visible. |
id | string | name | Override the input's id. Defaults to the field name. |
className | string | undefined | Merged onto the underlying <Input /> via cn. |
onChange | React.ChangeEventHandler<HTMLInputElement> | undefined | Forwarded to react-hook-form's register({ onChange }). |
onBlur | React.FocusEventHandler<HTMLInputElement> | undefined | Forwarded to react-hook-form's register({ onBlur }). |
ref | React.Ref<HTMLInputElement> | undefined | Merged with react-hook-form's internal ref via an internal mergeRefs helper. |
...inputProps | Native <input> attributes | - | Anything else accepted by |
Prop
Type
In addition, any other native <input> prop (e.g. placeholder, autoComplete, inputMode,
min, max, step, pattern) is forwarded to the underlying <Input> element.
Data attributes
These attributes are set on the wrapping <Field> and can be targeted from CSS.
| Attribute | Value | Description |
|---|---|---|
[data-disabled] | true | absent | Present when the input is disabled. |
[data-invalid] | true | absent | Present when the field has a validation error. |
Styling
GenericInput is unstyled beyond what shadcn/ui's Field and Input provide.
Customize through any of these layers:
1. The className prop
className is merged via cn() onto the underlying <Input>. Use this to
tweak per-instance styling:
<GenericInput<Values> name="email" className="font-mono tracking-wide" />2. Tailwind on the surrounding markup
Wrap GenericInput in a layout container — Field already manages its own
internal vertical rhythm, so prefer space-y-* / grid on the parent rather than
overriding the field internals.
<div className="grid gap-4 md:grid-cols-2">
<GenericInput<Values> name="firstName" />
<GenericInput<Values> name="lastName" />
</div>3. Theming via shadcn/ui tokens
Colors are sourced from your shadcn/ui theme (--destructive,
--muted-foreground, --foreground, …). Update your CSS variables and the
component re-themes automatically — no overrides required.
4. Data-attribute selectors
Style invalid or disabled states centrally without prop drilling:
[data-slot="field"][data-invalid] [data-slot="input"] {
box-shadow: 0 0 0 2px var(--destructive);
}5. RTL support
Padding for the leading icon and password toggle uses logical properties (ps-10, pe-10, inset-s-3, inset-e-3) and works correctly in both LTR and RTL layouts when dir="rtl" is set on a parent.
Logical inset utilities (inset-s-*, inset-e-*) require Tailwind CSS v4. If you're on v3,
replace them with left-*/right-* after copying the source.
6. Forking the component
Because the component lives in your own codebase (it's a registry component, not a package), you can fork it freely — change the password icons, swap the required marker, add a trailing slot, etc.
Accessibility
Built on shadcn's <Field /> primitives, which follow the WAI-ARIA form-field authoring practices.
- Label association. The
<FieldLabel>is wired to the input viahtmlFor={id}. Clicking the label focuses the input. - Required state. When
requiredistrue, the input receivesaria-required="true"and the visual asterisk is exposed to assistive tech asaria-label="required". - Optional state. When
optionalLabelis rendered, it is exposed asaria-label="optional". - Description and error. When
descriptionand/or a validation error are present, both ids are joined intoaria-describedby, so screen readers announce them after the label. - Validation state. On error, the input receives
aria-invalid="true"and the parent<Field>getsdata-invalidfor styling hooks. - Password toggle. A real
<button type="button">witharia-labelreflecting the current state,aria-pressedreflecting the toggled state, andaria-controlspointing at the input id. It is disabled in lockstep with the input.
Keyboard interactions
| Key | Description |
|---|---|
Tab | Moves focus into the input, then to the password toggle (when present). |
Shift + Tab | Moves focus in reverse. |
Enter | When focused on the password toggle, toggles visibility. Inside the input, submits the form. |
Space | When focused on the password toggle, toggles visibility. |
Notes
Disabled state behavior
The disabled prop on GenericInput works differently from react-hook-form's native disabled option:
| Approach | HTML disabled | Value in formState | Included on submit |
|---|---|---|---|
GenericInput disabled | ✅ | ✅ Preserved | ✅ Yes |
RHF register({ disabled }) | ✅ | ❌ Set to undefined | ❌ No |
This is intentional — it allows you to visually disable inputs (e.g., "coming soon" fields) while preserving their values for pre-filled data or hidden defaults.