Skip to content

Input ​

Input is one styled native control. It forwards native attributes, supports framework-native value binding, exposes its element for explicit focus recovery, and merges caller Tailwind classes last.

Klean deliberately does not supply Field, Label, description, or error components. The browser's form model is the convention, so the application writes the real <label>, messages, IDs, and ARIA relationships where they remain visible.

Preview Live · Vue
Usage
vue
<script setup>
import Input from '@/components/ui/input/Input.vue'
</script>

<template>
  <div class="grid gap-2">
    <label for="email">Email address</label>
    <Input
      id="email"
      v-model="form.email"
      name="email"
      type="email"
      autocomplete="email"
      required
      :aria-invalid="Boolean(form.errors.email)"
      aria-describedby="email-help email-error"
    />
    <p id="email-help">We only use this for account messages.</p>
    <p id="email-error" class="empty:hidden text-sm text-red-700">
      {{ form.errors.email }}
    </p>
  </div>
</template>
jsx
import Input from '@/components/ui/input/Input.jsx'

export default function EmailField({ value, onChange, error = '' }) {
  return (
    <div className="grid gap-2">
      <label htmlFor="email">Email address</label>
      <Input
        id="email"
        value={value}
        onChange={(event) => onChange(event.target.value)}
        name="email"
        type="email"
        autoComplete="email"
        required
        aria-invalid={Boolean(error)}
        aria-describedby="email-help email-error"
      />
      <p id="email-help">We only use this for account messages.</p>
      <p id="email-error" className="empty:hidden text-sm text-red-700">
        {error}
      </p>
    </div>
  )
}
svelte
<script>
  import Input from '@/components/ui/input/Input.svelte'

  let { value = $bindable(''), error = '' } = $props()
</script>

<div class="grid gap-2">
  <label for="email">Email address</label>
  <Input
    id="email"
    bind:value
    name="email"
    type="email"
    autocomplete="email"
    required
    aria-invalid={Boolean(error)}
    aria-describedby="email-help email-error"
  />
  <p id="email-help">We only use this for account messages.</p>
  <p id="email-error" class="empty:hidden text-sm text-red-700">
    {error}
  </p>
</div>
View source Input.vue
Input.vue
vue
<script setup>
import { computed, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  modelValue: { type: [String, Number], default: undefined },
  type: { type: String, default: 'text' }
})
const emit = defineEmits(['update:modelValue'])
const attrs = useAttrs()
const element = ref()
let composing = false

const resolvedValue = computed(() => props.modelValue ?? attrs.value)

const forwardedAttrs = computed(() => {
  const {
    class: _class,
    value: _value,
    'data-slot': _dataSlot,
    ...rest
  } = attrs
  return rest
})

function updateValue(event) {
  if (composing) return
  const value = ['number', 'range'].includes(props.type)
    ? event.target.value === ''
      ? ''
      : event.target.valueAsNumber
    : event.target.value
  emit('update:modelValue', value)
}

function finishComposition(event) {
  composing = false
  updateValue(event)
}

defineExpose({
  element,
  focus: (options) => element.value?.focus(options)
})
</script>

<template>
  <input
    ref="element"
    v-bind="forwardedAttrs"
    :type="type"
    :value="resolvedValue"
    data-slot="input"
    :class="
      twMerge(
        [
          'block min-h-11 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-base text-gray-950 shadow-sm outline-none transition-colors duration-150',
          'placeholder:text-gray-500 hover:border-gray-400',
          'focus-visible:border-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950',
          'disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500',
          'aria-invalid:border-red-600 aria-invalid:focus-visible:outline-red-600',
          'dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:hover:border-gray-600 dark:focus-visible:border-white dark:focus-visible:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus-visible:outline-red-500',
          'motion-reduce:transition-none'
        ],
        attrs.class
      )
    "
    @compositionstart="composing = true"
    @compositionend="finishComposition"
    @input="updateValue"
  />
</template>

Submit once to reveal the application-owned error.

Installation ​

Run from your application’s directory. Klean detects your framework and copies the matching source.

Terminal
npx klean-ui add input

Source you own. Edit the installed files to make them yours.

Native form recipe ​

Copy the example above for your framework.

The application owns the visible label, deterministic IDs, help and error elements, validation timing, and submitted value. Help and error nodes keep stable IDs, so aria-describedby never needs conditional string building. aria-invalid="false" is valid, and empty:hidden collapses an empty error. When an error appears, the existing relationship becomes useful automatically.

This explicit repetition is smaller and clearer than a Field configuration language or accessibility helper. Extract an application-owned form composition only when your product repeats the same complete markup and policy.

API ​

Input accepts native input attributes, framework-native value binding, and caller classes. It has no variant, size, tone, label, description, error, or validation props.

The default type is text. Native name, required, disabled, autocomplete, aria-invalid, and aria-describedby pass through unchanged.

Styling ​

