HextaUI

Input

A text input with three sizes, invalid and read-only states, native validation styling and a 16px touch font so phones never zoom in.

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function InputDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <Label htmlFor="input-demo-email">Email</Label>
      <Input
        id="input-demo-email"
        type="email"
        autoComplete="email"
        placeholder="[email protected]"
      />
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/input.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

import { Input } from "@/components/ui/input"
<label htmlFor="email">Email</label>
<Input id="email" type="email" placeholder="[email protected]" />

Sizes

sm, default and lg match the button heights, so an input and a button of the same size line up in a row.

import { Input } from "@/components/ui/input"

export function InputSizes() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <Input size="sm" aria-label="Small" placeholder="Small" />
      <Input aria-label="Default" placeholder="Default" />
      <Input size="lg" aria-label="Large" placeholder="Large" />
    </div>
  )
}

With a description

Point aria-describedby at the helper text so screen readers read it after the label.

Shown on your profile and in mentions.

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function InputDescription() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <Label htmlFor="input-username">Username</Label>
      <Input
        id="input-username"
        autoComplete="username"
        placeholder="preet"
        aria-describedby="input-username-description"
      />
      <p
        id="input-username-description"
        className="text-sm text-muted-foreground"
      >
        Shown on your profile and in mentions.
      </p>
    </div>
  )
}

Invalid

aria-invalid turns the edge and the focus ring red. Link the message with aria-describedby so it is announced, not just colored.

Enter a full email address, like [email protected].

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function InputInvalid() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <Label htmlFor="input-invalid">Email</Label>
      <Input
        id="input-invalid"
        type="email"
        defaultValue="preet@"
        aria-invalid
        aria-describedby="input-invalid-error"
      />
      <p id="input-invalid-error" className="text-sm text-destructive">
        Enter a full email address, like [email protected].
      </p>
    </div>
  )
}

Native validation

Fields with required, type="email" or pattern only turn red after someone has typed in them or tried to submit, never on first render. A submit that finds a field invalid shakes it once, so the eye lands on what needs fixing. It never shakes while you type or tab around. Submit the empty form to see it.

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function InputNativeValidation() {
  return (
    <form className="flex w-full max-w-sm flex-col gap-3">
      <div className="flex flex-col gap-2">
        <Label htmlFor="input-native-email">Email</Label>
        <Input
          id="input-native-email"
          name="email"
          type="email"
          required
          placeholder="[email protected]"
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="input-native-code">Invite code</Label>
        <Input
          id="input-native-code"
          name="code"
          required
          pattern="[A-Z]{4}-[0-9]{4}"
          placeholder="ABCD-1234"
        />
      </div>
      <Button type="submit" className="self-start">
        Join
      </Button>
    </form>
  )
}

Disabled

A disabled input can’t be focused, edited or submitted with the form.

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function InputDisabled() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <Label htmlFor="input-disabled">Workspace</Label>
      <Input id="input-disabled" defaultValue="Acme Inc." disabled />
    </div>
  )
}

Read only

readOnly keeps the value focusable, selectable and submitted, with a muted surface so it doesn’t look editable. Prefer it over disabled for values people need to copy.

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function InputReadOnly() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <Label htmlFor="input-read-only">API key</Label>
      <Input id="input-read-only" readOnly defaultValue="sk_live_51H8a…f2Qz" />
    </div>
  )
}

File

type="file" gets the same frame, with the browser’s button restyled as plain text.

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function InputFile() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <Label htmlFor="input-file">Avatar</Label>
      <Input id="input-file" type="file" accept="image/*" />
    </div>
  )
}

Input types

Password, number, search, date and time share one height and frame. In dark mode, the browser’s pickers and spinners switch to dark too.

import { Input } from "@/components/ui/input"

export function InputTypes() {
  return (
    <div className="grid w-full max-w-sm gap-3">
      <Input type="password" aria-label="Password" defaultValue="hunter2" />
      <Input type="number" aria-label="Seats" defaultValue={12} min={1} />
      <Input type="search" aria-label="Search" placeholder="Search…" />
      <Input type="date" aria-label="Start date" defaultValue="2026-10-03" />
      <Input type="time" aria-label="Start time" defaultValue="09:30" />
    </div>
  )
}

Controlled

onValueChange hands you the string directly, so there’s no event.target.value to unwrap. onChange still works too.

18/32

"use client"

import * as React from "react"

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

const limit = 32

