Skip to content
ThorbisInterface Guidelines (opens in a new tab)
All components

Input

A recessed well with a visible focus state and a mobile-safe text size.

Adopted
Family
Field
Available import
@/components/ui/input
Status
Thorbis wrapper and live specimen ship here.
Live Thorbis specimeninput
Preview a receipt

Use a sample email. This example only changes its local preview.

This preview needs JavaScript. If the fields stay disabled, reload the page.

A receipt destination for this preview. Nothing is emailed.

Read-only values remain selectable and appear in form data.

Phone editing is unavailable in this example. Disabled values are omitted from form data.

Nothing has been saved or sent.

Inspect the email field

The diagram compares heights, enlarged to fit. Values come from the actual input above and update when it resizes.

Live measurements become available when the example loads.

Field supplies layout. Its caller connects htmlFor, IDs, aria-describedby and aria-invalid. Input keeps native input attributes and its ref points to the input.

Use
Use for one-line text, URL, search, number, and credential entry with a visible label.
Avoid
Do not disable paste, hide the label in placeholder copy, or use less than 16px type on mobile.

Use this component

GitHub source links require access to the private Thorbis design repository.

Wrapper sourceRepository and dependencies

Value exports
  • Input
Type exports
  • InputProps
Setup, styles, and dependencies

These are React wrappers in the Thorbis design repository, not an installable npm UI package. In this repository, @/ resolves to src/ through tsconfig.json.

Use the repository's package.json and package-lock.json for dependencies. Load src/styles.css once at the application root for global tokens, typography, and themes. The wrappers compose colocated StyleX owners; include the generated token CSS and extracted StyleX output.

For another project, bring the wrapper and its transitive local imports, configure the alias, install the dependencies those files use, and provide the same token and style setup. Review the source before copying; the direct imports below are not a complete dependency closure.

Interactive examples require client-side React. This TanStack Start site server-renders and hydrates React; another host must hydrate or render the component on the client. Example code is the same TypeScript source used by its live preview.

Direct imports in this wrapper:

  • ../../stylex/tokens.stylex
  • ./field-target.stylex
  • ./input-group-contract
  • @stylexjs/stylex
  • react

Field anatomy, native validation and reset

Try native email validation, a local preview and reset. Inspect the same input's target, face, label, message and validity; measurements update on resize. Field supplies layout: connect htmlFor/id and aria-describedby yourself, and put aria-invalid on Input when reporting an error. Field invalid alone does not set the input's accessible state. Wrap a horizontal control and its message in FieldContent. FieldSet/FieldLegend keep native grouping. Input forwards native input attributes and its ref; read-only values submit, disabled values do not. Use defaultValue for native reset; controlled callers reset their own state. Controls wait for hydration. Nothing is saved or sent.

Preview a receipt

Use a sample email. This example only changes its local preview.

This preview needs JavaScript. If the fields stay disabled, reload the page.

A receipt destination for this preview. Nothing is emailed.

Read-only values remain selectable and appear in form data.

Phone editing is unavailable in this example. Disabled values are omitted from form data.

Nothing has been saved or sent.

Inspect the email field

The diagram compares heights, enlarged to fit. Values come from the actual input above and update when it resizes.

Live measurements become available when the example loads.

Field supplies layout. Its caller connects htmlFor, IDs, aria-describedby and aria-invalid. Input keeps native input attributes and its ref points to the input.

Example source
Read the complete example
import { exampleLayout } from "@/components/components/examples/example-layout.stylex";
import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FormActions } from "@/components/ui/form";
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field";

type FieldFacts = {
  name: string;
  description: string;
  targetHeight: number;
  faceHeight: number | null;
  validity: string;
};

