Components

Getting started

Components144

More

A one-line field with a label, hint and error that trade places, a clear button that folds in, and one shake when an error arrives.

Used forSign-up and settings formsFilters above a tableInline creation rowsDomain and handle fields

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/input.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Sign-up and settings forms

    Name, email and URL fields with hints that turn into errors in place.

  • Filters above a table

    A clearable field that empties with one press.

  • Inline creation rows

    A compact 28px field inside a toolbar or list row.

  • Domain and handle fields

    Leading and trailing text such as https:// or .com.

What it handles for you

The usual input pushes an error in under itself, moves the layout, and gives no signal when you look away. This one folds the message in, keeps the text still and shakes once.

Instead of wiring uplabel + hint + error wiringaria-describedby plumbingclear buttonerror shake
  1. Hint and error in one line

    The hint folds away as the error rises out of a blur, and back when it is fixed.

  2. A clear button that makes room

    It opens its own width as you type, so the text never jumps under it.

  3. Accessible by default

    Label, aria-invalid and aria-describedby are wired to the right ids.

  4. Works with any form library

    Controlled or uncontrolled; onChange and onValueChange both fire, even from the clear button.

Overview

Most inputs swap their hint for an error with a jump and leave the clear button popping in over the text. Here the hairline darkens on hover and turns to the ring on focus, the clear button opens its own width so the text never moves, and an error lifts in where the hint was while the field gives one short shake.

States

rest
A hairline pill, 36px or 28px tall; hover darkens the line.
focus
The line turns to the ring with a 3px halo (200ms).
typing
With clearable, a clear button opens its width as the first letter lands (320ms) and folds away when the field is empty.
error
The line turns red, the hint folds away while the error rises from a 4px blur (320ms), and the field shakes once (380ms).

Usage

usage.tsx
import { Input } from "@/components/ui/input";<Input label="Email" type="email" placeholder="you@fernhill.co" hint="We'll send the receipt here." /><Input label="Workspace" error={error} clearable />

Props

PropTypeDescription
labelReactNodeShown above; becomes the accessible name.
hintReactNodeLine under the field while there is no error.
errorReactNodeTurns the field red and takes the hint's place.
leading / trailingReactNodeIcon or text before and after the value.
clearablebooleandefault falseA clear button once there is text.
size"sm" | "md"default "md"28px or 36px tall.
onValueChange(value: string) => voidThe new text on every change.
...input propsInputHTMLAttributesEverything a native input takes, including ref.

Accessibility and motion

  • Shares field.tsx with every text control: one surface, one focus ring, one way of showing hints and errors.
  • Reduced motion: the hint and error change in place and nothing shakes.
  • The clear button hands focus back to the field.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/input.tsx