export function InputControlled() {
  const [value, setValue] = React.useState("Quarterly planning")

  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <Label htmlFor="input-controlled">Project name</Label>
      <Input
        id="input-controlled"
        value={value}
        maxLength={limit}
        onValueChange={setValue}
        aria-describedby="input-controlled-count"
      />
      <p
        id="input-controlled-count"
        className="text-end text-sm text-muted-foreground tabular-nums"
      >
        {value.length}/{limit}
      </p>
    </div>
  )
}

With a button

Side by side with a gap, or joined into one control inside a <ButtonGroup />, where the input takes the remaining width.

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { Input } from "@/components/ui/input"

export function InputWithButton() {
  return (
    <form className="flex w-full max-w-sm flex-col gap-4">
      <div className="flex gap-2">
        <Input type="email" aria-label="Email" placeholder="[email protected]" />
        <Button type="submit">Subscribe</Button>
      </div>
      <ButtonGroup className="w-full">
        <Input type="search" aria-label="Search" placeholder="Search…" />
        <Button variant="outline">Search</Button>
      </ButtonGroup>
    </form>
  )
}

Grid

Inputs fill their container, so place them in a grid. Give grid cells min-w-0 so long values can’t stretch a column.

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function InputGrid() {
  return (
    <div className="grid w-full max-w-sm grid-cols-2 gap-3">
      <div className="flex min-w-0 flex-col gap-2">
        <Label htmlFor="input-first-name">First name</Label>
        <Input id="input-first-name" autoComplete="given-name" />
      </div>
      <div className="flex min-w-0 flex-col gap-2">
        <Label htmlFor="input-last-name">Last name</Label>
        <Input id="input-last-name" autoComplete="family-name" />
      </div>
    </div>
  )
}

Long content

Long values scroll inside the field and long placeholders are cut off, without widening the layout.

import { Input } from "@/components/ui/input"

export function InputLongContent() {
  return (
    <div className="flex w-full max-w-56 flex-col gap-3">
      <Input
        aria-label="URL"
        defaultValue="https://example.com/a/really/long/url/without/any/spaces/at/all"
      />
      <Input
        aria-label="Note"
        placeholder="A placeholder that is far too long to fit in this field"
      />
    </div>
  )
}

Right to left

Text, caret and padding follow the direction. Use dir="auto" on fields that hold left-to-right values, like an email address in an Arabic form.

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function InputRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-sm flex-col gap-2">
      <Label htmlFor="input-rtl">البريد الإلكتروني</Label>
      <Input id="input-rtl" placeholder="[email protected]" dir="auto" />
      <Input aria-label="الاسم" placeholder="اكتب اسمك" />
    </div>
  )
}
  • Every input needs a name. Use a <label> with htmlFor, or aria-label when there’s no visible label. A placeholder is not a label.
  • Connect helper and error text with aria-describedby, and set aria-invalid only once there is an error to show.
  • On touch screens the text is at least 16px, so iOS Safari doesn’t zoom the page when the input is focused.
  • Inside a Base UI Field, the label, description, error and validity are wired up for you.

Built on the Base UI input. It accepts every native input attribute.

PropTypeDefault
sizeHeight and padding, matched to the buttons.
"sm" | "default" | "lg""default"
htmlSizeThe native size attribute, renamed because size is the variant.
number–
value
string | number | string[]–
defaultValue
string | number | string[]–
onValueChangeCalled with the new value on every change.
(value: string, details) => void–
type
string"text"
disabled
booleanfalse
readOnly
booleanfalse
aria-invalidShows the invalid edge and focus ring.
boolean–
className
string | (state) => string–
shakeShake once when a form submit finds this input invalid. Works with native validation, Base UI Field and libraries that set aria-invalid. Skipped under reduced motion.
booleantrue
render
ReactElement | (props, state) => ReactElement<input>
AttributeDescription
data-slot="input"Target the input in CSS.
data-sizeThe current size.
data-shakePresent while the input shakes after a failed submit.
data-disabledPresent when the input is disabled.
data-invalidPresent when the surrounding Field is invalid. Styled like aria-invalid.
data-validPresent when the surrounding Field is valid.
data-touchedPresent after the input lost focus once, inside a Field.
data-dirtyPresent once the value changed, inside a Field.
data-filledPresent when the input has a value, inside a Field.
data-focusedPresent while focused, inside a Field.

The class names behind the input, for styling another element to match, such as a native <select> or <textarea>. Call it with { size }.

The character count behind <InputGroupCount /> and <FieldCounter />. Use those parts, which read the field for you. Reach for this one only when you track the length yourself.

PropTypeDefault
lengthRequired.
number–
maxLength
number | null–
threshold
number10% of maxLength, at most 20
announcement
(remaining: number) => string–
AttributeDescription
data-slot="input-count"Target the count in CSS.
data-state="near" | "limit"Present within the threshold, and at the limit.