export function JobContactExample() {
  const id = useId();
  const email = useRef<HTMLInputElement>(null);
  const [ready, setReady] = useState(false);
  const [error, setError] = useState("");
  const [preview, setPreview] = useState("");
  const [facts, setFacts] = useState<FieldFacts | null>(null);
  const inspect = useCallback(() => {
    const input = email.current;
    const frame = input?.parentElement;
    if (!input || !frame) return;
    const face = getComputedStyle(frame, "::before");
    const descriptionId = input.getAttribute("aria-describedby");
    setFacts({
      name: Array.from(input.labels ?? [])
        .map((label) => label.textContent?.trim())
        .join(" "),
      description: descriptionId ? (document.getElementById(descriptionId)?.textContent ?? "") : "",
      targetHeight: input.getBoundingClientRect().height,
      faceHeight: face.display === "none" ? null : Number.parseFloat(face.height),
      validity: !input.willValidate
        ? "Validation is inactive while disabled"
        : input.validity.valid
          ? "Valid email"
          : input.validity.valueMissing
            ? "Required value is empty"
            : "Email is incomplete",
    });
  }, []);

  useEffect(() => setReady(true), []);
  useEffect(() => {
    inspect();
    const input = email.current;
    if (!input) return;
    const observer = new ResizeObserver(inspect);
    observer.observe(input);
    const forcedColors = matchMedia("(forced-colors: active)");
    forcedColors.addEventListener("change", inspect);
    return () => {
      observer.disconnect();
      forcedColors.removeEventListener("change", inspect);
    };
  }, [inspect, ready, error]);

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPreview("");
    const input = email.current;
    if (!input) return;
    if (!input.validity.valid) {
      setError(
        input.validity.valueMissing ? "Enter a receipt email." : "Enter a complete email address.",
      );
      input.focus();
      return;
    }
    setError("");
    const data = new FormData(event.currentTarget);
    setPreview(
      `Preview ready: ${data.get("email")} for ${data.get("reference")}. Nothing was saved or sent.`,
    );
  }

  const target = facts?.targetHeight ?? 0;
  const face = facts?.faceHeight ?? target;
  const drawingFaceHeight = target > 0 ? 64 * Math.min(face / target, 1) : 64;
  return (
    <form
      aria-labelledby={`${id}-title`}
      aria-busy={!ready}
      noValidate
      onSubmit={submit}
      onReset={() => {
        setError("");
        setPreview("");
        // Read on the next frame, after the browser's default reset action.
        requestAnimationFrame(inspect);
      }}
    >
      <FieldSet disabled={!ready}>
        <FieldLegend id={`${id}-title`}>Preview a receipt</FieldLegend>
        <FieldDescription>
          Use a sample email. This example only changes its local preview.
        </FieldDescription>
        {!ready && (
          <FieldDescription>
            This preview needs JavaScript. If the fields stay disabled, reload the page.
          </FieldDescription>
        )}
        <FieldGroup>
          <Field orientation="horizontal" invalid={Boolean(error)}>
            <FieldLabel htmlFor={`${id}-email`}>Receipt email (required)</FieldLabel>
            <FieldContent>
              <Input
                ref={email}
                id={`${id}-email`}
                name="email"
                type="email"
                autoComplete="off"
                required
                defaultValue=""
                placeholder="mike@example.com"
                aria-invalid={Boolean(error) || undefined}
                aria-describedby={`${id}-email-message`}
                onChange={() => {
                  setError("");
                  setPreview("");
                  inspect();
                }}
              />
              {error ? (
                <FieldError id={`${id}-email-message`}>{error}</FieldError>
              ) : (
                <FieldDescription id={`${id}-email-message`}>
                  A receipt destination for this preview. Nothing is emailed.
                </FieldDescription>
              )}
            </FieldContent>
          </Field>
          <Field orientation="horizontal">
            <FieldLabel htmlFor={`${id}-reference`}>Job reference (read only)</FieldLabel>
            <FieldContent>
              <Input
                id={`${id}-reference`}
                name="reference"
                defaultValue="JOB-1842"
                readOnly
                aria-describedby={`${id}-reference-help`}
              />
              <FieldDescription id={`${id}-reference-help`}>
                Read-only values remain selectable and appear in form data.
              </FieldDescription>
            </FieldContent>
          </Field>
          <Field orientation="horizontal">
            <FieldLabel htmlFor={`${id}-phone`}>Customer phone (unavailable)</FieldLabel>
            <FieldContent>
              <Input
                id={`${id}-phone`}
                name="phone"
                type="tel"
                defaultValue="(555) 014-0184"
                disabled
                aria-describedby={`${id}-phone-help`}
              />
              <FieldDescription id={`${id}-phone-help`}>
                Phone editing is unavailable in this example. Disabled values are omitted from form
                data.
              </FieldDescription>
            </FieldContent>
          </Field>
          <FormActions>
            <Button type="submit">Preview receipt</Button>
            <Button type="reset" variant="secondary">
              Reset example
            </Button>
          </FormActions>
          <FieldDescription role="status" aria-live="polite">
            {preview || "Nothing has been saved or sent."}
          </FieldDescription>
        </FieldGroup>
      </FieldSet>
      <details {...exampleLayout.details} onToggle={inspect}>
        <summary
          {...exampleLayout.summary}
          style={{ minHeight: "var(--ui-control-target)" }}
        >
          Inspect the email field
        </summary>
        <div {...exampleLayout.inspector}>
          <FieldDescription>
            The diagram compares heights, enlarged to fit. Values come from the actual input above
            and update when it resizes.
          </FieldDescription>
          {facts ? (
            <>
              <svg viewBox="0 0 320 104" {...exampleLayout.full} aria-hidden="true" focusable="false">
                <rect
                  x="32"
                  y="20"
                  width="256"
                  height="64"
                  rx="10"
                  fill="none"
                  stroke="var(--color-accent)"
                  strokeWidth="2"
                  strokeDasharray="4 4"
                />
                <rect
                  x="32"
                  y={20 + (64 - drawingFaceHeight) / 2}
                  width="256"
                  height={drawingFaceHeight}
                  rx="10"
                  fill="var(--color-field)"
                  stroke="var(--color-marker)"
                />
                <path d="M48 52h80" fill="none" stroke="var(--color-fg)" strokeWidth="2" />
              </svg>
              <dl {...exampleLayout.facts}>
                <div>
                  <dt {...exampleLayout.emphasis}>Input target height</dt>
                  <dd {...exampleLayout.mono} data-field-fact="target">
                    {facts.targetHeight.toFixed(1)} CSS px
                  </dd>
                </div>
                <div>
                  <dt {...exampleLayout.emphasis}>Visible field height</dt>
                  <dd {...exampleLayout.mono} data-field-fact="face">
                    {facts.faceHeight === null
                      ? "Native boundary in forced colors"
                      : `${facts.faceHeight.toFixed(1)} CSS px`}
                  </dd>
                </div>
                <div>
                  <dt {...exampleLayout.emphasis}>Associated label</dt>
                  <dd {...exampleLayout.body} data-field-fact="name">
                    {facts.name}
                  </dd>
                </div>
                <div>
                  <dt {...exampleLayout.emphasis}>Connected help or error</dt>
                  <dd {...exampleLayout.body} data-field-fact="description">
                    {facts.description}
                  </dd>
                </div>
                <div>
                  <dt {...exampleLayout.emphasis}>Native validity</dt>
                  <dd {...exampleLayout.body} data-field-fact="validity">
                    {facts.validity}
                  </dd>
                </div>
              </dl>
            </>
          ) : (
            <FieldDescription>
              Live measurements become available when the example loads.
            </FieldDescription>
          )}
          <div>
            <Button
              type="button"
              variant="secondary"
              disabled={!ready}
              onClick={() => email.current?.focus()}
            >
              Focus receipt email
            </Button>
          </div>
          <FieldDescription>
            Field supplies layout. Its caller connects htmlFor, IDs, aria-describedby and
            aria-invalid. Input keeps native input attributes and its ref points to the input.
          </FieldDescription>
        </div>
      </details>
    </form>
  );
}

