Skip to content

cv-input

Single-line text input control supporting text-like types, clearable behavior, and password visibility toggling.

Headless: createInput

Usage

View source
html
<div class="input-demo-shell" data-demo="input" data-live-demo-height="860">
  <section class="input-demo-hero" aria-labelledby="input-demo-title">
    <div class="input-demo-copy">
      <span class="input-demo-kicker">Text entry primitive</span>
      <h3 id="input-demo-title">Use input when a visible value needs native editing and headless state.</h3>
      <p>
        The headless input model owns value, focus, clearing, readonly/disabled state, password visibility,
        and form validity. UIKit renders the field surface, slots, size, variant, and preset density.
      </p>
    </div>

    <dl class="input-demo-metrics" aria-label="Input contract summary">
      <div>
        <dt>States</dt>
        <dd>focused / filled / invalid / readonly / disabled</dd>
      </div>
      <div>
        <dt>Actions</dt>
        <dd>input / change / clear / password toggle</dd>
      </div>
      <div>
        <dt>Surface</dt>
        <dd>outlined / filled / search-mobile</dd>
      </div>
    </dl>
  </section>

  <section class="input-demo-board" aria-label="Input examples in a vault record form">
    <form class="input-demo-form" data-input-form>
      <div class="input-demo-form-head">
        <div>
          <span>Visible vault record</span>
          <strong>border-relay.admin</strong>
        </div>
        <cv-badge variant="primary" pill>editable route</cv-badge>
      </div>

      <div class="input-demo-field-grid">
        <cv-field required>
          <span slot="label">Visible alias</span>
          <cv-input data-input-primary name="alias" value="border-relay.admin" clearable autocomplete="off">
            <span slot="prefix" aria-hidden="true">cv://</span>
          </cv-input>
          <span slot="description">Clearable text input with a prefix slot and live value events.</span>
        </cv-field>

        <cv-field>
          <span slot="label">Recovery email</span>
          <cv-input
            name="email"
            type="email"
            value="alex@chromvoid.local"
            clearable
            autocomplete="email"
          ></cv-input>
          <span slot="description">Text-like native types keep keyboard and validation hints intact.</span>
        </cv-field>

        <cv-field>
          <span slot="label">Local secret</span>
          <cv-input type="password" password-toggle clearable value="decoy-key-4589" autocomplete="off">
            <span slot="prefix" aria-hidden="true">key</span>
          </cv-input>
          <span slot="description">Password visibility is part of the input model, not local DOM state.</span>
        </cv-field>

        <cv-field>
          <span slot="label">Search visible layer</span>
          <cv-input
            preset="search-mobile"
            variant="filled"
            type="search"
            placeholder="Search visible vault"
            value="relay"
            clearable
          ></cv-input>
          <span slot="description">The mobile search preset changes density through component tokens.</span>
        </cv-field>
      </div>
    </form>

    <aside class="input-demo-side" aria-label="Input event output">
      <div class="input-demo-side-head">
        <span class="input-demo-kicker">Event stream</span>
        <h4>Interact with any active field to inspect the public contract.</h4>
      </div>

      <p class="input-demo-log" role="status" aria-live="polite" data-input-output>
        Waiting for input. Type, blur, clear, or toggle the password control.
      </p>

      <dl class="input-demo-live" aria-label="Live input state">
        <div>
          <dt>Primary value</dt>
          <dd data-input-mirror>border-relay.admin</dd>
        </div>
        <div>
          <dt>Last field</dt>
          <dd data-input-active>none</dd>
        </div>
      </dl>
    </aside>
  </section>

  <section class="input-demo-section" aria-labelledby="input-demo-matrix-title">
    <div class="input-demo-section-header">
      <span class="input-demo-kicker">Variants, sizes, and field states</span>
      <h4 id="input-demo-matrix-title">
        Keep one input contract, then tune emphasis with variant, size, slots, or field state.
      </h4>
    </div>

    <div class="input-demo-matrix" aria-label="Input state matrix">
      <div>
        <span>Variant</span>
        <cv-input placeholder="Outlined default"></cv-input>
        <cv-input variant="filled" value="Filled surface"></cv-input>
      </div>

      <div>
        <span>Size</span>
        <cv-input size="small" value="Small"></cv-input>
        <cv-input value="Medium"></cv-input>
        <cv-input size="large" value="Large"></cv-input>
      </div>

      <div>
        <span>Affixes</span>
        <cv-input value="chromvoid">
          <span slot="prefix" aria-hidden="true">@</span>
          <span slot="suffix">.app</span>
        </cv-input>
        <cv-input value="/vault/visible" clearable>
          <span slot="prefix" aria-hidden="true">path</span>
        </cv-input>
      </div>

      <div>
        <span>Validation</span>
        <cv-field required invalid>
          <span slot="label">Policy route</span>
          <cv-input value="unknown relay"></cv-input>
          <span slot="error">Route is not available in the visible profile.</span>
        </cv-field>
      </div>

      <div>
        <span>Read state</span>
        <cv-input readonly value="Readonly but focusable"></cv-input>
        <cv-field disabled>
          <span slot="label">Disabled by field</span>
          <cv-input value="Locked by policy"></cv-input>
        </cv-field>
      </div>

      <div>
        <span>Native type</span>
        <cv-input type="url" value="https://relay.chromvoid.local" clearable></cv-input>
        <cv-input type="tel" placeholder="+1 555 0100"></cv-input>
      </div>
    </div>
  </section>