The neutral defaults are monochrome, touch-safe, dark-mode aware, and visibly focusable. The 16px text default avoids mobile browser zoom. Caller Tailwind wins:

vue
<Input class="min-h-9 rounded-none border-2 py-1 text-sm shadow-none" />

If that dense treatment is a recurring product concept, create an application-owned DenseInput.vue; do not turn it into a Klean size prop.

Accessibility contract ​

  • Every input needs a visible associated label unless the application has a justified accessible-name alternative.
  • Help and error text connect through aria-describedby.
  • Invalid state uses aria-invalid; color is never the only signal.
  • Stable empty errors remain unannounced until they contain useful text.
  • Native required and disabled behavior stays native.
  • Focus remains visible in light, dark, and high-contrast contexts.
  • Validation waits for blur or submission instead of punishing untouched input.
  • A failed submission that needs announcement uses one application-owned error summary and focus recovery, not role="alert" on every inline error.

Complete framework source ​

The live preview demonstrates the shared native form contract. Copy the complete framework-native source for your application:

vue
<script setup>
import { computed, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  modelValue: { type: [String, Number], default: undefined },
  type: { type: String, default: 'text' }
})
const emit = defineEmits(['update:modelValue'])
const attrs = useAttrs()
const element = ref()
let composing = false

const resolvedValue = computed(() => props.modelValue ?? attrs.value)

const forwardedAttrs = computed(() => {
  const {
    class: _class,
    value: _value,
    'data-slot': _dataSlot,
    ...rest
  } = attrs
  return rest
})

function updateValue(event) {
  if (composing) return
  const value = ['number', 'range'].includes(props.type)
    ? event.target.value === ''
      ? ''
      : event.target.valueAsNumber
    : event.target.value
  emit('update:modelValue', value)
}

function finishComposition(event) {
  composing = false
  updateValue(event)
}

defineExpose({
  element,
  focus: (options) => element.value?.focus(options)
})
</script>

<template>
  <input
    ref="element"
    v-bind="forwardedAttrs"
    :type="type"
    :value="resolvedValue"
    data-slot="input"
    :class="
      twMerge(
        [
          'block min-h-11 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-base text-gray-950 shadow-sm outline-none transition-colors duration-150',
          'placeholder:text-gray-500 hover:border-gray-400',
          'focus-visible:border-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950',
          'disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500',
          'aria-invalid:border-red-600 aria-invalid:focus-visible:outline-red-600',
          'dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:hover:border-gray-600 dark:focus-visible:border-white dark:focus-visible:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus-visible:outline-red-500',
          'motion-reduce:transition-none'
        ],
        attrs.class
      )
    "
    @compositionstart="composing = true"
    @compositionend="finishComposition"
    @input="updateValue"
  />
</template>
jsx
import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'

const BASE_CLASSES = [
  'block min-h-11 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-base text-gray-950 shadow-sm outline-none transition-colors duration-150',
  'placeholder:text-gray-500 hover:border-gray-400',
  'focus-visible:border-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950',
  'disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500',
  'aria-invalid:border-red-600 aria-invalid:focus-visible:outline-red-600',
  'dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:hover:border-gray-600 dark:focus-visible:border-white dark:focus-visible:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus-visible:outline-red-500',
  'motion-reduce:transition-none'
]

const Input = forwardRef(function Input(
  { type = 'text', className, ...props },
  ref
) {
  return (
    <input
      {...props}
      ref={ref}
      type={type}
      data-slot="input"
      className={twMerge(BASE_CLASSES, className)}
    />
  )
})

export default Input
svelte
<script>
  import { twMerge } from "tailwind-merge";

  const BASE_CLASSES = [
    "block min-h-11 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-base text-gray-950 shadow-sm outline-none transition-colors duration-150",
    "placeholder:text-gray-500 hover:border-gray-400",
    "focus-visible:border-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950",
    "disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500",
    "aria-invalid:border-red-600 aria-invalid:focus-visible:outline-red-600",
    "dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:hover:border-gray-600 dark:focus-visible:border-white dark:focus-visible:outline-white dark:disabled:bg-gray-900 dark:disabled:text-gray-500 dark:aria-invalid:border-red-500 dark:aria-invalid:focus-visible:outline-red-500",
    "motion-reduce:transition-none",
  ];

  let {
    type = "text",
    value = $bindable(),
    class: className,
    ...props
  } = $props();

  let element;

  export function getElement() {
    return element;
  }

  export function focus(options) {
    element?.focus(options);
  }
</script>

<input
  {...props}
  bind:this={element}
  {type}
  bind:value
  data-slot="input"
  class={twMerge(BASE_CLASSES, className)}
/>
  • Textarea — growing multi-line input.
  • Select — one persistent value from a known fixed list.
  • Date Picker — one date-only YYYY-MM-DD value with Calendar.
  • Schedule Picker — date, time, and IANA timezone stored as an exact ISO instant.
  • Button — native form submission and actions.

All open source projects are released under the MIT License.