Form validation and recovery

Submit an empty address, then correct it, optionally add a unit, and choose a priority. FormRow wraps paired fields to the available width; the error replaces street help. Controls become available after client hydration so the local preview cannot send a native form request before it is ready. Nothing is saved. Form exports layout pieces; NativeSelect takes native option elements and native change events.

Preview a job

Local example. Your entry stays in this page and is not saved.

This preview needs JavaScript. If the fields stay disabled, reload the page.

Use a sample address to try the form.

Suite, apartment, or unit if needed.

Example source
Read the complete example
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import {
  FormActions,
  FormError,
  FormField,
  FormHelp,
  FormLabel,
  FormRow,
  FormSection,
  FormSectionDescription,
  FormSectionFields,
  FormSectionInfo,
  FormSectionTitle,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";

export function JobFormExample() {
  const id = useId();
  const street = useRef<HTMLInputElement>(null);
  const [ready, setReady] = useState(false);
  const [error, setError] = useState(false);
  const [preview, setPreview] = useState("");
  useEffect(() => setReady(true), []);

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const address = String(data.get("street") ?? "").trim();
    const unit = String(data.get("unit") ?? "").trim();
    setError(!address);
    setPreview("");
    if (!address) {
      street.current?.focus();
      return;
    }
    setPreview(
      `Preview ready: ${address}${unit ? `, unit ${unit}` : ""}, ${data.get("priority")}. Nothing was saved.`,
    );
  }

  return (
    <form data-form-pilot="form" aria-labelledby={`${id}-title`} aria-busy={!ready} onSubmit={submit} noValidate>
      <FormSection>
        <FormSectionInfo>
          <FormSectionTitle id={`${id}-title`}>Preview a job</FormSectionTitle>
          <FormSectionDescription>
            Local example. Your entry stays in this page and is not saved.
          </FormSectionDescription>
          {!ready && (
            <FormHelp>
              This preview needs JavaScript. If the fields stay disabled, reload the page.
            </FormHelp>
          )}
        </FormSectionInfo>
        <FormSectionFields>
          <FormField>
            <FormLabel htmlFor={`${id}-street`}>Street address (required)</FormLabel>
            <Input
              ref={street}
              id={`${id}-street`}
              name="street"
              autoComplete="off"
              required
              disabled={!ready}
              aria-invalid={error || undefined}
              aria-describedby={`${id}-street-message`}
              onChange={() => {
                setError(false);
                setPreview("");
              }}
            />
            {error ? (
              <FormError id={`${id}-street-message`}>Enter a street address.</FormError>
            ) : (
              <FormHelp id={`${id}-street-message`}>Use a sample address to try the form.</FormHelp>
            )}
          </FormField>
          <FormRow>
            <FormField>
              <FormLabel htmlFor={`${id}-unit`}>Unit or suite (optional)</FormLabel>
              <Input
                id={`${id}-unit`}
                name="unit"
                autoComplete="off"
                disabled={!ready}
                aria-describedby={`${id}-unit-help`}
                onChange={() => setPreview("")}
              />
              <FormHelp id={`${id}-unit-help`}>Suite, apartment, or unit if needed.</FormHelp>
            </FormField>
            <FormField>
              <FormLabel htmlFor={`${id}-priority`}>Priority</FormLabel>
              <NativeSelect
                id={`${id}-priority`}
                name="priority"
                defaultValue="routine"
                disabled={!ready}
                onChange={() => setPreview("")}
              >
                <option value="routine">Routine</option>
                <option value="urgent">Urgent</option>
              </NativeSelect>
            </FormField>
          </FormRow>
          <FormActions>
            <Button type="submit" disabled={!ready}>
              Preview job
            </Button>
          </FormActions>
          <FormHelp role="status" aria-live="polite">
            {preview}
          </FormHelp>
        </FormSectionFields>
      </FormSection>
    </form>
  );
}