</div>

<script type="module">
  document.querySelectorAll('.input-demo-shell[data-demo="input"]:not([data-ready])').forEach((shell) => {
    shell.dataset.ready = 'true'

    const form = shell.querySelector('[data-input-form]')
    const output = shell.querySelector('[data-input-output]')
    const mirror = shell.querySelector('[data-input-mirror]')
    const active = shell.querySelector('[data-input-active]')
    const primaryInput = shell.querySelector('[data-input-primary]')
    const eventLabels = {
      'cv-input': 'input',
      'cv-change': 'change',
      'cv-clear': 'clear',
      'cv-focus': 'focus',
      'cv-blur': 'blur',
    }

    const setOutput = (message) => {
      if (output) output.textContent = message
    }

    const getFieldName = (input) => {
      const label = input.closest('cv-field')?.querySelector('[slot="label"]')?.textContent?.trim()
      return label || input.getAttribute('name') || 'input'
    }

    const formatValue = (input, value) => {
      if (input?.type === 'password') return `${value.length} characters`
      return value || 'empty'
    }

    const report = (event) => {
      const input = event.target instanceof HTMLElement ? event.target : null
      if (!input?.matches('cv-input')) return

      const name = getFieldName(input)
      const label = eventLabels[event.type] ?? event.type
      const value = 'value' in event.detail ? String(event.detail.value) : input.value || ''

      if (active) active.textContent = name
      setOutput(`${label}: ${name} -> ${formatValue(input, value)}`)
    }

    primaryInput?.addEventListener('cv-input', (event) => {
      if (mirror) mirror.textContent = event.detail.value || 'empty'
    })

    form?.addEventListener('submit', (event) => {
      event.preventDefault()
      setOutput('submit: native Enter handling reached the form boundary.')
    })

    Object.keys(eventLabels).forEach((eventName) => {
      shell.addEventListener(eventName, report)
    })
  })
</script>

Anatomy

<cv-input> (host)
└── <div part="base">
    ├── <span part="prefix">
    │   └── <slot name="prefix">
    ├── <input part="input" />
    ├── <span part="clear-button">
    │   └── <slot name="clear-icon">×</slot>
    ├── <span part="password-toggle">
    │   └── <slot name="show-password-icon|hide-password-icon">
    └── <span part="suffix">
        └── <slot name="suffix">

