Input

A single-line text field with size variants and native validation styling.

Installation

Terminal
pnpm dlx @dowel-ui/cli add input

npm packages installed: class-variance-authority.

Accessibility

Always pair with a Label via htmlFor/id. Error state is driven by aria-invalid so assistive technology and styling stay in sync; describe the error with aria-describedby. FloatingLabelInput keeps a real <label for> as the accessible name whether resting or floated, and floats it with CSS alone.

Props

Input

PropTypeDefault
inputSize"sm" | "md" | "lg""md"

Plus every attribute of <input>.

Quality

8/8 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Used in

Whole screens assembled from this component. Installing one brings this and everything else it needs with it.

Source

This is exactly what dowel add input writes into your project, with imports rewritten to your own path alias.

ui/input.tsx
import { cva, type VariantProps } from "class-variance-authority";
import type { ComponentPropsWithRef } from "react";

import { focusRingInset, invalidStyles } from "@/lib/styles";
import { cn } from "@/lib/utils";

const inputVariants = cva(
  cn(
    "flex w-full min-w-0 rounded-md border border-input bg-background text-foreground shadow-xs",
    "transition-[border-color,box-shadow] duration-[var(--duration-fast)] ease-[var(--ease-out-quint)]",
    "placeholder:text-muted-foreground",
    "file:me-3 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground",
    "focus-visible:border-ring",
    "disabled:cursor-not-allowed disabled:opacity-55",
    focusRingInset,
    invalidStyles,
  ),
  {
    variants: {
      inputSize: {
        sm: "h-8 px-2.5 text-sm",
        md: "h-9 px-3 text-sm",
        lg: "h-10 px-3.5 text-base",
      },
    },
    defaultVariants: {
      inputSize: "md",
    },
  },
);

export interface InputProps
  extends ComponentPropsWithRef<"input">, VariantProps<typeof inputVariants> {}

/**
 * Single-line text field.
 *
 * The visual size prop is named `inputSize` rather than `size` because `size`
 * is a native `<input>` attribute with unrelated semantics (a character-count
 * width hint). Naming it apart keeps both available instead of shadowing one.
 *
 * Error styling is driven by the native `aria-invalid` attribute rather than a
 * bespoke prop, so form libraries wire it up without an adapter.
 */
export function Input({ className, inputSize, type = "text", ...props }: InputProps) {
  return (
    <input type={type} className={cn(inputVariants({ inputSize }), className)} {...props} />
  );
}

export { inputVariants };
ui/floating-label-input.tsx
"use client";

// Motion from SmoothUI AnimatedInput (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { cva } from "class-variance-authority";
import { useId, type ReactNode } from "react";

import { cn } from "@/lib/utils";

import { Input, type InputProps } from "./input";

/*
 * An Input whose label rests inside the field and floats up onto the border
 * when the field is focused or holds a value.
 *
 * The source drives the label from React state and a JS tween. Here it is CSS
 * alone: the input carries a placeholder (a single space when none is given),
 * so `:placeholder-shown` is true exactly when the field is empty, and the
 * label is a `peer-` of the input. That covers typing, controlled values set
 * from outside, form resets and browser autofill without an effect in sight,
 * and the global reduced-motion rule turns the float into a jump.
 *
 * The label is a real <label for>, never a placeholder standing in for one:
 * it is the accessible name at rest and while floated alike.
 */

const floatingLabelVariants = cva(
  cn(
    "pointer-events-none absolute top-1/2 max-w-[calc(100%-1rem)] -translate-y-1/2 truncate",
    "rounded-sm bg-background px-1 leading-none text-muted-foreground select-none",
    // Grows from its inline-start edge, so it stays put against the border in RTL.
    "origin-[0_50%] rtl:origin-[100%_50%]",
    "transition-[top,scale,color] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]",
    // Floated: focused, holding a value, or autofilled.
    "peer-focus:top-0 peer-focus:scale-85",
    "peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:scale-85",
    "peer-autofill:top-0 peer-autofill:scale-85",
    "peer-[:focus:not([aria-invalid=true])]:text-primary",
    "peer-aria-invalid:text-destructive",
    "peer-disabled:opacity-55",
  ),
  {
    variants: {
      inputSize: {
        sm: "start-1.5 text-sm",
        md: "start-2 text-sm",
        lg: "start-2.5 text-base",
      },
    },
    defaultVariants: {
      inputSize: "md",
    },
  },
);

export interface FloatingLabelInputProps extends InputProps {
  /** The field's label. Rendered as a real `<label>` pointing at the input. */
  label: ReactNode;
  /** Classes for the positioned wrapper. `className` goes to the input, as on Input. */
  containerClassName?: string;
  /** Classes for the label. */
  labelClassName?: string;
}

/**
 * Input with a floating label.
 *
 * Every prop but the three above is forwarded to the `<input>`, including `id`,
 * `ref` and ARIA attributes — so it drops into `FormControl`, which injects the
 * field's id, `aria-describedby` and `aria-invalid` into its child, and the
 * label follows the id it is given.
 */
export function FloatingLabelInput({
  label,
  containerClassName,
  labelClassName,
  inputSize,
  id,
  placeholder,
  className,
  ...props
}: FloatingLabelInputProps) {
  const generatedId = useId();
  const inputId = id ?? generatedId;

  return (
    <div data-slot="floating-label-input" className={cn("relative", containerClassName)}>
      <Input
        id={inputId}
        inputSize={inputSize}
        // A space keeps :placeholder-shown meaningful when there is no hint;
        // a real hint only shows once the label has moved out of its way.
        placeholder={placeholder ?? " "}
        className={cn(
          "peer placeholder:text-transparent focus:placeholder:text-muted-foreground",
          className,
        )}
        {...props}
      />
      <label
        htmlFor={inputId}
        data-slot="floating-label-input-label"
        className={cn(floatingLabelVariants({ inputSize }), labelClassName)}
      >
        {label}
      </label>
    </div>
  );
}

export { floatingLabelVariants };