Forms
TextField
Compact text field with no visible label, named through aria-label
Preview
size
placeholder
disabled
Code
import css from "./TextField.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 TextFieldProps {
size: Size | ResponsiveSize;
// Named after the attribute it becomes: the field carries no visible label, only an accessible
// one.
"aria-label": string;
placeholder?: string;
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 TextField({
size,
"aria-label": ariaLabel,
placeholder,
disabled,
value,
onChange,
}: TextFieldProps) {
// Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
// identify.
const fieldId = useId();
const [tabbed, setTabbed] = useState(false);
const pointer = useRef(false);
const onFocus = () => setTabbed(!pointer.current);
const onBlur = () => {
setTabbed(false);
pointer.current = false;
};
const onPointerDown = () => {
pointer.current = true;
};
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) =>
onChange(ev.target.value);
return (
<input
id={fieldId}
type="text"
className={`${css.field} ${getSizeClasses(size)} ${tabbed ? css.tabbed : ""} ${disabled ? css.disabled : ""}`}
value={value}
aria-label={ariaLabel}
placeholder={placeholder}
disabled={disabled}
onPointerDown={onPointerDown}
onFocus={onFocus}
onBlur={onBlur}
onChange={handleChange}
/>
);
}
.field {
width: 100%;
/* min-width: 0 — an input's intrinsic minimum (~20 characters) would override the 100% in a flex
row. */
min-width: 0;
padding: 0 var(--space-12);
background: none;
border: var(--border-surface) solid var(--color-border-control);
border-radius: var(--radius-8);
font-family: var(--font-body);
font-size: var(--text-14);
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: var(--text-16);
}
/* Only the height steps; the font stays at --text-14 across the whole scale. */
&.xs {
height: var(--size-36);
}
&.sm {
height: var(--size-40);
}
&.md {
height: var(--size-44);
}
&.lg {
height: var(--size-48);
}
&.xs_base {
height: var(--size-36);
}
&.sm_base {
height: var(--size-40);
}
&.md_base {
height: var(--size-44);
}
&.lg_base {
height: var(--size-48);
}
@media (min-width: 480px) {
&.xs_mobile {
height: var(--size-36);
}
&.sm_mobile {
height: var(--size-40);
}
&.md_mobile {
height: var(--size-44);
}
&.lg_mobile {
height: var(--size-48);
}
}
@media (min-width: 768px) {
&.xs_tablet {
height: var(--size-36);
}
&.sm_tablet {
height: var(--size-40);
}
&.md_tablet {
height: var(--size-44);
}
&.lg_tablet {
height: var(--size-48);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
height: var(--size-36);
}
&.sm_desktop {
height: var(--size-40);
}
&.md_desktop {
height: var(--size-44);
}
&.lg_desktop {
height: var(--size-48);
}
}
@media (min-width: 1440px) {
&.xs_wide {
height: var(--size-36);
}
&.sm_wide {
height: var(--size-40);
}
&.md_wide {
height: var(--size-44);
}
&.lg_wide {
height: var(--size-48);
}
}
/* Chrome's default placeholder fails contrast in light; Firefox fades it with opacity, hence
opacity: 1. */
&::placeholder {
color: var(--color-text-secondary);
opacity: 1;
}
/* Beats the global ring at (0,2,0): the contour carries the focus signal. */
&:focus-visible {
border-color: var(--color-contour-accent);
outline: none;
}
/* On text fields :focus-visible also fires on click, so the Tab
signal needs its own class. An inset shadow, not border-width:
with border-box a thicker border shifts the caret 1px. */
&.tabbed {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
&.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 TextFieldProps {
size: Size | ResponsiveSize;
// Named after the attribute it becomes: the field carries no visible label, only an accessible
// one.
ariaLabel: string;
placeholder?: string;
disabled?: boolean;
}
const { size } = defineProps<TextFieldProps>();
const model = defineModel<string>({ required: true });
// Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
// identify.
const fieldId = useId();
const tabbed = ref(false);
let pointer = false;
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 = () => {
tabbed.value = !pointer;
};
const onBlur = () => {
tabbed.value = false;
pointer = false;
};
const onPointerDown = () => {
pointer = true;
};
</script>
<template>
<input
:id="fieldId"
type="text"
class="field"
:class="[sizeClasses, { tabbed, disabled }]"
v-model="model"
:aria-label="ariaLabel"
:placeholder="placeholder"
:disabled="disabled"
@pointerdown="onPointerDown"
@focus="onFocus"
@blur="onBlur"
/>
</template>
<style scoped>
.field {
width: 100%;
/* min-width: 0 — an input's intrinsic minimum (~20 characters) would override the 100% in a flex
row. */
min-width: 0;
padding: 0 var(--space-12);
background: none;
border: var(--border-surface) solid var(--color-border-control);
border-radius: var(--radius-8);
font-family: var(--font-body);
font-size: var(--text-14);
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: var(--text-16);
}
/* Only the height steps; the font stays at --text-14 across the whole scale. */
&.xs {
height: var(--size-36);
}
&.sm {
height: var(--size-40);
}
&.md {
height: var(--size-44);
}
&.lg {
height: var(--size-48);
}
&.xs_base {
height: var(--size-36);
}
&.sm_base {
height: var(--size-40);
}
&.md_base {
height: var(--size-44);
}
&.lg_base {
height: var(--size-48);
}
@media (min-width: 480px) {
&.xs_mobile {
height: var(--size-36);
}
&.sm_mobile {
height: var(--size-40);
}
&.md_mobile {
height: var(--size-44);
}
&.lg_mobile {
height: var(--size-48);
}
}
@media (min-width: 768px) {
&.xs_tablet {
height: var(--size-36);
}
&.sm_tablet {
height: var(--size-40);
}
&.md_tablet {
height: var(--size-44);
}
&.lg_tablet {
height: var(--size-48);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
height: var(--size-36);
}
&.sm_desktop {
height: var(--size-40);
}
&.md_desktop {
height: var(--size-44);
}
&.lg_desktop {
height: var(--size-48);
}
}
@media (min-width: 1440px) {
&.xs_wide {
height: var(--size-36);
}
&.sm_wide {
height: var(--size-40);
}
&.md_wide {
height: var(--size-44);
}
&.lg_wide {
height: var(--size-48);
}
}
/* Chrome's default placeholder fails contrast in light; Firefox fades it with opacity, hence
opacity: 1. */
&::placeholder {
color: var(--color-text-secondary);
opacity: 1;
}
/* Beats the global ring at (0,2,0): the contour carries the focus signal. */
&:focus-visible {
border-color: var(--color-contour-accent);
outline: none;
}
/* On text fields :focus-visible also fires on click, so the Tab
signal needs its own class. An inset shadow, not border-width:
with border-box a thicker border shifts the caret 1px. */
&.tabbed {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Field height. Fixed or responsive. |
| aria-label | string | required | Name of the field for screen readers; the field draws no label. |
| placeholder | string | — | Hint inside the field while it is empty. Not a substitute for the name. |
| 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. |