Skip to content

InputsInput

Input

The single-line text input. The default form field across the site.

3 sizesLeading + trailing iconInvalid state

Sizes

Three sizes. The default is md (40 px tall).

Sizes

States

Default, focus, invalid, disabled.

States

With an icon

The icon sits inline with the input. It is decorative; the input is still announced by its label.

With icon

Anatomy

  • Background: --background (default) or --surface-elevated (subtle)
  • Border: 1 px --border at rest, --foreground on focus, --destructive when invalid
  • Text: --foreground at 0.9375 rem, line-height 1.4
  • Placeholder: --muted-foreground at the same size, normal weight
  • Padding: 12 px horizontal at md
  • Radius: --radius-md (4 px)
  • Focus ring: 1 px --foreground border + 3 px inner shadow at 12% opacity

Implementation

input.tsx
import { forwardRef, type InputHTMLAttributes } from "react"
import { cn } from "@/lib/utils"
import SiteIcon, type SiteIconProps } from "@/components/site-icon"

type Size = "sm" | "md" | "lg"
type Tone = "default" | "subtle"

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  size?: Size
  tone?: Tone
  leadingIcon?: SiteIconProps["icon"]
  trailingIcon?: SiteIconProps["icon"]
  invalid?: boolean
}

const sizeClass: Record<Size, string> = {
  sm: "ds-input--sm",
  md: "",
  lg: "ds-input--lg",
}

export const Input = forwardRef<HTMLInputElement, InputProps>(
  function Input({ size = "md", tone = "default", leadingIcon, trailingIcon, invalid, className, ...props }, ref) {
    const input = (
      <input
        ref={ref}
        className={cn("ds-input", sizeClass[size], `ds-input--${tone}`, invalid && "ds-input--invalid", className)}
        aria-invalid={invalid || undefined}
        {...props}
      />
    )
    if (!leadingIcon && !trailingIcon) return input
    return (
      <div className="ds-input-affix">
        {leadingIcon ? <SiteIcon icon={leadingIcon} size="sm" tone="muted" /> : null}
        {input}
        {trailingIcon ? <SiteIcon icon={trailingIcon} size="sm" tone="muted" /> : null}
      </div>
    )
  },
)

Props

PropTypeDefaultDescription
size"sm" | "md" | "lg""md"Height + padding
tone"default" | "subtle""default"Background tone
leadingIconLucideIconNoneLeading icon (decorative)
trailingIconLucideIconNoneTrailing icon (decorative)
invalidbooleanfalseError state
disabledbooleanfalseDisabled state

Accessibility

  • Every input is associated with a <label>. The association is by htmlFor + id, never by placeholder-as-label.
  • Invalid state uses aria-invalid and is announced to assistive tech.
  • Error messages are announced via aria-describedby.
  • Touch target is at least 40 × 40 px at the default size.