Form & Input Components: Button, Input, Select, Checkbox

Build a small, production-grade library of accessible form primitives — Button, Input, Select, and Checkbox — with strongly-typed prop APIs, forwardRef, discriminated unions where they clarify intent, and correct ARIA and keyboard behavior.

Form controls are the components every product uses the most and gets wrong the most often: missing labels, inaccessible error states, refs that don't forward to the underlying DOM node so consumers can't call .focus(), and prop APIs that don't compose with native HTML attributes. This lesson builds four foundational components — Button, Input, Select, Checkbox — the way a real internal library ships them: forwardRef everywhere a DOM node needs to be reachable, ComponentPropsWithoutRef to inherit the full native attribute surface instead of re-declaring it, discriminated unions when a prop's shape genuinely depends on another prop, and accessible labeling and error messaging wired in by default rather than left to the consumer.

The shared foundation: extending native props with forwardRef

Every form primitive in a real library should accept the native HTML attributes of the element it wraps — onFocus, onBlur, name, disabled, aria-*, etc. — without the library author having to redeclare each one. React.ComponentPropsWithoutRef<'input'> gives exactly that: the full native prop surface minus ref (which is handled separately because refs aren't part of the regular props flow under forwardRef).

forwardRef matters because consumers routinely need the underlying DOM node — to call .focus() after a validation error, to measure it with getBoundingClientRect, or to integrate with a non-React library. A component that doesn't forward its ref silently breaks every one of those use cases the first time a consumer tries ref={inputRef} and gets undefined.

The pattern used throughout this lesson is: define an OwnProps interface with only the props the component adds (label, error, variant, size), intersect it with ComponentPropsWithoutRef<'element'> for the inherited native surface, and wrap the implementation in forwardRef<HTMLElementType, Props>.

Button: variant, size, and a loading state discriminated union

A Button's own props are small — variant, size, loading, optionally fullWidth — layered on top of every native <button> attribute. The interesting typing decision is the loading state: when loading is true, the button should be disabled and typically shows a spinner in place of (or beside) its label, and it should not fire onClick. Rather than trying to encode that relationship as a comment, a discriminated union on an isIconOnly or similar mutually-exclusive prop set can enforce it at compile time when the component has genuinely alternate shapes (e.g. an icon-only button that requires an aria-label versus a labeled button that doesn't).

This 'icon-only requires aria-label' case is the canonical example of when a discriminated union earns its complexity in a design system: an icon-only button with no visible text is inaccessible without an accessible name, so making aria-label required exactly when children is omitted and an icon is present turns a common accessibility bug into a compile-time error.

Input: labeling, error state, and description wiring

An accessible text input is not just an <input> — it's an <input> correctly associated with a <label> (via htmlFor/id), optionally with a description and an error message associated via aria-describedby, and with aria-invalid set when there's an error. Doing this correctly by hand in every consuming form is exactly the kind of repeated, easy-to-get-wrong work a shared component should absorb.

The library-authored Input therefore takes label, error, and description as first-class props, generates a stable id (via React.useId()) when the consumer doesn't supply one, and wires up all the ARIA relationships internally. The consumer just passes label="Email" and error={errors.email} and gets a fully accessible field for free.

Select and Checkbox: native controls with a typed, accessible wrapper

A Select wrapping the native <select> is usually the right choice over a fully custom listbox when native behavior (OS-level keyboard search-by-letter, mobile picker UI, form auto-fill) is more valuable than pixel-perfect custom styling — the library should offer this native-backed Select as the default and reserve a custom combobox (built on headless primitives, covered in the Module DQ lesson) for cases that need option rendering the native element can't do (icons, multi-line options, async search).

Checkbox needs to support three states in real forms: checked, unchecked, and indeterminate (used for 'select all' controls over a partially-selected list). Indeterminate is not an HTML attribute settable via JSX — it must be set imperatively on the DOM node via a ref, which is another concrete reason the component must forward its ref internally and apply the indeterminate flag in a useEffect keyed off the prop.

• Prefer the native <select>/<input type="checkbox"> behind a typed wrapper unless a genuine visual/interaction requirement forces a custom, headless-built alternative. • indeterminate on a checkbox must be set imperatively via a DOM ref — there is no JSX prop for it. • Every control generates a stable id via useId() so label/aria-describedby wiring works even when the consumer supplies no id.

The forwardRef + native-prop-extension pattern

ElementRef<'input'> resolves to HTMLInputElement — using the helper instead of naming the DOM type keeps this generic across similar wrappers. aria-describedby is built by joining only the ids that actually exist, so it's never left as an empty or dangling string.

import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";

interface InputOwnProps {
  label: string;
  error?: string;
  description?: string;
}

// Inherit the FULL native <input> surface, then layer our own props on top.
type InputProps = InputOwnProps & ComponentPropsWithoutRef<"input">;

