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.

Input.vue
Submit once to reveal the application-owned error.

Installation

One command installs one framework-native source file. There is no initializer, configuration file, alias prompt, context provider, or Klean runtime.

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add input

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

Native form recipe

EmailField.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>

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:

Input.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>

  • 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.