Adoption contract

  1. Start from the ShadCN accessible source primitive; keep upstream keyboard and focus behavior.
  2. Use Thorbis color, type, space, radius, shadow, and motion tokens only.
  3. Design hover, focus, selected, disabled, loading, empty, error, offline, and reduced-motion states where they apply.
  4. Verify desktop and 390px mobile, keyboard order, accessible names, and stable async geometry.
Official ShadCN component directory

Applied bookmark research

Reviewed 2026-09-06. These examples reinforce the existing owners; they do not introduce another visual style or claim the product behavior is implemented. Read the 26-source review and access limits.

A control keeps its meaning through every state

A button that changes width, a field that loses input, or a dialog that loses focus breaks the task even when the resting design looks precise.

Review the approved primitive as a sequence: resting, focused, activated, pending, resolved, and recovered. Keep the action label and footprint stable, associate errors with their fields, and preserve a visible keyboard path.

Thorbis example. Save estimate remains Save estimate while its state mark changes. A validation error keeps the entered values, explains the repair beside the field, and moves focus only when the interaction calls for it.

Verify. Test Tab, Shift-Tab, Enter, Escape where applicable, paste, invalid input, rapid activation, and a slow response. Repeat at 390px, in both themes, with zoom and reduced motion.

Do not import. No hand-built duplicate primitive, hidden label, disabled browser zoom, or borrowed shadow-only outer edge that violates D-017.

Open the ui skill · Evidence: Web Interface Guidelines; Invisible Details; components.build; Details that make interfaces feel better

Transfer the intent, not the desktop gesture

An interaction that works with a precise pointer or modifier key may be unusable for a tech holding a phone.

Preserve the record and task while adapting the visible action path to the device. Use the existing native owner for target sizing, safe areas, focus, and host behavior; no critical action relies only on hover or a hidden gesture.

Thorbis example. A field photo remains attached to the same job when the tech changes orientation or returns from another app. The visible capture action and retry path remain available without discovering a desktop shortcut.

Verify. Check one-handed use, software keyboard opening, text enlargement, orientation, focus visibility, and reduced motion on the relevant device. Keep permission refusal and return-from-interruption states in the review.

Do not import. No desktop hover contract on a phone, blocked zoom, silent clipboard access, or unverified claim that a web imitation is native behavior.

Open the native skill · Evidence: Web Interface Guidelines; Design Engineer Principles; Devouring Details

Loading search…

Loading search. You can close this message and keep reading.