export const Input = forwardRef<ElementRef<"input">, InputProps>(
  function Input({ label, error, description, id, ...nativeProps }, ref) {
    const generatedId = React.useId();
    const inputId = id ?? generatedId;
    const descId = description ? `${inputId}-desc` : undefined;
    const errId = error ? `${inputId}-err` : undefined;

    return (
      <div className="field">
        <label htmlFor={inputId}>{label}</label>
        <input
          ref={ref}
          id={inputId}
          aria-invalid={!!error || undefined}
          aria-describedby={[descId, errId].filter(Boolean).join(" ") || undefined}
          {...nativeProps}
        />
        {description && <p id={descId} className="field-desc">{description}</p>}
        {error && <p id={errId} role="alert" className="field-error">{error}</p>}
      </div>
    );
  }
);

Code Examples

loading both disables the button and suppresses onClick, so a double-submit during an in-flight request is impossible regardless of what the consumer wires up — the guarantee lives in the component, not in every call site.

onClick={loading ? undefined: onClick} The discriminated union on iconOnly makes aria-label a required prop exactly when there's no visible text content, so the classic 'icon button with no accessible name' bug is caught by the compiler instead of an accessibility audit months later.

Common mistake: Making aria-label optional on all icon buttons 'to keep the API simple' quietly ships inaccessible controls — the discriminated union costs a little type complexity to eliminate a whole bug class.

indeterminate has no JSX/HTML-attribute equivalent; it must be set as a DOM property imperatively. The callback ref both satisfies the consumer's forwarded ref and gives the component its own internal handle to the node for the useEffect write.

useEffect(() => { if (innerRef.current) innerRef.current.indeterminate = indeterminate; }, [indeterminate]);

import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";

type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
type ButtonSize = "sm" | "md" | "lg";

interface ButtonOwnProps {
  variant?: ButtonVariant;
  size?: ButtonSize;
  loading?: boolean;
  fullWidth?: boolean;
}

type ButtonProps = ButtonOwnProps & ComponentPropsWithoutRef<"button">;

export const Button = forwardRef<ElementRef<"button">, ButtonProps>(
  function Button(
    { variant = "primary", size = "md", loading = false, fullWidth, disabled, children, onClick, ...rest },
    ref
  ) {
    return (
      <button
        ref={ref}
        className={`btn btn-${variant} btn-${size}${fullWidth ? " btn-full" : ""}`}
        disabled={disabled || loading}
        aria-busy={loading || undefined}
        onClick={loading ? undefined : onClick}
        {...rest}
      >
        {loading && <span className="btn-spinner" aria-hidden="true" />}
        <span className={loading ? "btn-label-loading" : undefined}>{children}</span>
      </button>
    );
  }
);
type ButtonContentProps =
  | { iconOnly: true; icon: React.ReactNode; "aria-label": string; children?: never }
  | { iconOnly?: false; icon?: React.ReactNode; children: React.ReactNode };

type IconButtonProps = ButtonContentProps & Omit<ButtonProps, "children">;

function IconButton(props: IconButtonProps) {
  if (props.iconOnly) {
    // TS narrows here: props["aria-label"] is REQUIRED and known to exist.
    return <Button aria-label={props["aria-label"]}>{props.icon}</Button>;
  }
  return <Button>{props.icon}{props.children}</Button>;
}

// Compiles:
<IconButton iconOnly icon={<TrashIcon />} aria-label="Delete item" />;
// Fails to compile — aria-label missing on an icon-only button:
// <IconButton iconOnly icon={<TrashIcon />} />;
import { forwardRef, useEffect, useRef, type ComponentPropsWithoutRef, type ElementRef } from "react";

interface CheckboxOwnProps {
  label: string;
  indeterminate?: boolean;
}

type CheckboxProps = CheckboxOwnProps & Omit<ComponentPropsWithoutRef<"input">, "type">;

export const Checkbox = forwardRef<ElementRef<"input">, CheckboxProps>(
  function Checkbox({ label, indeterminate = false, id, ...rest }, forwardedRef) {
    const innerRef = useRef<HTMLInputElement>(null);
    const generatedId = React.useId();
    const checkboxId = id ?? generatedId;

    // Merge the forwarded ref with the internal ref we need for the DOM call.
    useEffect(() => {
      if (innerRef.current) innerRef.current.indeterminate = indeterminate;
    }, [indeterminate]);

    return (
      <label htmlFor={checkboxId} className="checkbox-row">
        <input
          type="checkbox"
          id={checkboxId}
          ref={(node) => {
            innerRef.current = node;
            if (typeof forwardedRef === "function") forwardedRef(node);
            else if (forwardedRef) forwardedRef.current = node;
          }}
          {...rest}
        />
        {label}
      </label>
    );
  }
);

Prop composition: own props + native props

The consumer-facing API is the union of hand-authored 'own' props and the full inherited native prop surface — nothing native is lost by wrapping the element.

