Skip to content
  1. Inputs
  2. Input

Input

A single-line text field for names, emails, amounts, URLs and dates.

import { Field, Label } from '@/components/fieldset'
import { Input } from '@/components/input'

export default function Example() {
  return (
    <Field>
      <Label>Workspace name</Label>
      <Input name="workspace" defaultValue="northwind" />
    </Field>
  )
}
<script setup lang="ts">
import Field from '@/components/field.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'
</script>

<template>
  <Field>
    <Label>Workspace name</Label>
    <Input name="workspace" value="northwind" />
  </Field>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'

const groupIcon = [
  '*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:absolute *:data-[slot=icon]:z-10 *:data-[slot=icon]:text-zinc-500',
  '*:data-[slot=icon]:top-3 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:top-2.5 sm:*:data-[slot=icon]:size-4',
  'dark:*:data-[slot=icon]:text-zinc-400',
]

const leadingIcon = [
  '[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5',
  'has-[>[data-slot=icon]:first-child]:[&_input]:pl-10 sm:has-[>[data-slot=icon]:first-child]:[&_input]:pl-8',
]

const trailingIcon = [
  '[&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5',
  'has-[>[data-slot=icon]:last-child]:[&_input]:pr-10 sm:has-[>[data-slot=icon]:last-child]:[&_input]:pr-8',
]

export function InputGroup({ className, children }: React.ComponentPropsWithoutRef<'span'>) {
  return (
    <span data-slot="control" className={clsx(className, 'relative isolate block', groupIcon, leadingIcon, trailingIcon)}>
      {children}
    </span>
  )
}

const wrapper = {
  layout: 'relative block w-full',
  background: 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-sm before:bg-white dark:before:hidden',
  focus: [
    'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent',
    'sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
  ],
  disabled: 'has-data-disabled:opacity-50 has-data-disabled:before:shadow-none has-data-disabled:before:bg-zinc-950/5',
}

const field = {
  layout: 'relative block w-full appearance-none rounded-lg focus:outline-hidden',
  padding: 'py-[calc(--spacing(2.5)-1px)] px-[calc(--spacing(3.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:px-[calc(--spacing(3)-1px)]',
  typography: 'text-base/6 sm:text-sm/6 text-zinc-950 placeholder:text-zinc-500',
  background: 'bg-transparent',
  border: 'border border-zinc-950/10 data-hover:border-zinc-950/20',
  invalid: 'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
  disabled: 'data-disabled:border-zinc-950/20',
  dark: [
    'dark:scheme-dark dark:text-white dark:bg-white/5 dark:border-white/10 dark:data-hover:border-white/20',
    'dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
    'dark:data-disabled:bg-white/2.5 dark:data-disabled:border-white/15 dark:data-hover:data-disabled:border-white/15',
  ],
}

const datePicker = [
  '[&::-webkit-date-and-time-value]:min-h-[1.5em]',
  '[&::-webkit-datetime-edit]:inline-flex [&::-webkit-datetime-edit]:p-0 [&::-webkit-datetime-edit-fields-wrapper]:p-0',
  '[&::-webkit-datetime-edit-day-field]:p-0',
  '[&::-webkit-datetime-edit-month-field]:p-0',
  '[&::-webkit-datetime-edit-year-field]:p-0',
  '[&::-webkit-datetime-edit-hour-field]:p-0',
  '[&::-webkit-datetime-edit-minute-field]:p-0',
  '[&::-webkit-datetime-edit-second-field]:p-0',
  '[&::-webkit-datetime-edit-millisecond-field]:p-0',
  '[&::-webkit-datetime-edit-meridiem-field]:p-0',
]

const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
type DateType = (typeof dateTypes)[number]

type InputProps = {
  className?: string
  type?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | DateType
} & Omit<Headless.InputProps, 'as' | 'className'>

export const Input = forwardRef(function Input(
  { className, ...props }: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  let isDate = props.type !== undefined && dateTypes.includes(props.type)

  return (
    <span data-slot="control" className={clsx(className, Object.values(wrapper))}>
      <Headless.Input ref={ref} {...props} className={clsx(isDate && datePicker, Object.values(field))} />
    </span>
  )
})
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import { useDisabled, useFieldControl } from './fields'
import { vInteractions } from './interactions'

defineOptions({ inheritAttrs: false })

const props = defineProps<{
  type?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | 'date' | 'datetime-local' | 'month' | 'time' | 'week'
  invalid?: boolean
  disabled?: boolean
}>()

