Input
A text field with its label, help, error, prefix and suffix slots and a clear button, wired for screen readers.
Install#
npx shadcn@latest add @pixilui/inputOnce per app, add the registry to components.json:
{
"registries": {
"@pixilui": "https://pixilui.com/r/{name}.json"
}
}Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/input.json
- 1
Install the npm packages.
npm install motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"use client"; import { forwardRef, useCallback, useId, useRef, useState, type ChangeEvent, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "./lib/utils"; import { motionTokens, reducedTransition } from "./motion-tokens"; export type InputProps = Omit<ComponentPropsWithoutRef<"input">, "size" | "prefix"> & { /** Visible label. Use aria-label instead only when the surrounding UI already names the field. */ label?: ReactNode; /** Help under the field, linked with aria-describedby. */ description?: ReactNode; /** Error message. Marks the field invalid, replaces nothing, and is announced politely. */ error?: ReactNode; /** Content before the value: an icon, a currency sign, a URL scheme. */ prefix?: ReactNode; /** Content after the value: a unit, a shortcut hint, a status. */ suffix?: ReactNode; /** Control height: sm is 36px, md 44px, lg 50px. */ size?: "sm" | "md" | "lg"; /** Shows a clear button while there is a value. Esc clears too. */ clearable?: boolean; /** Runs after the clear button or Esc empties the field. onChange also fires with an empty value. */ onClear?: () => void; /** Accessible name of the clear button. */ clearLabel?: string; /** Icon for the clear button. Defaults to a drawn cross. */ clearIcon?: ReactNode; /** Classes for the outer wrapper (label, field and messages). Native attributes and the ref go to the input. */ className?: string; }; const fieldSizes = { sm: "h-control-sm gap-2 px-3 text-14", md: "h-control-md gap-2 px-3.5 text-16 sm:text-14", lg: "h-control-lg gap-2.5 px-4 text-16", } as const; function InputClearMark() { return ( <span aria-hidden="true" className="relative block size-2.5 before:absolute before:inset-x-0 before:top-1/2 before:h-[1.5px] before:-translate-y-1/2 before:rotate-45 before:rounded-pill before:bg-current after:absolute after:inset-x-0 after:top-1/2 after:h-[1.5px] after:-translate-y-1/2 after:-rotate-45 after:rounded-pill after:bg-current" /> ); } export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( { label, description, error, prefix, suffix, size = "md", clearable = false, onClear, clearLabel = "Clear", clearIcon, className, id, value, defaultValue, onChange, onKeyDown, disabled, readOnly, "aria-describedby": describedBy, "aria-invalid": ariaInvalid, ...rest }, forwardedRef, ) { const reduced = useReducedMotion(); const autoId = useId(); const inputId = id ?? `${autoId}-input`; const descriptionId = `${autoId}-description`; const errorId = `${autoId}-error`; const inputRef = useRef<HTMLInputElement | null>(null); const [innerValue, setInnerValue] = useState(() => String(defaultValue ?? "")); const controlled = value !== undefined; const hasValue = String(controlled ? (value ?? "") : innerValue).length > 0; const invalid = Boolean(error) || ariaInvalid === true || ariaInvalid === "true"; const showClear = clearable && hasValue && !disabled && !readOnly; const setRef = useCallback( (node: HTMLInputElement | null) => { inputRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef], ); const clear = () => { const el = inputRef.current; if (!el) return; // Set the value the way typing does, so React's onChange fires for controlled fields too. Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(el, ""); el.dispatchEvent(new Event("input", { bubbles: true })); el.focus(); onClear?.(); }; const describedByIds = [describedBy, description ? descriptionId : undefined, error ? errorId : undefined].filter(Boolean).join(" ") || undefined; const enter = reduced ? reducedTransition : { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter }; return ( <div data-slot="input" data-invalid={invalid ? "" : undefined} data-disabled={disabled ? "" : undefined} className={cn("flex w-full flex-col gap-1.5 font-body", className)} > {label ? ( <label htmlFor={inputId} className="text-14 font-medium text-foreground"> {label} </label> ) : null} <div data-slot="input-field" data-size={size} className={cn( "flex w-full cursor-text items-center rounded-control bg-surface text-foreground shadow-resting hairline", "transition-[background-color,box-shadow] duration-instant ease-swap has-[input:focus-visible]:focus-ring", "[&_svg]:size-4 [&_svg]:shrink-0", invalid ? "hairline-danger" : "hover-fine:hairline-strong", disabled && "cursor-not-allowed bg-surface-muted opacity-60", fieldSizes[size], )} onPointerDown={(event) => { // Clicking the padding or a prefix focuses the input, like a native field. if (event.target === event.currentTarget || !(event.target as HTMLElement).closest("button,input")) { event.preventDefault(); inputRef.current?.focus(); } }} > {prefix ? <span className="flex shrink-0 items-center text-muted">{prefix}</span> : null} <input ref={setRef} id={inputId} value={value} defaultValue={defaultValue} disabled={disabled} readOnly={readOnly} aria-invalid={invalid || undefined} aria-describedby={describedByIds} className="h-full min-w-0 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted disabled:cursor-not-allowed" onChange={(event: ChangeEvent<HTMLInputElement>) => { if (!controlled) setInnerValue(event.target.value); onChange?.(event); }} onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => { if (event.key === "Escape" && showClear) { event.preventDefault(); event.stopPropagation(); clear(); } onKeyDown?.(event); }} {...rest} /> <AnimatePresence initial={false}> {showClear ? ( <motion.button key="clear" type="button" tabIndex={-1} aria-label={clearLabel} className="-me-1 grid size-6 shrink-0 cursor-pointer place-items-center rounded-pill text-muted outline-none hover-fine:bg-surface-muted hover-fine:text-foreground focus-visible:focus-ring" initial={reduced ? { opacity: 0 } : { opacity: 0, scale: motionTokens.scale.enterFrom }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }} transition={reduced ? reducedTransition : { duration: motionTokens.duration.quick, ease: motionTokens.ease.swap }} onClick={clear} > {clearIcon ?? <InputClearMark />} </motion.button> ) : null} </AnimatePresence> {suffix ? <span className="flex shrink-0 items-center text-muted">{suffix}</span> : null} </div> {description ? ( <p id={descriptionId} className="text-14 text-muted"> {description} </p> ) : null} <div aria-live="polite"> <AnimatePresence initial={false}> {error ? ( <motion.p key="error" id={errorId} className="text-14 text-danger" initial={reduced ? { opacity: 0 } : { opacity: 0, y: -motionTokens.distance.micro, filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }} transition={enter} > {error} </motion.p> ) : null} </AnimatePresence> </div> </div> ); }); - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/input.tsx. Needs motion, clsx, tailwind-merge.
Preview#
Esc clears the field and hands focus straight back, and an error rises into place instead of popping.
Code#
"use client";
import {
forwardRef,
useCallback,
useId,
useRef,
useState,
type ChangeEvent,
type ComponentPropsWithoutRef,
type KeyboardEvent,
type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { motionTokens, reducedTransition } from "./motion-tokens";
export type InputProps = Omit<ComponentPropsWithoutRef<"input">, "size" | "prefix"> & {
/** Visible label. Use aria-label instead only when the surrounding UI already names the field. */
label?: ReactNode;
/** Help under the field, linked with aria-describedby. */
description?: ReactNode;
/** Error message. Marks the field invalid, replaces nothing, and is announced politely. */
error?: ReactNode;
/** Content before the value: an icon, a currency sign, a URL scheme. */
prefix?: ReactNode;
/** Content after the value: a unit, a shortcut hint, a status. */
suffix?: ReactNode;
/** Control height: sm is 36px, md 44px, lg 50px. */
size?: "sm" | "md" | "lg";
/** Shows a clear button while there is a value. Esc clears too. */
clearable?: boolean;
/** Runs after the clear button or Esc empties the field. onChange also fires with an empty value. */
onClear?: () => void;
/** Accessible name of the clear button. */
clearLabel?: string;
/** Icon for the clear button. Defaults to a drawn cross. */
clearIcon?: ReactNode;
/** Classes for the outer wrapper (label, field and messages). Native attributes and the ref go to the input. */
className?: string;
};
const fieldSizes = {
sm: "h-control-sm gap-2 px-3 text-14",
md: "h-control-md gap-2 px-3.5 text-16 sm:text-14",
lg: "h-control-lg gap-2.5 px-4 text-16",
} as const;
function InputClearMark() {
return (
<span
aria-hidden="true"
className="relative block size-2.5 before:absolute before:inset-x-0 before:top-1/2 before:h-[1.5px] before:-translate-y-1/2 before:rotate-45 before:rounded-pill before:bg-current after:absolute after:inset-x-0 after:top-1/2 after:h-[1.5px] after:-translate-y-1/2 after:-rotate-45 after:rounded-pill after:bg-current"
/>
);
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
label,
description,
error,
prefix,
suffix,
size = "md",
clearable = false,
onClear,
clearLabel = "Clear",
clearIcon,
className,
id,
value,
defaultValue,
onChange,
onKeyDown,
disabled,
readOnly,
"aria-describedby": describedBy,
"aria-invalid": ariaInvalid,
...rest
},
forwardedRef,
) {
const reduced = useReducedMotion();
const autoId = useId();
const inputId = id ?? `${autoId}-input`;
const descriptionId = `${autoId}-description`;
const errorId = `${autoId}-error`;
const inputRef = useRef<HTMLInputElement | null>(null);
const [innerValue, setInnerValue] = useState(() => String(defaultValue ?? ""));
const controlled = value !== undefined;
const hasValue = String(controlled ? (value ?? "") : innerValue).length > 0;
const invalid = Boolean(error) || ariaInvalid === true || ariaInvalid === "true";
const showClear = clearable && hasValue && !disabled && !readOnly;
const setRef = useCallback(
(node: HTMLInputElement | null) => {
inputRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
const clear = () => {
const el = inputRef.current;
if (!el) return;
// Set the value the way typing does, so React's onChange fires for controlled fields too.
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(el, "");
el.dispatchEvent(new Event("input", { bubbles: true }));
el.focus();
onClear?.();
};
const describedByIds = [describedBy, description ? descriptionId : undefined, error ? errorId : undefined].filter(Boolean).join(" ") || undefined;
const enter = reduced ? reducedTransition : { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter };
return (
<div
data-slot="input"
data-invalid={invalid ? "" : undefined}
data-disabled={disabled ? "" : undefined}
className={cn("flex w-full flex-col gap-1.5 font-body", className)}
>
{label ? (
<label htmlFor={inputId} className="text-14 font-medium text-foreground">
{label}
</label>
) : null}
<div
data-slot="input-field"
data-size={size}
className={cn(
"flex w-full cursor-text items-center rounded-control bg-surface text-foreground shadow-resting hairline",
"transition-[background-color,box-shadow] duration-instant ease-swap has-[input:focus-visible]:focus-ring",
"[&_svg]:size-4 [&_svg]:shrink-0",
invalid ? "hairline-danger" : "hover-fine:hairline-strong",
disabled && "cursor-not-allowed bg-surface-muted opacity-60",
fieldSizes[size],
)}
onPointerDown={(event) => {
// Clicking the padding or a prefix focuses the input, like a native field.
if (event.target === event.currentTarget || !(event.target as HTMLElement).closest("button,input")) {
event.preventDefault();
inputRef.current?.focus();
}
}}
>
{prefix ? <span className="flex shrink-0 items-center text-muted">{prefix}</span> : null}
<input
ref={setRef}
id={inputId}
value={value}
defaultValue={defaultValue}
disabled={disabled}
readOnly={readOnly}
aria-invalid={invalid || undefined}
aria-describedby={describedByIds}
className="h-full min-w-0 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted disabled:cursor-not-allowed"
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (!controlled) setInnerValue(event.target.value);
onChange?.(event);
}}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape" && showClear) {
event.preventDefault();
event.stopPropagation();
clear();
}
onKeyDown?.(event);
}}
{...rest}
/>
<AnimatePresence initial={false}>
{showClear ? (
<motion.button
key="clear"
type="button"
tabIndex={-1}
aria-label={clearLabel}
className="-me-1 grid size-6 shrink-0 cursor-pointer place-items-center rounded-pill text-muted outline-none hover-fine:bg-surface-muted hover-fine:text-foreground focus-visible:focus-ring"
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: motionTokens.scale.enterFrom }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }}
transition={reduced ? reducedTransition : { duration: motionTokens.duration.quick, ease: motionTokens.ease.swap }}
onClick={clear}
>
{clearIcon ?? <InputClearMark />}
</motion.button>
) : null}
</AnimatePresence>
{suffix ? <span className="flex shrink-0 items-center text-muted">{suffix}</span> : null}
</div>
{description ? (
<p id={descriptionId} className="text-14 text-muted">
{description}
</p>
) : null}
<div aria-live="polite">
<AnimatePresence initial={false}>
{error ? (
<motion.p
key="error"
id={errorId}
className="text-14 text-danger"
initial={reduced ? { opacity: 0 } : { opacity: 0, y: -motionTokens.distance.micro, filter: `blur(${motionTokens.blur.soft}px)` }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }}
transition={enter}
>
{error}
</motion.p>
) : null}
</AnimatePresence>
</div>
</div>
);
});Usage#
Use it when
- Single-line text: names, URLs, amounts, API keys, search inside a panel.
- Fields that need a unit or scheme beside the value (USD, https://, a shortcut hint).
Use something else when
- Several lines of text: use textarea.
- Numbers someone nudges up and down: use number-field.
- Picking from a known list: use select or combobox.
import { useState } from "react";
import { Link2 } from "lucide-react";
import { Input } from "@/components/pixilui/input";
export const knobs = {
size: ["md", "sm", "lg"],
state: ["default", "error", "disabled"],
} as const;
export default function InputDemo({ size = "md", state = "default" }: KnobValues<typeof knobs>) {
const [url, setUrl] = useState("hooks.northwind.co/agent-runs");
const [limit, setLimit] = useState("2,400");
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<Input
label="Webhook URL"
size={size}
prefix={<Link2 strokeWidth={1.5} />}
suffix={<span className="numeric text-12">POST</span>}
description="Called once for every finished run."
error={state === "error" ? "That URL returned 404. Check the path." : undefined}
disabled={state === "disabled"}
clearable
value={url}
onChange={(event) => setUrl(event.target.value)}
type="url"
inputMode="url"
spellCheck={false}
/>
<Input
label="Monthly spend limit"
size={size}
prefix="$"
suffix="USD"
inputMode="decimal"
disabled={state === "disabled"}
value={limit}
onChange={(event) => setLimit(event.target.value)}
/>
</div>
);
}API#
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Classes for the outer wrapper (label, field and messages). Native attributes and the ref go to the input. | |
label | ReactNode | Visible label. Use aria-label instead only when the surrounding UI already names the field. | |
description | ReactNode | Help under the field, linked with aria-describedby. | |
error | ReactNode | Error message. Marks the field invalid, replaces nothing, and is announced politely. | |
prefix | ReactNode | Content before the value: an icon, a currency sign, a URL scheme. | |
suffix | ReactNode | Content after the value: a unit, a shortcut hint, a status. | |
size | "sm" | "md" | "lg" | "md" | Control height: sm is 36px, md 44px, lg 50px. |
clearable | boolean | false | Shows a clear button while there is a value. Esc clears too. |
onClear | () => void | Runs after the clear button or Esc empties the field. onChange also fires with an empty value. | |
clearLabel | string | "Clear" | Accessible name of the clear button. |
clearIcon | ReactNode | Icon for the clear button. Defaults to a drawn cross. |
Also takes every native <input> attribute except size, prefix, and a ref to it.
Keyboard#
- Tab
- Moves focus into the field; the clear button stays out of the tab order.
- Esc
- Clears the value when the field is clearable and has a value; otherwise Esc passes through to the dialog or card around it.
Accessibility#
- The label is a real label element tied to the input with htmlFor.
- Description and error are linked with aria-describedby; an error sets aria-invalid.
- Errors appear inside a polite live region, so they are announced when they arrive.
- The clear button has an accessible name (clearLabel) and returns focus to the input.
Motion#
- The error rises 4px out of a 4px blur on the enter ease in 250ms.
- The clear button fades and settles from 0.96 on the swap ease; it leaves in 120ms.
- Reduced motion: both only fade.
Responsive#
- Fills its container's width.
- The md size uses 16px text on small screens so iOS does not zoom on focus.
Performance#
- Uncontrolled by default; pass value and onChange to control it.
Notes for AI#
Also in the markdown page and llms.txt
- Native attributes (name, type, required, autoComplete) and the ref go to the input element; className goes to the wrapper.
- Pass error as a string after validation; do not also change the label colour.
- Use prefix and suffix for scheme, currency and units instead of putting them in the placeholder.