InputOwnProps                 ComponentPropsWithoutRef<"input">
   { label, error, description }  { value, onChange, name, disabled, ... }
              \                          /
               \                        /
                v                      v
             ┌───────────────────────────┐
             │        InputProps          │   type union of both
             └──────────────┬─────────────┘
                             │
                             v
             forwardRef<HTMLInputElement, InputProps>(...)
                             │
                             v
                <Input label="Email" value={v} onChange={fn} />
                     (own props)      (inherited native props)

Important Rules

Every component that wraps a native interactive element uses forwardRef so consumers can reach the underlying DOM node.

Own props are typed with a dedicated interface and intersected with ComponentPropsWithoutRef<'element'> rather than manually re-declaring native attributes.

Use a discriminated union only when two prop shapes are genuinely mutually exclusive and one implies a requirement on another (e.g. icon-only implies required aria-label) — not as a default for every optional prop.

Labels, descriptions, and error messages are associated via htmlFor/id and aria-describedby, generated automatically with useId() when the consumer supplies no id.

DOM-only state that has no JSX prop equivalent (e.g. indeterminate) is set imperatively via a ref in a useEffect.

Where this shows up in production

Libraries like Radix UI, React Aria, and Chakra UI all follow this exact pattern — own props intersected with native props, forwardRef everywhere, ARIA wiring baked in — because it is the only approach that scales to hundreds of consuming teams without every team re-solving labeling and focus-management bugs independently. Getting this right in the four primitives covered here (Button, Input, Select, Checkbox) is what makes every form built on top of them accessible by default.

function Input({ label,...props }: { label: string; value: string; onChange: (e) => void }) { return <input value={props.value} onChange={props.onChange} />; } type InputProps = { label: string } & ComponentPropsWithoutRef<'input'>; const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => <input ref={ref} {...props} />);

Manually re-declaring only the native props you thought of (here, just value/onChange) silently drops every other native attribute (onBlur, placeholder, maxLength, aria-*) that a consumer will eventually need and be surprised is missing.

<button ref={inputRef}>Save</button> // Button component defined without forwardRef const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => <button ref={ref} {...props} />);

Without forwardRef, ref passed to a custom component is simply ignored by React (or, before React 19, throws a warning) and inputRef.current stays null, breaking .focus() calls after validation errors.

Why does a shared Input component use ComponentPropsWithoutRef<'input'> instead of manually listing props like value and onChange? A. It reduces the bundle size of the component B. It automatically inherits the full native attribute surface, so nothing native is silently dropped from the API C. ref cannot be typed without it D. It is required for React.memo to work correctly

B. It automatically inherits the full native attribute surface, so nothing native is silently dropped from the API — Manually re-declaring native props only covers the ones the author thought of; ComponentPropsWithoutRef inherits everything the underlying element natively supports.

Why must indeterminate be set on a checkbox via a ref in a useEffect rather than as a JSX prop? A. React forbids boolean props on native elements B. indeterminate is a DOM property with no corresponding HTML attribute, so it cannot be set declaratively through JSX C. It only works inside class components D. It must be set before the component mounts

B. indeterminate is a DOM property with no corresponding HTML attribute, so it cannot be set declaratively through JSX — Unlike checked or disabled, indeterminate has no HTML attribute equivalent — it exists only as a DOM node property, so it must be assigned imperatively via a ref.

In the icon-only Button example, what does the discriminated union on iconOnly enforce at compile time? A. That the button always has a visible label B. That aria-label is required exactly when the button is icon-only and has no children C. That the button cannot be disabled D. That the icon must be an SVG element

B. That aria-label is required exactly when the button is icon-only and has no children — The union makes the { iconOnly: true; "aria-label": string; children?: never } branch require an aria-label, turning a missing accessible name into a compile-time type error instead of a runtime accessibility bug.

Build a Select component that wraps the native <select>, accepts label, error, and an options: { value: string; label: string }[] prop, forwards its ref, and wires up the same aria-invalid/aria-describedby pattern used by the Input example.

type SelectProps = { label: string; error?: string; options: { value: string; label: string }[] } & ComponentPropsWithoutRef<'select'>; const Select = forwardRef<HTMLSelectElement, SelectProps>(({ label, error, options, id, ...rest }, ref) => { const generatedId = React.useId(); const selectId = id ?? generatedId; const errId = error ? ${selectId}-err : undefined; return (<div className='field'><label htmlFor={selectId}>{label}</label><select ref={ref} id={selectId} aria-invalid={!!error || undefined} aria-describedby={errId} {...rest}>{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}</select>{error && <p id={errId} role='alert'>{error}</p>}</div>); });

Reuse the id-generation and description/error association logic from the Input example almost verbatim — the only real difference is rendering <option> children instead of accepting a raw value/onChange passthrough.