const model = defineModel<string | number>()
const attrs = useAttrs()
const fieldControl = useFieldControl(attrs)

const wrapper = {
  layout: 'relative block w-full',
  background: 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-sm before:bg-white dark:before:hidden',
  focus: [
    'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent',
    'sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
  ],
  disabled: 'has-data-disabled:opacity-50 has-data-disabled:before:shadow-none has-data-disabled:before:bg-zinc-950/5',
}

const field = {
  layout: 'relative block w-full appearance-none rounded-lg focus:outline-hidden',
  padding: 'py-[calc(--spacing(2.5)-1px)] px-[calc(--spacing(3.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:px-[calc(--spacing(3)-1px)]',
  typography: 'text-base/6 sm:text-sm/6 text-zinc-950 placeholder:text-zinc-500',
  background: 'bg-transparent',
  border: 'border border-zinc-950/10 data-hover:border-zinc-950/20',
  invalid: 'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
  disabled: 'data-disabled:border-zinc-950/20',
  dark: [
    'dark:scheme-dark dark:text-white dark:bg-white/5 dark:border-white/10 dark:data-hover:border-white/20',
    'dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
    'dark:data-disabled:bg-white/2.5 dark:data-disabled:border-white/15 dark:data-hover:data-disabled:border-white/15',
  ],
}

const datePicker = [
  '[&::-webkit-date-and-time-value]:min-h-[1.5em]',
  '[&::-webkit-datetime-edit]:inline-flex [&::-webkit-datetime-edit]:p-0 [&::-webkit-datetime-edit-fields-wrapper]:p-0',
  '[&::-webkit-datetime-edit-day-field]:p-0',
  '[&::-webkit-datetime-edit-month-field]:p-0',
  '[&::-webkit-datetime-edit-year-field]:p-0',
  '[&::-webkit-datetime-edit-hour-field]:p-0',
  '[&::-webkit-datetime-edit-minute-field]:p-0',
  '[&::-webkit-datetime-edit-second-field]:p-0',
  '[&::-webkit-datetime-edit-millisecond-field]:p-0',
  '[&::-webkit-datetime-edit-meridiem-field]:p-0',
]

const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
const isDate = computed(() => props.type !== undefined && dateTypes.includes(props.type))
const isDisabled = useDisabled(() => props.disabled)

const innerAttrs = computed(() => {
  const { class: _class, ...rest } = attrs
  const bindings: Record<string, unknown> = { ...rest }
  if (model.value !== undefined) bindings.value = model.value
  return bindings
})
</script>

<template>
  <span data-slot="control" :class="[attrs.class, Object.values(wrapper)]">
    <input
      v-bind="innerAttrs"
      v-interactions
      :type="type"
      :id="fieldControl.id.value"
      :aria-describedby="fieldControl.describedBy.value"
      :disabled="isDisabled"
      :data-disabled="isDisabled ? '' : undefined"
      :aria-invalid="invalid ? 'true' : undefined"
      :data-invalid="invalid ? '' : undefined"
      :class="[isDate && datePicker, Object.values(field)]"
      @input="model = ($event.target as HTMLInputElement).value"
    />
  </span>
</template>

Component API

PropDefaultDescription
Inputrenders a <input> element
typetextNative input type: text, email, url, number, password, a date type, and so on.
invalidfalseDraws the red border and sets aria-invalid.
disabledfalseTurns the input off when it is not inside a disabled Field.
InputGrouprenders a <span> element
This component does not expose any component-specific props.
Fieldbuilt on the Headless UI <Headless.Field> component
disabledfalseTurns off the control and dims its label and help text.
Labelbuilt on the Headless UI <Headless.Label> component
This component does not expose any component-specific props.
Descriptionbuilt on the Headless UI <Headless.Description> component
This component does not expose any component-specific props.
ErrorMessagebuilt on the Headless UI <Headless.Description> component
This component does not expose any component-specific props.
PropDefaultDescription
Inputrenders a <input> element
v-model-The current value, kept in sync as the user types.
typetextNative input type: text, email, url, number, password, a date type, and so on.
invalidfalseDraws the red border and sets aria-invalid.
disabledfalseTurns the input off when it is not inside a disabled Field.
InputGrouprenders a <span> element
This component does not expose any component-specific props.
Fieldrenders a <div> element
disabledfalseTurns off the control and dims its label and help text.
Labelrenders a <label> element
This component does not expose any component-specific props.
Descriptionrenders a <p> element
This component does not expose any component-specific props.
ErrorMessagerenders a <p> element
This component does not expose any component-specific props.

