Input

Single-line text field. Builds its own Field when given a label.

Forms Input.jsx Input.d.ts Input.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Input is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Input } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Single-line text field. Builds its own Field when given a label.

Use it when

  • Collecting a short, free-form value: name, email, search term.

Don't use it when

  • The value is one of a known set. Use Select.
  • The answer runs past a line. Use Textarea.

Example

``jsx <Input label="Work email" type="email" required hint="We only use this for billing" /> <Input label="Email" error="That email is already in use. Try signing in instead." /> ``

Tokens

--dt-input-*, shared with Textarea and Select so every field in a form has identical geometry.

Accessibility

Generates an id and wires htmlFor automatically. The error prop sets aria-invalid and announces in a live region. Placeholder is never a label; it disappears the moment someone types.

Content

Label is a sentence-case noun. Placeholder shows format, not instruction: "name@company.com", not "Enter your email".

Props

The typed contract, from Input.d.ts.

import * as React from "react";

/**
 * Single-line text field.
 */
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
  label?: string;
  hint?: string;
  /** Sets aria-invalid and the error border. */
  error?: string;
  required?: boolean;
  /** @default "md" */
  size?: "sm" | "md" | "lg";
  /** Leading icon, 20px. Decorative — it is aria-hidden. */
  iconStart?: React.ReactNode;
}

export declare function Input(props: InputProps): React.JSX.Element;

Tokens it reads

Every custom property Input.jsx resolves, read from the source: 16 component, 2 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-input-bgcomponentvar(--dt-surface-base)
--dt-input-bg-disabledcomponentvar(--dt-surface-disabled)
--dt-input-bordercomponentvar(--dt-border-default)
--dt-input-border-errorcomponentvar(--dt-surface-danger)
--dt-input-border-focuscomponentvar(--dt-focus-ring-color)
--dt-input-border-hovercomponentvar(--dt-border-strong)
--dt-input-border-widthcomponentvar(--dt-border-width-default)
--dt-input-fgcomponentvar(--dt-text-primary)
--dt-input-font-familycomponentvar(--dt-text-body-md-family)
--dt-input-font-sizecomponentvar(--dt-font-size-sm)
--dt-input-height-lgcomponentvar(--dt-size-control-lg)
--dt-input-height-mdcomponentvar(--dt-size-control-md)
--dt-input-height-smcomponentvar(--dt-size-control-sm)
--dt-input-padding-xcomponentvar(--dt-space-inset-sm)
--dt-input-radiuscomponentvar(--dt-radius-control)
--dt-input-transitioncomponentvar(--dt-motion-micro)
--dt-size-icon-mdsemanticvar(--dt-dim-5)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)

Source

React only, no dependencies, every value a token.

import React from "react";
import { Field } from "./Field.jsx";

export function Input({ label, hint, error, required, size = "md", id, iconStart, style, ...rest }) {
  const [focus, setFocus] = React.useState(false);
  const [hover, setHover] = React.useState(false);
  const auto = React.useId();
  const inputId = id || auto;
  const border = error ? "var(--dt-input-border-error)" : focus ? "var(--dt-input-border-focus)" : hover ? "var(--dt-input-border-hover)" : "var(--dt-input-border)";

  const control = (
    <div style={{ position: "relative", display: "flex", alignItems: "center" }}>
      {iconStart && (
        <span aria-hidden="true" style={{ position: "absolute", left: "var(--dt-input-padding-x)", display: "flex", color: "var(--dt-text-tertiary)", pointerEvents: "none" }}>
          {iconStart}
        </span>
      )}
      <input
        id={inputId}
        required={required}
        aria-invalid={error ? true : undefined}
        onFocus={() => setFocus(true)}
        onBlur={() => setFocus(false)}
        onMouseEnter={() => setHover(true)}
        onMouseLeave={() => setHover(false)}
        style={{
          width: "100%",
          height: `var(--dt-input-height-${size})`,
          padding: iconStart
            ? "0 var(--dt-input-padding-x) 0 calc(var(--dt-input-padding-x) * 2 + var(--dt-size-icon-md))"
            : "0 var(--dt-input-padding-x)",
          fontFamily: "var(--dt-input-font-family)",
          fontSize: "var(--dt-input-font-size)",
          color: "var(--dt-input-fg)",
          background: rest.disabled ? "var(--dt-input-bg-disabled)" : "var(--dt-input-bg)",
          border: `var(--dt-input-border-width) solid ${border}`,
          borderRadius: "var(--dt-input-radius)",
          outline: "none",
          transition: "border-color var(--dt-input-transition)",
          boxSizing: "border-box",
        }}
        {...rest}
      />
    </div>
  );

  if (!label && !hint && !error) return <div style={style}>{control}</div>;
  return <Field label={label} hint={hint} error={error} required={required} htmlFor={inputId} style={style}>{control}</Field>;
}

In context

Input also appears on the forms reference cards, alongside the rest of its family.