Forms
Input
Text field with a floating label
Preview
size
type
label
required
error
disabled
Code
import css from "./Input.module.css";
import { useId, useRef, useState } from "react";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface InputProps {
type: "text" | "number" | "email" | "password";
name: string;
autoComplete?: string;
size: Size | ResponsiveSize;
label: string;
required?: boolean;
error?: boolean;
disabled?: boolean;
value: string;
onChange: (value: string) => void;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Input({
type,
name,
autoComplete,
size,
label,
required,
error,
disabled,
value,
onChange,
}: InputProps) {
const [focused, setFocused] = useState(false);
const [tabbed, setTabbed] = useState(false);
const pointer = useRef(false);
const fieldId = useId();
const active = focused || value.length > 0;
const onFocus = () => {
setFocused(true);
setTabbed(!pointer.current);
};
const onBlur = () => {
setFocused(false);
setTabbed(false);
pointer.current = false;
};
const onPointerDown = () => {
pointer.current = true;
};
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) =>
onChange(ev.target.value);
return (
<div
className={`
${css.input}
${getSizeClasses(size)}
${active ? css.active : ""}
${tabbed ? css.tabbed : ""}
${error ? css.error : ""}
${disabled ? css.disabled : ""}
`}
onPointerDown={onPointerDown}
>
<input
id={fieldId}
type={type}
name={name}
autoComplete={autoComplete}
value={value}
required={required}
disabled={disabled}
aria-invalid={error}
onFocus={onFocus}
onBlur={onBlur}
onChange={handleChange}
/>
<fieldset className={css.input_fieldset}>
<legend className={css.fieldset_legend}>{label}</legend>
</fieldset>
<label htmlFor={fieldId} className={css.input_label}>
{label}
{required && <span aria-hidden="true">*</span>}
</label>
</div>
);
}
.input {
width: 100%;
position: relative;
/* The field ladder: the height steps every tier, the font plateaus at --text-16. */
&.xs {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm {
height: var(--size-40);
font-size: var(--text-14);
}
&.md {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg {
height: var(--size-48);
font-size: var(--text-16);
}
&.xs_base {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm_base {
height: var(--size-40);
font-size: var(--text-14);
}
&.md_base {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg_base {
height: var(--size-48);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm_mobile {
height: var(--size-40);
font-size: var(--text-14);
}
&.md_mobile {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg_mobile {
height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm_tablet {
height: var(--size-40);
font-size: var(--text-14);
}
&.md_tablet {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg_tablet {
height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm_desktop {
height: var(--size-40);
font-size: var(--text-14);
}
&.md_desktop {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg_desktop {
height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm_wide {
height: var(--size-40);
font-size: var(--text-14);
}
&.md_wide {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg_wide {
height: var(--size-48);
font-size: var(--text-16);
}
}
&:has(input:focus-visible) .input_fieldset {
border-color: var(--color-contour-accent);
}
/* On text fields :focus-visible also fires on click, so the Tab signal needs its
own class. The repeated :has() outranks .active, and .error still wins. */
&.tabbed:has(input:focus-visible) .input_fieldset {
border-width: 2px;
border-color: var(--color-accent);
}
input {
width: 100%;
height: 100%;
padding: 0 var(--space-12);
background: none;
border: 0;
border-radius: var(--radius-8);
outline: none;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
/* iOS zooms in on a focused field under 16px; a phone's short side is under 600, a tablet's
744+. */
@media (pointer: coarse) and (max-width: 599px),
(pointer: coarse) and (max-height: 599px) {
font-size: max(1em, 16px);
}
}
.input_fieldset {
position: absolute;
inset: 0;
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-8);
padding: 0;
margin: 0;
pointer-events: none;
.fieldset_legend {
width: 0;
height: 0;
overflow: hidden;
padding: 0;
margin-left: var(--space-8);
/* The notch mirrors the floated label: 0.875em (inherit × its scale) and
weight 600, so it fits at every size. */
font-size: 0.875em;
font-weight: var(--weight-600);
visibility: hidden;
transition: width 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.input_label {
position: absolute;
top: 50%;
left: var(--space-12);
transform: translateY(-50%);
transform-origin: left center;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
pointer-events: none;
/* The float shrinks with transform, not font-size: smooth, and instant when the size changes at
rest. */
transition:
top 0.2s ease,
left 0.2s ease,
transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
&.active {
.input_fieldset {
border-color: var(--color-contour-accent);
.fieldset_legend {
width: auto;
padding: 0 var(--space-12);
}
}
.input_label {
top: 0;
left: var(--space-20);
transform: translateY(-65%) scale(0.875);
font-weight: var(--weight-600);
}
}
&.error {
.input_fieldset {
border-color: var(--color-error);
}
/* Red wins even when focused: it overrides the accent from :focus-visible */
&.active .input_fieldset,
&:has(input:focus-visible) .input_fieldset {
border-color: color-mix(in srgb, var(--color-error) 99%, transparent);
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
<script setup lang="ts">
import { computed, ref, useId } from "vue";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface InputProps {
type: "text" | "number" | "email" | "password";
name: string;
autoComplete?: string;
size: Size | ResponsiveSize;
label: string;
required?: boolean;
error?: boolean;
disabled?: boolean;
}
const { size } = defineProps<InputProps>();
const model = defineModel<string>({ required: true });
const focused = ref(false);
const tabbed = ref(false);
let pointer = false;
const fieldId = useId();
const active = computed(() => focused.value || model.value.length > 0);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const onFocus = () => {
focused.value = true;
tabbed.value = !pointer;
};
const onBlur = () => {
focused.value = false;
tabbed.value = false;
pointer = false;
};
const onPointerDown = () => {
pointer = true;
};
</script>
<template>
<div
class="input"
:class="[sizeClasses, { active, error, disabled, tabbed }]"
@pointerdown="onPointerDown"
>
<input
:id="fieldId"
v-model="model"
:type="type"
:name="name"
:autocomplete="autoComplete"
:required="required"
:disabled="disabled"
:aria-invalid="error"
@focus="onFocus"
@blur="onBlur"
/>
<fieldset class="input_fieldset">
<legend class="fieldset_legend">{{ label }}</legend>
</fieldset>
<label :for="fieldId" class="input_label">
{{ label }}<span v-if="required" aria-hidden="true">*</span>
</label>
</div>
</template>
<style scoped>
.input {
width: 100%;
position: relative;
/* The field ladder: the height steps every tier, the font plateaus at --text-16. */
&.xs {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm {
height: var(--size-40);
font-size: var(--text-14);
}
&.md {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg {
height: var(--size-48);
font-size: var(--text-16);
}
&.xs_base {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm_base {
height: var(--size-40);
font-size: var(--text-14);
}
&.md_base {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg_base {
height: var(--size-48);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm_mobile {
height: var(--size-40);
font-size: var(--text-14);
}
&.md_mobile {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg_mobile {
height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm_tablet {
height: var(--size-40);
font-size: var(--text-14);
}
&.md_tablet {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg_tablet {
height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm_desktop {
height: var(--size-40);
font-size: var(--text-14);
}
&.md_desktop {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg_desktop {
height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
height: var(--size-36);
font-size: var(--text-14);
}
&.sm_wide {
height: var(--size-40);
font-size: var(--text-14);
}
&.md_wide {
height: var(--size-44);
font-size: var(--text-16);
}
&.lg_wide {
height: var(--size-48);
font-size: var(--text-16);
}
}
&:has(input:focus-visible) .input_fieldset {
border-color: var(--color-contour-accent);
}
/* On text fields :focus-visible also fires on click, so the Tab signal needs its
own class. The repeated :has() outranks .active, and .error still wins. */
&.tabbed:has(input:focus-visible) .input_fieldset {
border-width: 2px;
border-color: var(--color-accent);
}
input {
width: 100%;
height: 100%;
padding: 0 var(--space-12);
background: none;
border: 0;
border-radius: var(--radius-8);
outline: none;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
/* iOS zooms in on a focused field under 16px; a phone's short side is under 600, a tablet's
744+. */
@media (pointer: coarse) and (max-width: 599px),
(pointer: coarse) and (max-height: 599px) {
font-size: max(1em, 16px);
}
}
.input_fieldset {
position: absolute;
inset: 0;
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-8);
padding: 0;
margin: 0;
pointer-events: none;
.fieldset_legend {
width: 0;
height: 0;
overflow: hidden;
padding: 0;
margin-left: var(--space-8);
/* The notch mirrors the floated label: 0.875em (inherit × its scale) and
weight 600, so it fits at every size. */
font-size: 0.875em;
font-weight: var(--weight-600);
visibility: hidden;
transition: width 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.input_label {
position: absolute;
top: 50%;
left: var(--space-12);
transform: translateY(-50%);
transform-origin: left center;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
pointer-events: none;
/* The float shrinks with transform, not font-size: smooth, and instant when the size changes at
rest. */
transition:
top 0.2s ease,
left 0.2s ease,
transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
&.active {
.input_fieldset {
border-color: var(--color-contour-accent);
.fieldset_legend {
width: auto;
padding: 0 var(--space-12);
}
}
.input_label {
top: 0;
left: var(--space-20);
transform: translateY(-65%) scale(0.875);
font-weight: var(--weight-600);
}
}
&.error {
.input_fieldset {
border-color: var(--color-error);
}
/* Red wins even when focused: it overrides the accent from :focus-visible */
&.active .input_fieldset,
&:has(input:focus-visible) .input_fieldset {
border-color: color-mix(in srgb, var(--color-error) 99%, transparent);
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| type | "text" | "number" | "email" | "password" | required | Native field type. |
| name | string | required | Field name, for forms and browser autofill. |
| autoComplete | string | — | Autocomplete hint for the browser (e.g. "email"). |
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Field height and its text size. Fixed or responsive. |
| label | string | required | Floating label that rises on focus or when filled. |
| required | boolean | — | Marks the field as required (adds * to the label and the native required). |
| error | boolean | — | Visual error state (red border, aria-invalid). |
| disabled | boolean | — | Disables the field (not editable, reduced opacity). |
| value | string | required | Field value (controlled). |
| onChange | (value: string) => void | required | Callback with the new value on every change. |