Input
A customizable text input component with multiple types, variants, and validation states. Built with accessibility in mind and fully customizable through CSS custom properties.
Features
Accessible — Full keyboard support, ARIA attributes, screen reader friendly 5 Semantic Colors — primary, secondary, success, warning, error 6 Variants — solid, flat, bordered, outline, ghost, text Integrated Label — Support for wide inset labels that span across slots Helper Text — Add descriptive text or complex content below the input 3 Sizes — sm, md, lg 7 Input Types — text, email, password, search, url, tel, number Prefix/Suffix Slots — Add icons or buttons before/after input
Source Code
View Source Code
import { define, useField, html, inject, live, prop, ref } from '@vielzeug/craft';
import { computed, signal, watch } from '@vielzeug/ripple';
import type { TextFieldProps } from '../../shared';
import type { InputType, VisualVariant } from '../../types';
import { lifecycleSignal, createTextField } from '../../headless';
import { disablableBundle, FIELD_SIZE_PRESET, roundableBundle, sizableBundle, themableBundle } from '../../shared';
import '../../content/icon/icon';
import { fieldMixins, forcedColorsFocusMixin, sizeVariantMixin } from '../../styles';
import { FORM_CTX, useFormContext } from '../shared/form-context';
import componentStyles from './input.css?inline';
/** Input component properties */
export type SgInputEvents = {
change: { originalEvent: Event; value: string };
input: { originalEvent: Event; value: string };
};
export type SgInputProps = TextFieldProps<Exclude<VisualVariant, 'frost'>> & {
/** Autocomplete hint */
autocomplete?: string;
/** Show a clear (×) button when the field has a value */
clearable?: boolean;
/** Virtual keyboard hint for mobile devices */
inputmode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
/** Maximum character length — shows a counter below the input */
maxlength?: number;
/** Minimum character length */
minlength?: number;
/** HTML pattern attribute for client-side validation */
pattern?: string;
/**
* JS-only callback fired with the inner `<input>` element when it mounts,
* and with `null` when it unmounts. Intended for composed components that
* need imperative access to the raw input element.
* Set as a JS property: `bitInput.ref = (el) => { ... }`.
*/
ref?: ((el: HTMLInputElement | null) => void) | null;
/** HTML input type */
type?: InputType;
};
const VALID_INPUT_TYPES = [
'text',
'email',
'password',
'search',
'url',
'tel',
'number',
'date',
'time',
'datetime-local',
'month',
'week',
] as const;
/**
* A customizable text input component with multiple variants, label placements, and form features.
*
* @element sg-input
*
* @attr {string} label - Label text
* @attr {string} label-placement - Label placement: 'inset' | 'outside'
* @attr {string} type - HTML input type: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search'
* @attr {string} value - Current input value
* @attr {string} placeholder - Placeholder text
* @attr {string} name - Form field name
* @attr {string} helper - Helper text displayed below the input (fallback when the `helper` slot is empty)
* @attr {string} error - Error message — marks the field as invalid (fallback when the `error` slot is empty)
* @attr {boolean} disabled - Disable input interaction
* @attr {boolean} readonly - Make the input read-only
* @attr {boolean} required - Mark the field as required
* @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
* @attr {string} variant - Visual variant: 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text'
* @attr {string} size - Input size: 'sm' | 'md' | 'lg'
* @attr {string} rounded - Border radius: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
*
* @fires input - Emitted when input value changes (on every keystroke). detail: { value: string; originalEvent: Event }
* @fires change - Emitted when input loses focus with changed value. detail: { value: string; originalEvent: Event }
*
* @slot prefix - Content before the input (e.g., icons)
* @slot suffix - Content after the input (e.g., clear button, validation icon)
* @slot label - Replaces the label text — slotted content takes precedence over the `label` prop
* @slot helper - Replaces the helper text — slotted content takes precedence over the `helper` prop
* @slot error - Replaces the error text — slotted content takes precedence over the `error` prop
*
* @part wrapper - The input wrapper element
* @part label - The label element (inset or outside)
* @part field - The field container element
* @part input-row - The input row container element
* @part input - The input element
* @part helper - The helper text element
*
* @cssprop --input-bg - Background color
* @cssprop --input-color - Text color
* @cssprop --input-border-color - Border color
* @cssprop --input-placeholder-color - Placeholder text color
* @cssprop --input-radius - Border radius
* @cssprop --input-padding - Inner padding (block inline)
* @cssprop --input-gap - Gap between prefix/suffix icons and input text
* @cssprop --input-font-size - Font size
* @cssprop --input-height - Field height
* @cssprop --input-hover-bg - Field background on hover (flat/ghost variants)
* @cssprop --input-hover-border-color - Field border on hover (flat/bordered variants)
* @cssprop --input-focus-bg - Field background when focused (flat variant)
* @cssprop --input-focus-border-color - Field border when focused (flat/text variants)
*
* @example
* ```html
* <sg-input type="email" label="Email" placeholder="you@example.com" />
* <sg-input label="Name" variant="bordered" color="primary" />
* ```
*/
export const INPUT_TAG = 'sg-input' as const;
define<SgInputProps, SgInputEvents>(INPUT_TAG, {
formAssociated: true,
props: {
...themableBundle,
...sizableBundle,
...disablableBundle,
...roundableBundle,
autocomplete: prop.string(),
clearable: prop.bool(false),
error: prop.string(),
fullwidth: prop.bool(false),
helper: prop.string(),
inputmode: prop.string<'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'>(),
label: prop.string(),
'label-placement': prop.oneOf(['inset', 'outside'] as const, 'inset'),
maxlength: prop.json(undefined as number | undefined),
minlength: prop.json(undefined as number | undefined),
name: prop.string(),
pattern: prop.string(),
placeholder: prop.string(),
readonly: prop.bool(false),
ref: prop.json(undefined as ((el: HTMLInputElement | null) => void) | null | undefined),
required: prop.bool(false),
type: prop.oneOf(VALID_INPUT_TYPES, 'text'),
value: prop.string(),
variant: prop.string<'flat' | 'text' | 'solid' | 'bordered' | 'outline' | 'ghost'>(),
},
setup(props, { bind, emit, onCleanup, onElement, slots }) {
const formCtx = inject(FORM_CTX);
const fCtxProps = useFormContext(bind, props, formCtx);
const showPassword = signal(false);
const inputRef = ref<HTMLInputElement>();
const hasLabel = computed(() => !!props.label.value || slots.has('label').value);
const abortSignal = lifecycleSignal(onCleanup);
let _formField: { reportValidity(): void } | null = null;
const tf = createTextField({
disabled: fCtxProps.disabled,
error: props.error,
getFormField: () => _formField,
hasLabel,
helper: props.helper,
label: props.label,
labelPlacement: props['label-placement'],
maxLength: props.maxlength,
onChange: (event: Event, value: string) => {
emit('change', { originalEvent: event, value });
},
onInput: (event: Event, value: string) => {
emit('input', { originalEvent: event, value });
},
prefix: 'input',
signal: abortSignal,
validateOn: formCtx?.validateOn,
value: props.value,
});
_formField = useField<string>({ disabled: tf.disabled, toFormValue: (v) => v, value: tf.value });
const {
ariaDescribedBy,
ariaErrorMessage,
ariaInvalid,
ariaLabelledBy,
assistiveId,
clear: clearValue,
counter,
errorId,
errorText,
fieldId: inputId,
helperText,
labelId,
labelVisible,
value: fieldValue,
wire,
} = tf;
onElement(inputRef, (el) => {
wire(el, abortSignal);
// Immediate fire for when the prop is already set on mount.
props.ref.value?.(el);
// Reactive watcher so that if props.ref is set *after* the inner
// <input> mounts (e.g. parent sets it via a ref callback after render),
// the new callback still receives the live element.
const sub = watch(props.ref, (cb) => {
cb?.(el);
});
return () => {
sub.dispose();
props.ref.value?.(null);
};
});
const clear = (event?: Event): void => {
clearValue(event);
inputRef.value?.focus();
};
const resolvedInputType = (): string =>
props.type.value === 'password' && showPassword.value ? 'text' : (props.type.value ?? 'text');
bind({
attr: {
error: () => errorText.value || undefined,
'has-value': () => (fieldValue.value ? true : undefined),
size: fCtxProps.size,
variant: fCtxProps.variant,
},
});
const labelHidden = () => !labelVisible.value;
const passwordToggleLabel = () => (showPassword.value ? 'Hide password' : 'Show password');
const passwordTogglePressed = () => String(showPassword.value);
const passwordToggleIcon = () =>
showPassword.value
? html`<sg-icon name="eye-off" size="14" stroke-width="2" aria-hidden="true"></sg-icon>`
: html`<sg-icon name="eye" size="14" stroke-width="2" aria-hidden="true"></sg-icon>`;
const helperHidden = () => !!errorText.value || !helperText.value;
const errorHidden = () => !errorText.value;
const counterNearLimit = () => (counter?.value.counterNearLimit && !counter?.value.counterAtLimit ? '' : null);
const counterAtLimit = () => (counter?.value.counterAtLimit ? '' : null);
const counterHidden = () => !counter;
const counterText = () => counter?.value.counterText ?? '';
const clearTabIndex = () => (fieldValue.value ? '0' : '-1');
const pwdToggleTabIndex = () => (props.type.value === 'password' ? '0' : '-1');
const togglePassword = () => {
showPassword.value = !showPassword.value;
inputRef.value?.focus();
};
return html`
<div class="input-wrapper" part="wrapper">
<label class="label" for="${inputId}" id="${labelId}" part="label" ?hidden="${labelHidden}"
><slot name="label">${props.label}</slot></label
>
<div class="field" part="field">
<div class="input-row" part="input-row">
<slot name="prefix"></slot>
<input
part="input"
id="${inputId}"
:type="${resolvedInputType}"
:name="${props.name}"
:placeholder="${props.placeholder}"
:autocomplete="${props.autocomplete}"
:inputmode="${props.inputmode}"
:maxlength="${props.maxlength}"
:minlength="${props.minlength}"
:pattern="${props.pattern}"
?disabled="${props.disabled}"
?readonly="${props.readonly}"
?required="${props.required}"
:value="${live(fieldValue)}"
:aria-labelledby="${ariaLabelledBy}"
:aria-describedby="${ariaDescribedBy}"
:aria-errormessage="${ariaErrorMessage}"
:aria-invalid="${ariaInvalid}"
ref="${inputRef}" />
<slot name="suffix"></slot>
<button
class="pwd-toggle-btn"
part="pwd-toggle"
type="button"
:aria-label="${passwordToggleLabel}"
:aria-pressed="${passwordTogglePressed}"
:tabindex="${pwdToggleTabIndex}"
@click="${togglePassword}">
${passwordToggleIcon}
</button>
<button
aria-label="Clear"
class="clear-btn"
part="clear"
type="button"
:tabindex="${clearTabIndex}"
@click="${clear}">
<sg-icon aria-hidden="true" name="x" size="12" stroke-width="2.5"></sg-icon>
</button>
</div>
</div>
<div class="helper-text" aria-live="polite" id="${assistiveId}" part="helper" ?hidden="${helperHidden}">
<slot name="helper">${() => helperText.value}</slot>
</div>
<div class="helper-text" id="${errorId}" role="alert" part="error" ?hidden="${errorHidden}">
<slot name="error">${() => errorText.value}</slot>
</div>
<div
class="char-counter"
part="char-counter"
:data-near-limit="${counterNearLimit}"
:data-at-limit="${counterAtLimit}"
?hidden="${counterHidden}">
${counterText}
</div>
</div>
`;
},
shadow: { delegatesFocus: true },
styles: [...fieldMixins, sizeVariantMixin(FIELD_SIZE_PRESET), forcedColorsFocusMixin('input'), componentStyles],
});Basic Usage
<sg-input type="text" placeholder="Enter your name"></sg-input>Visual Options
Variants
Six visual variants for different UI contexts and levels of emphasis.
Colors
Six semantic colors for different contexts and validation states. Defaults to neutral when no color is specified.
Input Types
Different input types for various use cases.
Sizes
Three sizes for different contexts.
Rounded (Custom Border Radius)
Use the rounded attribute to apply border radius from the theme. Use it without a value (or rounded="full") for pill shape, or specify a theme value like "lg", "xl", etc.
Customization
Prefix & Suffix
Add prefix or suffix content like icons or clear buttons using slots.
Integrated Label
Use the label attribute to render an inset label inside the input field, creating a modern Material Design-style floating label effect.
Label with Variants
Labels work seamlessly with all variants and colors.
Label with Prefix/Suffix
Combine labels with prefix and suffix slots for rich input fields.
Label Placement
Labels can be placed inside the input field (default) or above it.
Helper Text
Provide additional context or validation messages below the input using the helper attribute or slot.
States
Disabled & Readonly
Prevent interaction or modification of the input.
API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
type | 'text' | 'email' | 'password' | 'search' | 'url' | 'tel' | 'number' | 'text' | Input type |
value | string | '' | Current input value |
name | string | '' | Form field name |
placeholder | string | '' | Placeholder text |
label | string | '' | Label text |
label-placement | 'inset' | 'outside' | 'inset' | Label placement |
helper | string | '' | Helper text below input |
disabled | boolean | false | Disable the input |
readonly | boolean | false | Make the input read-only |
required | boolean | false | Mark field as required |
fullwidth | boolean | false | Expand to full width |
size | 'sm' | 'md' | 'lg' | 'md' | Input size |
variant | 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text' | 'solid' | Visual variant |
color | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'primary' | Color theme |
Slots
| Slot | Description |
|---|---|
prefix | Content before the input (e.g., icon) |
suffix | Content after the input (e.g., clear button, unit) |
helper | Complex helper content below the input |
Events
| Event | Detail | Description |
|---|---|---|
input | { value: string, originalEvent: Event } | Emitted when the value changes (user input) |
change | { value: string, originalEvent: Event } | Emitted when value is committed (blur or Enter) |
CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--input-bg | Background color | Variant-dependent |
--input-color | Text color | Variant-dependent |
--input-border-color | Border color | Variant-dependent |
--input-placeholder-color | Placeholder text color | Theme-dependent |
--input-radius | Border radius | var(--rounded-lg) |
--input-padding | Inner padding (block inline) | Size-dependent |
--input-gap | Gap between prefix/suffix icons and input text | Size-dependent |
--input-font-size | Font size | Size-dependent |
--input-height | Field height | Size-dependent |
--input-hover-bg | Field background on hover (flat/ghost variants) | Variant-dependent |
--input-hover-border-color | Field border on hover (flat/bordered variants) | Variant-dependent |
--input-focus-bg | Field background when focused (flat variant) | Variant-dependent |
--input-focus-border-color | Field border when focused (flat/text variants) | Variant-dependent |
Accessibility
The input component follows WCAG 2.1 Level AA standards.
sg-input
Tabfocuses the input.- Native input behavior (Enter to commit, etc.).
- Proper ARIA states (disabled, required, readonly).
- Associated labels via
aria-labelor<label>.
Best Practices
Onboard with Clear Microcopy
For first-run forms, combine a visible label, helper text, and progressive validation feedback.
Do:
- Use the
labelattribute for integrated labels with a modern floating effect. - Use external
<label>elements when the label needs to be positioned outside the input. - Always provide a label via the
labelattribute,aria-label, or an associated<label>element. - Use the appropriate
typefor better mobile keyboards and validation. - Use semantic colors (
success,error,warning) to indicate validation states. - Combine labels with prefix/suffix slots for enhanced UX (e.g., currency symbols, icons).
Don't:
- Use placeholder text as a label replacement (placeholders disappear on input).
- Over-customize colors to the point of breaking contrast.
- Use labels for inputs that should remain visually minimal (ghost, text variants).