Skip to content
Drivn
7 min read

React Input Component Examples

Drop-in React Input examples: text, email, password, search with icon, labeled field, error state, and react-hook-form integration. Zero runtime deps.

Text inputs carry more of a React app than any other control — the email field on sign-in, the search box above a Sidebar, the password field in settings, the quantity box beside a stepper. Drivn's Input is about twenty-five lines: a React.forwardRef wrapper around the native <input>, typed as React.InputHTMLAttributes<HTMLInputElement>, with a single styles.base string doing all the visual work. That string sets w-full h-10 px-4, a rounded-[10px] border on border-input, text-sm with a muted placeholder, a focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring focus treatment, and disabled:opacity-50 disabled:cursor-default. Zero runtime UI dependencies.

Because the surface stays that small, several patterns you might expect as props are wrappers instead. Leading icons, error states, and label pairings all compose around the Input rather than configuring it. That is deliberate — the field wrapper is where Label, helper text, and inline validation already have to live, so moving leftIcon or error onto the input would duplicate a layer you are writing anyway.

Every snippet below imports from @/components/ui/input, the path the Drivn CLI installs to, so each one compiles the moment npx drivn add input finishes. The examples run from the plain text field through the common type variations, a labeled field with helper text, the aria-invalid error pattern, a search field with a leading Lucide icon, and a full react-hook-form field validated with zod. All TypeScript — Drivn ships TypeScript-only.

Basic text input

The minimum viable Input is a type and a placeholder; leave type off entirely and the browser assumes text. Everything else native comes along for free — name, id, required, autoComplete, inputMode, value, defaultValue, onChange, onBlur — because the prop type is exactly React.InputHTMLAttributes<HTMLInputElement> with nothing filtered out. Visually you get a forty-pixel-tall field, a ten-pixel radius, sixteen pixels of horizontal padding, a muted placeholder drawn from the Drivn surface tokens, and a three-pixel ring-ring/50 focus ring bound to focus-visible rather than focus, so it appears for keyboard focus and stays quiet on a mouse click.

Ref forwarding is the other half. React.forwardRef puts the ref on the underlying <input>, so useRef gives you imperative .focus() and .select(), and register from a form library lands its ref exactly where it expects to. Controlled fields take value and onChange; uncontrolled fields take defaultValue and read back through the ref or the surrounding form's FormData. Nothing about the wrapper changes how either mode behaves, so existing form code drops in unchanged.

1import { Input } from "@/components/ui/input"
2
3export default function Page() {
4 return (
5 <Input
6 type="text"
7 name="fullName"
8 placeholder="Enter your name"
9 />
10 )
11}

Labeled field with helper text

Every production form pairs an input with a label and, usually, a helper paragraph. The pattern is <label> over <input> over <p> — the label provides the accessible name, the input captures the value, the helper paragraph explains the format or shows a validation error. Use Drivn's Label component for the label so the font and color match the rest of the form rhythm, set htmlFor to the input's id, and put the helper paragraph at text-xs text-muted-foreground for the muted style or text-destructive for an error.

The id on the input and the htmlFor on the label do the accessibility work — screen readers announce the label when the input is focused, and clicking the label focuses the input. For a generated id, use React's useId hook so the id stays stable across re-renders without colliding with other fields on the page.

1import { Input } from "@/components/ui/input"
2import { Label } from "@/components/ui/label"
3import { useId } from "react"
4
5export default function Page() {
6 const id = useId()
7 return (
8 <div className="space-y-2">
9 <Label htmlFor={id}>Email address</Label>
10 <Input
11 id={id}
12 type="email"
13 placeholder="you@example.com"
14 autoComplete="email"
15 />
16 <p className="text-xs text-muted-foreground">
17 We&apos;ll send a magic link to this address.
18 </p>
19 </div>
20 )
21}

Inline error state with aria-invalid

The Drivn Input has no built-in error variant — error visuals live at the field-wrapper level. The pattern is to set aria-invalid={true} on the input when an error string is present and add aria-[invalid=true]:border-destructive to styles.base after install (one-line edit), then render a destructive paragraph under the input with the message. The aria-invalid attribute is also the right accessibility primitive: screen readers announce the field as invalid, and assistive tech links the helper paragraph through aria-describedby if you set it.

The field stays composable: a Label on top, the Input with aria-invalid, an aria-describedby reference, and a destructive paragraph underneath. The same shape works whether the error string comes from a synchronous validator, a server response, or react-hook-form's formState.errors. See Drivn vs shadcn Input for how shadcn handles the same pattern with a built-in aria-invalid style.

1import { Input } from "@/components/ui/input"
2import { Label } from "@/components/ui/label"
3import { useId, useState } from "react"
4
5export default function Page() {
6 const id = useId()
7 const errorId = `${id}-error`
8 const [email, setEmail] = useState("")
9 const error = email && !email.includes("@") ? "Enter a valid email" : null
10
11 return (
12 <div className="space-y-2">
13 <Label htmlFor={id}>Email</Label>
14 <Input
15 id={id}
16 type="email"
17 value={email}
18 onChange={(e) => setEmail(e.target.value)}
19 aria-invalid={!!error}
20 aria-describedby={error ? errorId : undefined}
21 className={error ? "border-destructive" : undefined}
22 />
23 {error && (
24 <p id={errorId} className="text-xs text-destructive">
25 {error}
26 </p>
27 )}
28 </div>
29 )
30}