Usage

Give every input a visible label. A placeholder disappears as soon as someone types, so use it for a sample value, never as the only hint of what the field expects. Help text that people need before typing goes between the label and the input.

Name a field

Inside a Field, the Label and the Input are linked for you, so a click on the label focuses the input and screen readers read the label with it:

import { Field, Label } from '@/components/fieldset'
import { Input } from '@/components/input'

export default function Example() {
  return (
    <Field>
      <Label>Workspace name</Label>
      <Input name="workspace" defaultValue="northwind" />
    </Field>
  )
}
<script setup lang="ts">
import Field from '@/components/field.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'
</script>

<template>
  <Field>
    <Label>Workspace name</Label>
    <Input name="workspace" value="northwind" />
  </Field>
</template>

Explain a constraint

A Description placed before the input is read along with it. Keep it to the rule people would otherwise learn from an error:

Shown on your customers' card statements. Up to 22 characters.

import { Description, Field, Label } from '@/components/fieldset'
import { Input } from '@/components/input'

export default function Example() {
  return (
    <Field>
      <Label>Statement descriptor</Label>
      <Description>Shown on your customers' card statements. Up to 22 characters.</Description>
      <Input name="statement_descriptor" maxLength={22} placeholder="NORTHWIND" />
    </Field>
  )
}
<script setup lang="ts">
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'
</script>

<template>
  <Field>
    <Label>Statement descriptor</Label>
    <Description>Shown on your customers' card statements. Up to 22 characters.</Description>
    <Input name="statement_descriptor" maxlength="22" placeholder="NORTHWIND" />
  </Field>
</template>

Match the keyboard to the value

Set type to what the field holds. Mobile keyboards adapt to it, and the browser checks the format of email and url values on submit:

<Field>
  <Label>Endpoint URL</Label>
  <Input type="url" name="endpoint_url" placeholder="https://api.example.com/webhooks" />
</Field>
<Field>
  <Label>Endpoint URL</Label>
  <Input type="url" name="endpoint_url" placeholder="https://api.example.com/webhooks" />
</Field>

Accepted values: text, email, url, tel, search, number, password, date, datetime-local, month, time and week. The five date and time types get extra styles so the browser's date fields keep the same height as a text input.

Flag a required field

required on the Label adds a red asterisk that screen readers skip. Put required on the Input too, so the browser and assistive technology know about it:

<Field>
  <Label required>Company legal name</Label>
  <Input name="legal_name" required />
</Field>
<Field>
  <Label required>Company legal name</Label>
  <Input name="legal_name" required />
</Field>

Layout

An input fills the width of its container. That suits free text, but a short value in a long box looks like a mistake, so size fields to what they hold.

Short values

A width class in className lands on the wrapper, which is where the width should go:

A width class in class lands on the wrapper, which is where the width should go:

<Field>
  <Label>Seats</Label>
  <Input className="max-w-24" type="number" name="seats" min={1} defaultValue={12} />
</Field>
<Field>
  <Label>Seats</Label>
  <Input class="max-w-24" type="number" name="seats" min="1" value="12" />
</Field>

Stick to layout classes such as width and margin in classNameclass. Colors, borders and padding are already set by the component, and overriding them gives uneven results.

Label on the same line

For a toolbar or a compact row, skip Field and write your own <label>. Its htmlFor has to match the id of the Input:

For a toolbar or a compact row, skip Field and write your own <label>. Its for has to match the id of the Input:

import { Input } from '@/components/input'

export default function Example() {
  return (
    <div className="flex items-center gap-4">
      <label htmlFor="key_name" className="shrink-0 text-base/6 text-zinc-950 select-none sm:text-sm/6 dark:text-white">
        Key name
      </label>
      <Input id="key_name" name="key_name" placeholder="Production server" className="max-w-56" />
    </div>
  )
}
<script setup lang="ts">
import Input from '@/components/input.vue'
</script>

<template>
  <div class="flex items-center gap-4">
    <label for="key_name" class="shrink-0 text-base/6 text-zinc-950 select-none sm:text-sm/6 dark:text-white">
      Key name
    </label>
    <Input id="key_name" name="key_name" placeholder="Production server" class="max-w-56" />
  </div>
</template>

Appearance