Attributes

AttributeTypeDefaultReflectsDescription
valueString""noCurrent input value
typeInputType"text"noInput type: text | password | email | url | tel | search
placeholderString""noPlaceholder text displayed when the input is empty
disabledBooleanfalseyesPrevents interaction and dims the component
readonlyBooleanfalseyesPrevents editing while keeping the input focusable
requiredBooleanfalseyesMarks the input as required for form validation
clearableBooleanfalseyesShows a clear button when the input has a value
password-toggleBooleanfalseyesShows a password visibility toggle (only effective when type="password")
sizeString"medium"yesComponent size: small | medium | large
variantString"outlined"yesVisual variant: outlined | filled
presetStringyesSemantic preset: search-mobile
nameString""noName for form association
aria-labelledbyString""noID reference passed to the native input
aria-describedbyString""noDescription/error ID reference passed to the native input

Variants

VariantDescription
outlinedDefault style with visible border and transparent background
filledSubtle background fill with no visible border

Sizes

Size--cv-input-height--cv-input-padding-inline--cv-input-font-size
small30pxvar(--cv-space-2, 8px)var(--cv-font-size-sm, 13px)
medium36pxvar(--cv-space-3, 12px)var(--cv-font-size-base, 14px)
large42pxvar(--cv-space-4, 16px)var(--cv-font-size-md, 16px)

Presets

PresetDescription
search-mobileMobile search/input density with 42px height, 14px radius, and search tokens

Slots

SlotDescription
prefixContent rendered before the input (e.g., icon)
suffixContent rendered after the input (e.g., icon)
clear-iconCustom icon for the clear button (default: ×)
show-password-iconCustom icon for the "show password" state
hide-password-iconCustom icon for the "hide password" state

Note: The native <input> element is not slottable. There is no default slot.

CSS Parts

PartElementDescription
base<div>Outermost wrapper element
input<input>The native input element
prefix<span>Wrapper around the prefix slot
suffix<span>Wrapper around the suffix slot
clear-button<span>The clear button wrapper
password-toggle<span>The password toggle button wrapper

CSS Custom Properties