"use client";import * as React from "react";import { Field, fieldSurface, setNativeValue, useFieldIds, useShake, type FieldSize } from "./field";import { EASE_OUT, INTERACTION_FOCUS, interactionIcon, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * INPUT: a one-line field * *   rest     a hairline pill; hover darkens the line *   focus    the line turns to the ring with a 3px halo *   typing   a clear button folds in once there is text and *            folds away when it's gone (it hands focus back) *   error    the line turns red, the hint gives way to the *            error and the field shakes once * ───────────────────────────────────────────────────────── */export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {  label?: React.ReactNode;  hint?: React.ReactNode;  /** Shows red and replaces the hint. */  error?: React.ReactNode;  /** An icon or text before the value. */  leading?: React.ReactNode;  /** An icon, text or button after the value. */  trailing?: React.ReactNode;  /** A button that empties the field once it has text. */  clearable?: boolean;  /** 28px or 36px tall. */  size?: FieldSize;  /** Called with the new text on every change. */  onValueChange?: (value: string) => void;  ref?: React.Ref<HTMLInputElement>;  /** Class for the outer column. */  className?: string;  /** Class for the input element itself. */  inputClassName?: string;}function ClearIcon() {  return (    <svg viewBox="0 0 16 16" fill="none" className="size-3.5">      <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />    </svg>  );}export function Input({  label,  hint,  error,  leading,  trailing,  clearable,  size = "md",  onValueChange,  ref,  id,  className = "",  inputClassName = "",  value,  defaultValue,  onChange,  disabled,  required,  ...props}: InputProps) {  const reduced = useInteractionReducedMotion();  const ids = useFieldIds(id);  const inner = React.useRef<HTMLInputElement>(null);  const [text, setText] = React.useState(String(defaultValue ?? ""));  const current = value !== undefined ? String(value) : text;  const shakeRef = useShake<HTMLDivElement>(!!error);  const sm = size === "sm";  const showClear = !!clearable && current.length > 0 && !disabled;  const setRefs = (el: HTMLInputElement | null) => {    inner.current = el;    if (typeof ref === "function") ref(el);    else if (ref) (ref as React.RefObject<HTMLInputElement | null>).current = el;  };  const describedBy = [hint && !error ? ids.hint : null, error ? ids.error : null].filter(Boolean).join(" ") || undefined;  return (    <Field id={ids.id} label={label} hint={hint} error={error} required={required} className={className}>      <div ref={shakeRef} className={`${fieldSurface({ invalid: !!error, disabled })} ${sm ? "h-7 text-[12.5px]" : "h-9 text-[13.5px]"}`}>        {leading && <span className={`flex shrink-0 items-center text-muted-foreground ${sm ? "pl-2.5" : "pl-3.5"} [&_svg]:size-3.5`}>{leading}</span>}        <input          ref={setRefs}          id={ids.id}          value={value}          defaultValue={defaultValue}          disabled={disabled}          required={required}          aria-invalid={error ? true : undefined}          aria-describedby={describedBy}          onChange={(e) => {            if (value === undefined) setText(e.target.value);            onChange?.(e);            onValueChange?.(e.target.value);          }}          className={`h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground/70 ${leading ? "pl-2" : sm ? "pl-3" : "pl-4"} ${trailing || clearable ? "pr-1" : sm ? "pr-3" : "pr-4"} ${inputClassName}`}          {...props}        />        {clearable && (          // The clear button folds its own width open, so the text never jumps.          <span            className="grid shrink-0"            style={{ gridTemplateColumns: showClear ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-columns 320ms ${EASE_OUT}` }}          >            <span className="min-w-0 overflow-hidden">              <button                type="button"                tabIndex={showClear ? 0 : -1}                aria-hidden={!showClear}                aria-label="Clear"                onClick={() => {                  if (!inner.current) return;                  setNativeValue(inner.current, "");                  inner.current.focus();                }}                className={`mr-1 flex items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${sm ? "size-5" : "size-7"} ${INTERACTION_FOCUS}`}                style={interactionIcon(showClear, reduced)}              >                <ClearIcon />              </button>            </span>          </span>        )}        {trailing && <span className={`flex shrink-0 items-center text-muted-foreground ${sm ? "pr-1" : "pr-1.5"} [&_svg]:size-3.5`}>{trailing}</span>}      </div>    </Field>  );}export default Input;
components/field.tsx
"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * FIELD: the column every text control sits in * *   label    above, with an optional aside on the right (a count) *   hint     under the control while all is well *   error    takes the hint's place: the hint folds away, the *            error rises in out of a blur, the control shakes once * * Shared by input, textarea, password, search and select so the * whole family has one surface, one focus ring and one error. * ───────────────────────────────────────────────────────── */export interface FieldProps {  id: string;  label?: React.ReactNode;  /** Right side of the label row, e.g. a character count. */  aside?: React.ReactNode;  hint?: React.ReactNode;  error?: React.ReactNode;  required?: boolean;  className?: string;  children: React.ReactNode;}export type FieldSize = "sm" | "md";/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */export function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {  const ring = invalid    ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]"    : "shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]";  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? "rounded-full" : "rounded-[14px]"} ${ring} ${    disabled ? "pointer-events-none opacity-50" : ""  }`;}/** ids for the control's label, hint and error. */export function useFieldIds(id?: string) {  const auto = React.useId();  const base = id ?? auto;  return { id: base, hint: `${base}-hint`, error: `${base}-error` };}/** One short shake whenever `on` turns true (an error arrives). */export function useShake<T extends HTMLElement>(on: boolean) {  const ref = React.useRef<T>(null);  const reduced = useInteractionReducedMotion();  React.useEffect(() => {    if (!on || reduced) return;    ref.current?.animate(      [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }],      { duration: 380, easing: "ease-out" },    );  }, [on, reduced]);  return ref;}/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */export function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;  Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value);  el.dispatchEvent(new Event("input", { bubbles: true }));}/** A line under the control that folds open and closed; keeps its last words while closing. */function Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {  const [kept, setKept] = React.useState(children);  if (show && children !== kept) setKept(children);  return (    <div      className="grid"      style={{ gridTemplateRows: show ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE_OUT}` }}    >      <div className="min-h-0 overflow-hidden">        <p          id={id}          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}          style={{            opacity: show ? 1 : 0,            filter: show ? "blur(0px)" : "blur(4px)",            transform: show ? "none" : "translateY(-4px)",            transition: reduced ? "none" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,          }}        >          {show ? children : kept}        </p>      </div>    </div>  );}export function Field({ id, label, aside, hint, error, required, className = "", children }: FieldProps) {  const reduced = useInteractionReducedMotion();  const ids = { hint: `${id}-hint`, error: `${id}-error` };  return (    <div className={`flex w-full flex-col ${className}`}>      {(label || aside) && (        <div className="flex items-baseline justify-between gap-3 px-3 pb-1.5">          {label && (            <label htmlFor={id} className="text-[13px] font-medium text-foreground">              {label}              {required && (                <span aria-hidden="true" className="ml-0.5 text-muted-foreground">                  *                </span>              )}            </label>          )}          {aside && <span className="text-[12px] tabular-nums text-muted-foreground">{aside}</span>}        </div>      )}      {children}      <Line show={!error && !!hint} id={ids.hint} tone="text-muted-foreground" reduced={reduced}>        {hint}      </Line>      <Line show={!!error} id={ids.error} tone="text-red-600 dark:text-red-400" reduced={reduced}>        {error}      </Line>    </div>  );}export default Field;

Registry item: /r/input.json