Search input with a leading icon

A search bar usually shows a magnifying-glass icon on the left edge of the input. Drivn's Input has no leftIcon prop — the recommended pattern is a relative wrapper that absolutely positions a Lucide icon inside the input's padding area, then bumps the input's pl-10 so the text starts past the icon. The wrapper lives in your form code rather than on the Input itself, which keeps the Input file at its minimal size and lets you reuse the same wrapper for trailing icons, clear buttons, or character-count badges.

The icon goes inside the wrapper at absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none. The pointer-events-none rule matters: without it, clicking the icon swallows the click that would otherwise focus the input. For a clear button on the right, swap to right-2, drop the pointer-events-none, and wire an onClick that resets the input value. Pair this with the Command component when the search drives a results list.

1import { Input } from "@/components/ui/input"
2import { Search } from "lucide-react"
3
4export default function Page() {
5 return (
6 <div className="relative w-full max-w-sm">
7 <Search
8 className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
9 />
10 <Input
11 type="search"
12 placeholder="Search components..."
13 className="pl-10"
14 />
15 </div>
16 )
17}

react-hook-form + zod validation

For real forms, wire the Input to react-hook-form and zod. Define a zod schema with the field types and constraints, pass it through zodResolver into useForm, and spread register("fieldName") onto the Input. The register function returns name, onChange, onBlur, and ref props — every one of those works on Drivn's Input because it forwards ref through React.forwardRef and accepts every native input attribute.

The error message comes from formState.errors[fieldName]?.message. Pair it with the aria-invalid pattern from the previous section to get a fully accessible field with synchronous validation, ref forwarding, and a destructive helper paragraph in roughly twenty lines of JSX. The same pattern scales to multi-field forms — pair Input with Textarea, Select, and Checkbox and submit the whole form through handleSubmit.

1"use client"
2import { Input } from "@/components/ui/input"
3import { Label } from "@/components/ui/label"
4import { Button } from "@/components/ui/button"
5import { useForm } from "react-hook-form"
6import { zodResolver } from "@hookform/resolvers/zod"
7import { z } from "zod"
8
9const schema = z.object({
10 email: z.string().email("Enter a valid email"),
11 password: z.string().min(8, "At least 8 characters"),
12})
13
14type FormValues = z.infer<typeof schema>
15
16export default function SignInForm() {
17 const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
18 resolver: zodResolver(schema),
19 })
20
21 return (
22 <form onSubmit={handleSubmit((data) => console.log(data))} className="space-y-4">
23 <div className="space-y-2">
24 <Label htmlFor="email">Email</Label>
25 <Input
26 id="email"
27 type="email"
28 autoComplete="email"
29 aria-invalid={!!errors.email}
30 {...register("email")}
31 />
32 {errors.email && (
33 <p className="text-xs text-destructive">{errors.email.message}</p>
34 )}
35 </div>
36 <div className="space-y-2">
37 <Label htmlFor="password">Password</Label>
38 <Input
39 id="password"
40 type="password"
41 autoComplete="current-password"
42 aria-invalid={!!errors.password}
43 {...register("password")}
44 />
45 {errors.password && (
46 <p className="text-xs text-destructive">{errors.password.message}</p>
47 )}
48 </div>
49 <Button type="submit">Sign in</Button>
50 </form>
51 )
52}
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

For a controlled input, pass value and onChange — the parent component holds the state in useState and the input mirrors it on every keystroke. For an uncontrolled input, pass defaultValue (optional) and read the current value either through a ref attached via useRef and inputRef.current.value, or from the FormData of the surrounding <form> on submit. Both patterns work because the Drivn Input forwards ref through React.forwardRef and passes every native attribute through React.InputHTMLAttributes<HTMLInputElement>.

Not by default — the Input registry source has no icon prop, intentionally. The component file is about 25 lines and keeps a single styles.base className. The recommended pattern is a relative wrapper around the Input with an absolutely positioned Lucide icon inside the padding area, plus pl-10 or pr-10 on the Input to push the text past the icon. If your project uses this pattern often, edit the local @/components/ui/input file after install to accept a leftIcon prop — the file lives in your repo, so the change is yours to make and own.

The styles.base string drops the browser default with focus:outline-none, then reapplies a deliberate one: focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring, eased through transition-[color,box-shadow]. Binding to focus-visible instead of focus means the ring shows for keyboard and assistive-tech focus but stays quiet when the field is clicked, which keeps dense forms calm without costing keyboard users the affordance. Because the file lives in your repo after install, changing the ring width, color, or trigger is a one-line edit to styles.base.

Yes. register("fieldName") from useForm returns name, onChange, onBlur, and ref, and the Drivn Input forwards every one of those because it uses React.forwardRef and accepts the full React.InputHTMLAttributes<HTMLInputElement> shape. Spread {...register("fieldName")} onto the Input and the form library wires controlled state, validation, and ref handling for you. Pair with zodResolver from @hookform/resolvers/zod for schema validation — the react-hook-form page walks through the full pattern.

Yes — the Input file has no "use client" directive because it uses no client-only APIs. There is no useState, no useEffect, no event handler attached inside the component. You can render the Input inside a Server Component to capture form data through a Server Action, or import it into a Client Component when you need controlled state or react-hook-form integration. Next.js draws the client boundary based on the consumer file, so the same Input file works in both rendering modes without configuration.