Wrap the Input in an InputGroup and put an icon before it. The icon sits inside the border and the text moves over to make room. Without a visible label, aria-label names the field:

import { Input, InputGroup } from '@/components/input'
import { MagnifyingGlassIcon } from '@heroicons/react/16/solid'

export default function Example() {
  return (
    <InputGroup>
      <MagnifyingGlassIcon />
      <Input type="search" name="q" placeholder="Search invoices" aria-label="Search invoices" />
    </InputGroup>
  )
}
<script setup lang="ts">
import Input from '@/components/input.vue'
import InputGroup from '@/components/input-group.vue'
import { MagnifyingGlassIcon } from '@heroicons/vue/16/solid'
</script>

<template>
  <InputGroup>
    <MagnifyingGlassIcon />
    <Input type="search" name="q" placeholder="Search invoices" aria-label="Search invoices" />
  </InputGroup>
</template>

Icons render at 20px on small screens and 16px from the sm breakpoint, so the 16px Heroicons fit best. The group finds icons by their data-slot="icon" attribute: Heroicons already have it, add it to any other icon.

Icon at the end

Put the icon after the Input and it moves to the right edge. Here it signals a value people can copy but not change:

<Field>
  <Label>Signing secret</Label>
  <InputGroup>
    <Input name="signing_secret" defaultValue="whsec_…9c1d" readOnly />
    <LockClosedIcon />
  </InputGroup>
</Field>
<Field>
  <Label>Signing secret</Label>
  <InputGroup>
    <Input name="signing_secret" value="whsec_…9c1d" readonly />
    <LockClosedIcon />
  </InputGroup>
</Field>

States

Feature not on the plan

disabled on the Field turns the input off and dims the label and description with it. Say why in the description, or people will look for a way to turn it on:

Available on the Scale plan.

<Field disabled>
  <Label>Custom domain</Label>
  <Description>Available on the Scale plan.</Description>
  <Input name="custom_domain" placeholder="billing.northwind.dev" />
</Field>
<Field disabled>
  <Label>Custom domain</Label>
  <Description>Available on the Scale plan.</Description>
  <Input name="custom_domain" placeholder="billing.northwind.dev" />
</Field>

An input without a Field takes disabled directly.

Validation

Show errors after a submit or when the user leaves the field, not on every keystroke. The message says how to fix the value, not only that it is wrong.

Point to the fix

invalid turns the border red and sets aria-invalid. An ErrorMessage in the same Field is attached to the input as its description, so it is read on focus:

Add the domain, like finance@northwind.dev.

import { ErrorMessage, Field, Label } from '@/components/fieldset'
import { Input } from '@/components/input'

export default function Example({ errors }) {
  return (
    <Field>
      <Label>Billing email</Label>
      <Input type="email" name="billing_email" defaultValue="finance@northwind" invalid={errors.has('billing_email')} />
      {errors.has('billing_email') && <ErrorMessage>{errors.get('billing_email')}</ErrorMessage>}
    </Field>
  )
}
<script setup lang="ts">
import ErrorMessage from '@/components/error-message.vue'
import Field from '@/components/field.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'

defineProps<{ errors: Map<string, string> }>()
</script>

<template>
  <Field>
    <Label>Billing email</Label>
    <Input type="email" name="billing_email" value="finance@northwind" :invalid="errors.has('billing_email')" />
    <ErrorMessage v-if="errors.has('billing_email')">{{ errors.get('billing_email') }}</ErrorMessage>
  </Field>
</template>

Interaction

Preview the result while typing

Hold the value in state with value and onChange when other parts of the screen depend on it:

Bind the value with v-model when other parts of the screen depend on it:

Next invoice number: NW-2042

'use client'

import { useState } from 'react'
import { Description, Field, Label } from '@/components/fieldset'
import { Input } from '@/components/input'

export default function Example() {
  let [prefix, setPrefix] = useState('NW')

  return (
    <Field>
      <Label>Invoice prefix</Label>
      <Input name="invoice_prefix" value={prefix} onChange={(event) => setPrefix(event.target.value)} />
      <Description>Next invoice number: {prefix || 'INV'}-2042</Description>
    </Field>
  )
}
<script setup lang="ts">
import { ref } from 'vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'

const prefix = ref('NW')
</script>

<template>
  <Field>
    <Label>Invoice prefix</Label>
    <Input name="invoice_prefix" v-model="prefix" />
    <Description>Next invoice number: {{ prefix || 'INV' }}-2042</Description>
  </Field>
</template>