PropertyDefaultDescription
--cv-input-height36pxComponent block size
--cv-input-padding-inlinevar(--cv-space-3, 12px)Horizontal padding inside the input
--cv-input-font-sizevar(--cv-font-size-base, 14px)Font size of the input text
--cv-input-border-radiusvar(--cv-radius-sm, 6px)Border radius of the input container
--cv-input-border-colorvar(--cv-color-border, #2a3245)Border color in default state
--cv-input-backgroundtransparentBackground color of the input container
--cv-input-colorvar(--cv-color-text, #e8ecf6)Text color of the input value
--cv-input-placeholder-colorvar(--cv-color-text-muted, #6b7a99)Placeholder text color
--cv-input-focus-ring0 0 0 2px var(--cv-color-primary, #65d7ff)Box-shadow applied on focus
--cv-input-icon-size1emSize of prefix/suffix/clear/toggle icons
--cv-input-gapvar(--cv-space-2, 8px)Spacing between inner elements (prefix, input, suffix, buttons)
--cv-input-transition-durationvar(--cv-duration-fast, 120ms)Transition duration for state changes

Additionally, component styles depend on theme tokens through fallback values:

Theme PropertyDefaultDescription
--cv-color-border#2a3245Base border color
--cv-color-surface#141923Surface background color (used by filled variant)
--cv-color-text#e8ecf6Default text color
--cv-color-text-muted#6b7a99Muted text color for placeholder
--cv-color-primary#65d7ffPrimary accent color for focus ring
--cv-duration-fast120msTransition duration
--cv-easing-standardeaseTransition timing function
--cv-radius-sm6pxBase border radius fallback
--cv-font-size-sm13pxSmall font size
--cv-font-size-base14pxBase font size
--cv-font-size-md16pxMedium font size
--cv-space-28pxSpacing scale: small
--cv-space-312pxSpacing scale: medium
--cv-space-416pxSpacing scale: large

Visual States

Host selectorDescription
:host([disabled])Reduced opacity (0.55), cursor: not-allowed, no interaction
:host([readonly])Normal opacity, cursor: default, input text not editable
:host([required])No visual change by default (can be styled via part selectors)
:host([focused])Focus ring applied via --cv-input-focus-ring
:host([filled])Indicates non-empty value (e.g., for floating label transitions)
:host([clearable])Clear button space reserved in layout
:host([password-toggle])Password toggle button space reserved in layout
:host([size="small"])Small size overrides
:host([size="large"])Large size overrides
:host([variant="outlined"])Visible border, transparent background
:host([variant="filled"])Subtle background (--cv-color-surface), no visible border

Reactive State Mapping

cv-input is a visual adapter over headless createInput.

UIKit properties to headless actions

UIKit PropertyDirectionHeadless Binding
valueattr/prop -> actionactions.setValue(value)
typeattr -> actionactions.setType(type)
disabledattr -> actionactions.setDisabled(value)
readonlyattr -> actionactions.setReadonly(value)
requiredattr -> actionactions.setRequired(value)
placeholderattr -> actionactions.setPlaceholder(value)
clearableattr -> actionactions.setClearable(value)
password-toggleattr -> actionactions.setPasswordToggle(value)

Headless state to DOM reflection

Headless StateDirectionDOM Reflection
state.disabled()state -> attr[disabled] host attribute
state.readonly()state -> attr[readonly] host attribute
state.required()state -> attr[required] host attribute
state.focused()state -> attr[focused] host attribute
state.filled()state -> attr[filled] host attribute
state.passwordVisible()state -> DOMtoggles between show-password-icon / hide-password-icon slots
state.showClearButton()state -> DOMshows/hides the clear button element
state.showPasswordToggle()state -> DOMshows/hides the password toggle element
state.resolvedType()state -> DOMapplied as type attribute on the native <input>

Contract props spreading

  • contracts.getInputProps() is spread onto the [part="input"] native <input> element to apply id, type, aria-disabled, aria-readonly, aria-required, placeholder, disabled, readonly, tabindex, and autocomplete.
  • Host aria-labelledby and aria-describedby are passed through to the native <input>, typically from cv-field.
  • contracts.getClearButtonProps() is spread onto the [part="clear-button"] element to apply role, aria-label, tabindex, hidden, and aria-hidden.
  • contracts.getPasswordToggleProps() is spread onto the [part="password-toggle"] element to apply role, aria-label, aria-pressed, tabindex, hidden, and aria-hidden.

Event wiring

  • Native <input> input event -> actions.handleInput(e.target.value) -> dispatches cv-input CustomEvent
  • Native <input> keydown event -> actions.handleKeyDown(e) -> may trigger actions.clear() -> dispatches cv-clear CustomEvent
  • Native <input> focus event -> actions.setFocused(true) -> dispatches cv-focus CustomEvent
  • Native <input> blur event -> actions.setFocused(false) -> dispatches cv-blur CustomEvent; if value changed since focus, dispatches cv-change CustomEvent
  • Clear button click -> actions.clear() -> dispatches cv-clear CustomEvent
  • Password toggle click -> actions.togglePasswordVisibility()

UIKit does not own value management, type resolution, clearable logic, or password toggle logic; headless state is the source of truth.

Events

EventDetailDescription
cv-input{ value: string }Fires on value change from user interaction (native input event), not from programmatic setValue
cv-change{ value: string }Fires on value commit (blur after the value changed since focus)
cv-clear{ }Fires when the value is cleared via the clear button or Escape key
cv-focus{ }Fires when the input receives focus
cv-blur{ }Fires when the input loses focus

ChromVoid UIKit documentation