CInput
CInput is the low-level primitive for building input controls. It manages focus state, validation, aria attributes, and CForm integration. It does not render a native <input> itself — instead it exposes everything through the field slot so the consumer can build any kind of control on top.
When to use CInput directly?
For most use cases, prefer CTextField. Use CInput when you need a non-standard control: a styled textarea, a PIN input, a numeric stepper, or any other widget that needs validation and focus state.
Example: custom field
Show code
<template>
<c-input v-model="search">
<template #field="field">
<div class="search-bar" :class="{ 'search-bar--focused': field.focused }">
<svg
class="search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
v-bind="field.attrs"
class="search-input"
placeholder="Search anything…"
:value="search"
@input="
(e: any) => {
search = e.target.value
}
"
@focus="field.focus"
@blur="field.blur"
/>
<kbd v-if="!search" class="search-kbd">⌘K</kbd>
<button v-else class="search-clear" @click="search = ''">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
width="14"
height="14"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
</template>
</c-input>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const search = ref('')
</script>Preset system
The preset system is the primary way to style CInput-based components. Instead of writing conditional CSS classes in every template, you define a preset once and reference it by name. The component resolves the right classes automatically for the current state.
Every value is an array of utility class names, so presets work with any utility-first CSS engine you have configured.
Zones
A flat preset (ZonePreset) maps zones to class lists. Zones map 1:1 to the rendered DOM:
CInput itself owns two zones:
root.c-input wrapperdetailsNested parts are described by their components' own presets and composed into the input snapshot by value — the same format standalone components use:
fieldCFieldPresetroot (.c-field), input, label, prepend, appendfocused filled error disabled readonlymenuCMenuPresetroot (.c-menu)opened closedlistCListPresetroot (.c-list), option (.c-list-item)disabled readonlyThe CInputPreset type
A preset is a set of flat presets keyed by state. base is the resting look; each state is its own complete flat preset:
type CInputZone = 'root' | 'details'
type CInputState = 'focused' | 'filled' | 'error' | 'disabled' | 'readonly'
// one snapshot: own zones + nested component presets (by value)
type CInputSnapshot = Partial<Record<CInputZone, string[]>> & {
field?: CFieldPreset
menu?: CMenuPreset
list?: CListPreset
}
// base + optional per-state snapshots — everything optional
type CInputPreset = Partial<Record<'base' | CInputState, CInputSnapshot>>There are no compound states. Nesting happens only at component boundaries: put a nested preset in base — its own states are resolved by that component itself.
One state at a time
The component is always in a single current state, and that state's preset is applied (or base when the field is at rest). The active state's zones replace the base zones per-zone; a zone the state doesn't define falls back to base. There's nothing to stack and no priorities to reason about — you just define a flat preset per state.
Why one state, not stacked?
Utility classes are !important with equal specificity, so stacking conflicting classes (say two bg-*) is resolved by stylesheet order, not by intent. Applying exactly one set of classes per zone keeps the result predictable.
Addressing a state directly
Each state is itself a flat preset, addressable by name.state. input.blue is the whole set; input.blue.focused is just the focused flat preset.
Registering presets
Presets are registered globally in createVuelandUI:
import { createVuelandUI } from '@vueland/ui'
import type { CInputPreset } from '@vueland/ui/types'
function makePreset(color: string): CInputPreset {
return {
base: {
field: {
base: { label: [color] },
focused: { label: [color], root: [color] },
filled: { label: [color] },
error: { label: ['text-red'], root: ['text-red'] },
readonly: { label: ['text-grey'] },
// `disabled` is dimmed by the component; add a state only to override
},
},
error: { details: ['text-red'] },
}
}
const vueland = createVuelandUI({
presets: {
input: {
blue: makePreset('text-blue'),
teal: makePreset('text-teal'),
},
},
})Then use the preset by name on any CInput-based component:
<c-text-field preset="input.blue" ... />
<c-text-field preset="input.teal" ... />CInput → CField distribution
You write a single preset. CInput resolves it, applies its own zones (root, details), and shares the set with the subtree through provide/inject. CField, CMenu and CList pick up their nested presets (field/menu/list) from the base snapshot and resolve their own states themselves. Each of them also has its own preset prop — it overrides the context, so standalone usage keeps working. Nothing is mutated globally.
All states at a glance
The example above uses preset="input.blue" across six states:
- Default —
basezones - Focused —
focusedreplaces base per-zone - Filled —
filledreplaces base (label floats up, keeps color) - Error —
errorreplaces base (red) - Disabled — interaction blocked; the component dims the field
- Readonly — value visible but not editable
API
Props
modelValueT | T[] | undefined | nullundefinedidstringuid, uid-label, uid-detailslabelstringfield slot)detailsstringnoDetailsbooleanfalseclearablebooleanfalseclearable into the field slotdisabledbooleanfalsearia-disabledreadonlybooleanfalsearia-readonly, blocks editingfocusedbooleanfalsedirtybooleanfalseroleCInputRoleuid prefixrulesValidateFn[][]validateOn'input' | 'blur''input'validationValueanyrules instead of modelValue — for fields where modelValue holds the displayed textpresetstringpresets object passed to createVuelandUI)CInputRole type
type CInputRole = 'combobox' | 'checkbox' | 'radio' | 'listbox''combobox'role="combobox", aria-haspopup="listbox", aria-controls, aria-expanded — for select / autocomplete activators'checkbox'aria-labelledby to the label'radio'aria-labelledby to the label'listbox'uid prefix)Slots
fieldCInputFieldSlotPropsdetailsCInputDetailsSlotPropsfield slot props
uidstring<input>attrsRecord<string, unknown>v-bindfocusedbooleanlabelstring | undefinedlabel propclearableboolean | undefinedclearable propdisabledboolean | undefineddisabled propreadonlyboolean | undefinedreadonly propdirtyboolean | undefineddirty proppresetCInputPreset | undefinedhasErrorbooleanerrorMessagestring | undefinedvalidatingbooleanfocus() => voidblur() => voidreset() => voidvalidate() => Promise<boolean>details slot props
uidstringerrorMessagestring | undefinedhasErrorbooleanvalidatingbooleandetailsstring | undefineddetails propEvents
update:focusedbooleanCInput is a renderless primitive: it accepts modelValue for validation and state, but it does not know how to update a custom field value. Update your source value inside the field slot.
Expose
validate() => Promise<boolean>reset() => voidfocus() => voiddisabled/readonlyblur() => voidisReadonly() => booleanreadonly prop valueisDisabled() => booleandisabled prop valueCForm integration
CInput automatically registers its validate method with the nearest parent CForm. When form.validate() is called, all registered fields are validated in parallel via Promise.all.
<template>
<c-form>
<template #default="{ validate }">
<c-input v-model="pin" :rules="rules">
<template #field="field">
<input
:id="field.uid"
v-bind="field.attrs"
:value="pin"
@input="(e: any) => {}"
@focus="field.focus"
@blur="field.blur"
/>
</template>
</c-input>
<button @click="validate">Validate</button>
</template>
</c-form>
</template>Automatic aria attributes
CInput computes aria attributes and passes them via field.attrs. Always spread v-bind="field.attrs" on the native element.
aria-labelledby="{uid}-label"label is set, or kind = checkbox/radioaria-labellabel is the only labelaria-describedby="{uid}-details"details prop or error message is presentaria-invalid="true"aria-errormessage="{uid}-details"aria-disabled="true"disabled = truearia-readonly="true"readonly = truearia-haspopup="listbox"kind = 'listbox'aria-controls="{uid}-menu"kind = 'listbox'aria-expandedkind = 'listbox' (updated on focus change)CSS variables
--c-input-details-heightvar(--c-sys-control-height-sm)--c-input-transition-durationvar(--c-sys-motion-duration-medium)--c-input-primary-colorvar(--c-sys-color-primary)--c-input-error-colorvar(--c-sys-color-error)--c-input-disabled-colorvar(--c-sys-color-disabled)--c-input-readonly-colorvar(--c-sys-color-readonly)Field backgrounds for
focused/readonly/disabled/errorstates are rendered byCField(--c-field-focused-bg-color,--c-field-readonly-bg-color,--c-field-disabled-bg-color,--c-field-error-bg-color), notCInput.
State CSS classes
c-input--focusedc-input--has-errordisabled/readonly)c-input--disableddisabled = truec-input--readonlyreadonly = truec-input--dirtydirty = truec-input--clearableclearable = true