Input

A text field with its label, help, error, prefix and suffix slots and a clear button, wired for screen readers.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/input

Once per app, add the registry to components.json:

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

Adds components/pixilui/input.tsx. Needs motion, clsx, tailwind-merge.

Preview#

Size
State
Theme
Accent

Esc clears the field and hands focus straight back, and an error rises into place instead of popping.

Code#

components/pixilui/input.tsx
"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.
Example
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#

PropTypeDefaultDescription
classNamestringClasses for the outer wrapper (label, field and messages). Native attributes and the ref go to the input.
labelReactNodeVisible label. Use aria-label instead only when the surrounding UI already names the field.
descriptionReactNodeHelp under the field, linked with aria-describedby.
errorReactNodeError message. Marks the field invalid, replaces nothing, and is announced politely.
prefixReactNodeContent before the value: an icon, a currency sign, a URL scheme.
suffixReactNodeContent after the value: a unit, a shortcut hint, a status.
size"sm" | "md" | "lg""md"Control height: sm is 36px, md 44px, lg 50px.
clearablebooleanfalseShows a clear button while there is a value. Esc clears too.
onClear() => voidRuns after the clear button or Esc empties the field. onChange also fires with an empty value.
clearLabelstring"Clear"Accessible name of the clear button.
clearIconReactNodeIcon 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
  1. Native attributes (name, type, required, autoComplete) and the ref go to the input element; className goes to the wrapper.
  2. Pass error as a string after validation; do not also change the label colour.
  3. Use prefix and suffix for scheme, currency and units instead of putting them in the placeholder.