Navigation

v0.0.4
Forms

Input

Text fields cut into the surface, with labels, hints, errors and groups.

On this page

A text field is a well cut into its surface, edged by a half-pixel contour. On focus it rises to the sheet and takes a single ink contour with a soft halo. Use .input on <input> and <textarea>, and .field on the <label> that holds it.

Input

<label class="field">Email  <input class="input" type="email" placeholder="you@example.com"></label>

#Hint and error

Connect a hint or an error to its field with aria-describedby, and mark an invalid value with aria-invalid="true". A field-error shows only while its field is invalid, and replaces the hint while it shows, so both can be in the markup from the start.

Hint and error

<label class="field">Project name  <input class="input" value="atlas" aria-describedby="name-hint">  <span class="field-hint" id="name-hint">Lowercase letters, numbers and dashes.</span></label><label class="field">Domain  <input class="input" value="atlas" aria-invalid="true" aria-describedby="domain-error">  <span class="field-error" id="domain-error">Enter a full domain, such as atlas.dev.</span></label>

#Native validation

The same states follow the browser's own constraints, without script. A field with required, a type or a pattern takes the error contour through :user-invalid, which matches only after the reader has edited the field or tried to submit, never on a fresh form. The field-error appears with it.

Native validation

<form class="flex flex-col gap-md">  <label class="field">Work email    <input class="input" type="email" required placeholder="you@company.com" aria-describedby="work-email-hint work-email-error">    <span class="field-hint" id="work-email-hint">Type something that is not an email, then leave the field.</span>    <span class="field-error" id="work-email-error">Enter an email address, such as ada@example.com.</span>  </label>  <button type="submit" class="btn btn-primary self-start">Continue</button></form>

#Sizes

A field is 32, 38 or 44px tall: the heights of a button, a select and a segmented control, so any of them share a row with no step between them. On touch screens every size is 44px.

Sizes

<input class="input input-sm" placeholder="Small" aria-label="Small"><input class="input" placeholder="Medium" aria-label="Medium"><input class="input input-lg" placeholder="Large" aria-label="Large">

#Textarea

A <textarea> with .input keeps the rows you give it and a vertical resize handle. Add input-auto to let it grow with its content instead: field-sizing: content sizes it from three lines up to twelve, then it scrolls. Set --input-rows and --input-max-rows to change the range. In a browser without field-sizing it keeps its minimum height and the handle.

Textarea

<label class="field">Message  <textarea class="input" rows="3" placeholder="A fixed height with a resize handle"></textarea></label><label class="field">Notes  <textarea class="input input-auto" placeholder="Keep typing: this one grows with its content"></textarea></label>

#Input group

input-group is one well around a bare field and what belongs to it: an icon, a unit, a shortcut or a button. The group draws the hover, focus, invalid and disabled states of the field inside it through :has(); the field itself, an input-group-field, keeps the semantics and the caret. Make the group a <label> so the whole well focuses the field.

Input group

<label class="input-group">  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">    <circle cx="11" cy="11" r="6" />    <path d="m20 20-4.35-4.35" />  </svg>  <input class="input-group-field" type="search" placeholder="Search projects" aria-label="Search projects">  <kbd class="kbd">⌘K</kbd></label><label class="input-group">  <span class="input-group-addon">https://</span>  <input class="input-group-field" value="atlas" aria-label="Subdomain">  <span class="input-group-addon">.master.co</span></label><label class="input-group">  <input class="input-group-field" type="number" value="24" aria-label="Timeout">  <span class="input-group-addon">seconds</span></label>

A button inside the group sits 4px from the well's edge and takes a corner 4px smaller than the well's, so the two stay concentric at every size. Use a <div> for the group when it holds a button.

With a button

<form class="input-group">  <input class="input-group-field" type="email" placeholder="you@example.com" aria-label="Email">  <button type="submit" class="btn btn-primary">Subscribe</button></form><div class="input-group input-group-sm">  <input class="input-group-field" value="https://atlas.dev/p/9f2c" readonly aria-label="Share link">  <button type="button" class="btn">Copy</button></div>

To keep a field and a button apart instead, put them in a flex row: <form class="flex gap-xs">.

#Several values

input-group-wrap turns the group into a field that collects several values: recipients, tags, filters. Each value is a chip in the well, the field takes what is left of the last line, and the well grows by a line when the values need one. A chip sits 6px in from the well's edge and takes the concentric corner.

The stylesheet lays the values out; adding one on Enter and removing the last on Backspace is a few lines of your own script, or a round trip to the server.

Several values

<div class="field max-w(420) mx-auto">  <label for="input-teams">Teams</label>  <div class="input-group input-group-wrap">    <span class="chip">Design      <button type="button" class="chip-remove" aria-label="Remove Design"></button>    </span>    <span class="chip">Research      <button type="button" class="chip-remove" aria-label="Remove Research"></button>    </span>    <span class="chip">Engineering      <button type="button" class="chip-remove" aria-label="Remove Engineering"></button>    </span>    <span class="chip">Operations      <button type="button" class="chip-remove" aria-label="Remove Operations"></button>    </span>    <input class="input-group-field" id="input-teams" placeholder="Add a team">  </div></div>

#Search and autofill

A type="search" field keeps the browser's clear button, redrawn as a fine cross in the ink of an icon. When the browser fills a field in, the well takes a faint tint of the info color in place of the engine's own yellow or blue, so a filled form still looks like this one.

Search

<input class="input max-w(360) mx-auto" type="search" value="moonstone" aria-label="Search">
Hover or focus the field to see its clear button in Chromium and Safari.

#File

On <input type="file"> the browser's button becomes a small raised control that sits in the well like an option in a segmented track.

File

<label class="field">Attachment  <input class="input" type="file"></label>

#Date, time, number and color

.input draws the browser's own date, time, number and color fields in the same well. The picker stays the platform's, which is the one a reader already knows on their device. A color field becomes a square whose swatch sits 4px inside, with a concentric corner.

The small marks a browser puts in these fields are redrawn at the weight of an icon and in its ink: a calendar or a clock that opens the picker, and a pair of chevrons that step a number, the upper one up and the lower one down. Chromium and Safari expose those parts; Firefox keeps its own. For a number a reader steps often, use a stepper.

Date, time, number and color

<label class="field">Starts  <input class="input" type="date" value="2026-11-11"></label><label class="field">At  <input class="input" type="time" value="09:30"></label><label class="field">Timeout in seconds  <input class="input" type="number" min="0" step="5" value="30"></label><label class="field">Accent  <input class="input" type="color" value="#5b4fe0"></label>

#Fieldset

fieldset on a <fieldset> groups related controls under one caption without the browser's groove, and fieldset-legend gives the <legend> the voice of a field label. Use it around checkboxes, radios and choices, so the group has a name.

Fieldset

<fieldset class="fieldset">  <legend class="fieldset-legend">Notify me about</legend>  <label class="check">    <input type="checkbox" class="checkbox" checked>Failed deployments</label>  <label class="check">    <input type="checkbox" class="checkbox" checked>Expiring certificates</label>  <label class="check">    <input type="checkbox" class="checkbox">Product news</label></fieldset>

#Disabled

Disabled

<input class="input" value="Read only in this plan" aria-label="Plan" disabled>

#React, Svelte and Vue

The field component joins a label, a control, a hint and an error by their ids. The control stays the page's own markup, and everything else on this page is markup too. Copy it into the project with npx master-ui add field; React, Svelte and Vue says how the components are built and used.

ui/field.tsx
import { useId, type ComponentProps, type ReactNode } from 'react'export interface FieldProps extends Omit<ComponentProps<'div'>, 'children'> {  label: ReactNode  /** Guidance under the control. */  hint?: ReactNode  /** What is wrong with the value. The stylesheet shows it while the control is invalid. */  error?: ReactNode  /** The control: spread what it is given on an input, a select, a textarea or a component that takes them. */  children: (control: { id: string, 'aria-describedby'?: string, 'aria-invalid'?: true }) => ReactNode}/** A label, its control, a hint and an error, joined by their ids. */export function Field({ label, hint, error, className, children, ...root }: FieldProps) {  const id = useId()  const described = [hint !== undefined && `${id}hint`, error !== undefined && `${id}error`].filter(Boolean).join(' ')  return (    <div className={className ? `field ${className}` : 'field'} {...root}>      <label htmlFor={id}>{label}</label>      {children({ id, 'aria-describedby': described || undefined, 'aria-invalid': error !== undefined && error !== false && error !== null ? true : undefined })}      {hint !== undefined && <span className="field-hint" id={`${id}hint`}>{hint}</span>}      {error !== undefined && <span className="field-error" id={`${id}error`}>{error}</span>}    </div>  )}
ui/Field.svelte
<script lang="ts" module>  import type { Snippet } from 'svelte'  import type { HTMLAttributes } from 'svelte/elements'  export interface FieldControl {    id: string    'aria-describedby'?: string    'aria-invalid'?: true  }  export interface FieldProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {    label: string    /** Guidance under the control. */    hint?: string    /** What is wrong with the value. The stylesheet shows it while the control is invalid. */    error?: string | null    /** The control: spread what it is given on an input, a select, a textarea or a component that takes them. */    children: Snippet<[FieldControl]>  }</script><script lang="ts">  let { label, hint, error, class: className, children, ...root }: FieldProps = $props()  const id = $props.id()  const described = $derived([hint !== undefined && `${id}hint`, error !== undefined && `${id}error`].filter(Boolean).join(' '))</script><!--  @component  A label, its control, a hint and an error, joined by their ids.--><div class={['field', className]} {...root}>  <label for={id}>{label}</label>  {@render children({ id, 'aria-describedby': described || undefined, 'aria-invalid': error !== undefined && error !== null ? true : undefined })}  {#if hint !== undefined}<span class="field-hint" id="{id}hint">{hint}</span>{/if}  {#if error !== undefined}<span class="field-error" id="{id}error">{error}</span>{/if}</div>
ui/Field.vue
<script setup lang="ts">import { computed, useId } from 'vue'defineOptions({ inheritAttrs: false })const { hint, error } = defineProps<{  label: string  /** Guidance under the control. */  hint?: string  /** What is wrong with the value. The stylesheet shows it while the control is invalid. */  error?: string | null}>()defineSlots<{  /** The control: bind what it is given on an input, a select, a textarea or a component that takes them. */  default: (control: { 'id': string, 'aria-describedby'?: string, 'aria-invalid'?: true }) => unknown}>()/* A label, its control, a hint and an error, joined by their ids. */const id = useId()const control = computed(() => ({  'id': id,  'aria-describedby': [hint !== undefined && `${id}-hint`, error !== undefined && `${id}-error`].filter(Boolean).join(' ') || undefined,  'aria-invalid': error !== undefined && error !== null ? true as const : undefined,}))</script><template>  <div class="field" v-bind="$attrs">    <label :for="id">{{ label }}</label>    <slot v-bind="control" />    <span v-if="hint !== undefined" class="field-hint" :id="`${id}-hint`">{{ hint }}</span>    <span v-if="error !== undefined" class="field-error" :id="`${id}-error`">{{ error }}</span>  </div></template>

#Classes

ClassDescription
inputA text input, a file input or a textarea, drawn as a well; it also draws the browser's date, time, number, search and color fields
input-sm32px tall; the default is 38px, a button's height. With input
input-lg44px tall; the default is 38px, a button's height. With input
input-autoA textarea that grows with its content, from --input-rows to --input-max-rows lines, then scrolls. With input
input-groupOne well around a bare field and its icon, addon or button; it draws the hover, focus, invalid and disabled states of the field inside it
input-group-smA group 32px tall; the default is 38px. With input-group
input-group-lgA group 44px tall; the default is 38px. With input-group
input-group-wrapA group that collects several values as chips and grows by lines; adding and removing a value is the page's own script or a round trip to the server. With input-group
input-group-fieldThe bare native field inside a group; it keeps the semantics and the caret
input-group-addonFixed text beside the value, such as a protocol, a unit or a domain
fieldA label stacked over its control, with optional guidance below; a label element, or a div that holds a label with for
field-hintMuted guidance under the control; inside a field it steps aside while the error shows
field-errorThe danger message; it shows only while a control in the field is invalid
fieldsetA fieldset that groups related controls under one caption, without the browser's groove
fieldset-legendThe legend: the group's caption, in the voice of a field label

#Custom properties

PropertyDefaultDescription
--input-height2.375remMinimum block size of an input or an input group; 2.75rem on touch screens
--input-padding-x.75remInline padding of an input or an input group
--input-font-size.875remText size
--input-radiusvar(--radius-control)Corner radius: 10px, with 8px for small and 12px for large
--input-rows3The fewest lines an input-auto textarea shows. On input-auto
--input-max-rows12The most lines an input-auto textarea grows to before it scrolls. On input-auto

#Source

@master/ui/components/input.css
/* * Input and field: a text field is a well cut into its surface, edged by a half-pixel contour. * On focus the well rises to the sheet and takes one ink contour with a soft halo. * A field is as tall as the button beside it: 32, 38 and 44px. * A textarea can grow with its content, and an input group holds an icon, an addon or a button in the same well. */@layer components {  .input {    --input-height: 2.375rem;    --input-padding-x: .75rem;    /* A line of 14px text is 20.3px; 8px over and under keeps it inside the 38px the field shares with a button. */    --input-padding-y: .5rem;    --input-font-size: .875rem;    --input-radius: var(--radius-control);    width: 100%;    min-width: 0;    min-height: var(--input-height);    margin: 0;    padding: var(--input-padding-y) var(--input-padding-x);    border: 0;    border-radius: calc(var(--input-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    background: var(--color-surface-inset);    color: var(--color-text-strong);    scrollbar-width: thin;    scrollbar-color: var(--color-line-strong) transparent;    /* One contour and a soft inner shade: the well's tone does the rest of the work of being found. */    box-shadow: var(--shadow-pressed);    font: 400 var(--input-font-size)/1.45 var(--font-family-sans);    font-feature-settings: inherit;    caret-color: var(--color-text-strong);    resize: vertical;    appearance: none;    transition-property: box-shadow, background-color;    transition-duration: var(--duration-quick);    transition-timing-function: var(--easing-glide);    &::placeholder {      color: var(--color-text-muted);      opacity: 1;    }    @media (hover: hover) {      &:hover:not(:focus, :disabled, [readonly]) {        background: var(--color-surface-inset-hover);      }    }    &:focus {      outline: none;      background: var(--color-surface-raised);      box-shadow: inset 0 0 0 .5px var(--color-line-mark), 0 0 0 .5px var(--color-line-mark), 0 0 0 3.5px var(--color-focus-halo);    }    /*     * An error keeps its boundary while focused; the focus halo takes the danger tone.     * :user-invalid reports native constraints (required, type, pattern) once the reader has tried, without script.     */    &:is([aria-invalid="true"], :user-invalid) {      box-shadow: var(--shadow-pressed), inset 0 0 0 .5px var(--color-danger), 0 0 0 .5px color-mix(in oklab, var(--color-danger) 45%, transparent);      &:focus {        box-shadow: inset 0 0 0 .5px var(--color-danger), 0 0 0 .5px var(--color-danger), 0 0 0 3.5px color-mix(in oklab, var(--color-danger) 15%, transparent);      }    }    /*     * Filled in by the browser: the engine paints its own background and does not let a page replace it, so the     * well is laid over it as an inset shadow, tinted just enough to say the value was not typed.     */    &:autofill {      box-shadow: var(--shadow-pressed), inset 0 0 0 100vmax color-mix(in oklab, var(--color-info) 7%, var(--color-surface-inset));      &:focus {        box-shadow: inset 0 0 0 .5px var(--color-line-mark), 0 0 0 .5px var(--color-line-mark), 0 0 0 3.5px var(--color-focus-halo), inset 0 0 0 100vmax color-mix(in oklab, var(--color-info) 7%, var(--color-surface-raised));      }    }    &:disabled {      color: var(--color-text-disabled);      cursor: not-allowed;      &::placeholder {        color: var(--color-text-disabled);      }    }    /* A search field's clear button, redrawn as a fine cross in the ink of an icon. */    &::-webkit-search-cancel-button {      width: 14px;      height: 14px;      margin-inline: .5rem -.125rem;      background: var(--color-text-muted);      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='m4.5 4.5 7 7m0-7-7 7'/%3E%3C/svg%3E") center / contain no-repeat;      appearance: none;      cursor: pointer;    }    /*     * The marks a browser puts in a date, a time or a number field, redrawn at the weight of an icon and in its     * ink: a calendar or a clock that opens the picker, and a pair of chevrons that step the number. Chromium and     * Safari expose these parts; Firefox keeps its own.     */    &::-webkit-calendar-picker-indicator {      box-sizing: border-box;      width: 1rem;      height: 1rem;      margin-inline: .5rem -.125rem;      padding: 0;      background: var(--color-text-muted);      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 4h9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM2.5 7h11M5.5 2.5V5M10.5 2.5V5'/%3E%3C/svg%3E") center / contain no-repeat;      opacity: 1;      cursor: pointer;    }    /* The engine pads each part of a date by a pixel, which would make the field taller than its neighbors. */    &::-webkit-datetime-edit {      margin-block: -1px;    }    &[type="time"]::-webkit-calendar-picker-indicator {      mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2.5a5.5 5.5 0 1 0 0 11a5.5 5.5 0 0 0 0-11M8 5v3l2 1.5'/%3E%3C/svg%3E");    }    /* The upper half of the mark steps up and the lower half steps down, as the browser's own does. */    &::-webkit-inner-spin-button {      width: 1rem;      margin-inline: .5rem -.125rem;      background: var(--color-text-muted);      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5.5 6.25 2.5-2.5 2.5 2.5M10.5 9.75 8 12.25 5.5 9.75'/%3E%3C/svg%3E") center / 1rem no-repeat;      opacity: 1;      appearance: none;      cursor: pointer;    }    &:disabled::-webkit-calendar-picker-indicator {      background: var(--color-text-disabled);      cursor: not-allowed;    }    &:disabled::-webkit-inner-spin-button {      background: var(--color-text-disabled);      cursor: not-allowed;    }    @media (forced-colors: active) {      &::-webkit-calendar-picker-indicator {        background: FieldText;        forced-color-adjust: none;      }      &::-webkit-inner-spin-button {        background: FieldText;        forced-color-adjust: none;      }    }    &.input-sm {      --input-height: 2rem;      --input-padding-x: .625rem;      --input-padding-y: .3125rem;      --input-font-size: .8125rem;      --input-radius: var(--radius-control-sm);    }    &.input-lg {      --input-height: 2.75rem;      --input-padding-x: .875rem;      --input-padding-y: .625rem;      --input-font-size: 1rem;      --input-radius: var(--radius-control-lg);    }    /*     * A textarea that grows with its content, from --input-rows to --input-max-rows lines, then scrolls.     * Without field-sizing it keeps its minimum height and the resize handle.     */    &.input-auto {      --input-rows: 3;      --input-max-rows: 12;      min-height: max(var(--input-height), calc(var(--input-rows) * 1lh + var(--input-padding-y) * 2));      @supports (field-sizing: content) {        max-height: calc(var(--input-max-rows) * 1lh + var(--input-padding-y) * 2);        field-sizing: content;        resize: none;      }    }    /* A file input holds its button like a segmented track holds an option: 4px in, with a concentric corner. */    &[type="file"] {      padding-block: 4px;      padding-inline: 4px var(--input-padding-x);      color: var(--color-text-muted);      line-height: calc(var(--input-height) - 8px);      cursor: pointer;      &::file-selector-button {        height: calc(var(--input-height) - 8px);        margin-block: 0;        margin-inline: 0 .75rem;        padding: 0 .625rem;        border: 0;        border-radius: calc(var(--input-radius) * var(--corner-scale) - 4px);        corner-shape: var(--corner-shape);        @apply --surface-sheet;        color: var(--color-text-strong);        box-shadow: var(--shadow-raised);        font: var(--font-weight-control) .78125rem/1 var(--font-family-sans);        /* On the baseline the button would push its line, and the field, a fraction taller than a button's 38px. */        vertical-align: top;        cursor: pointer;      }      &:disabled::file-selector-button {        background: transparent;        color: var(--color-text-disabled);        box-shadow: var(--shadow-flat);        cursor: not-allowed;      }    }    /* A color: the swatch fills the well, 4px in, with a concentric corner. */    &[type="color"] {      width: var(--input-height);      padding: 4px;      cursor: pointer;      &::-webkit-color-swatch-wrapper {        padding: 0;      }      &::-webkit-color-swatch {        border: 0;        border-radius: calc(var(--input-radius) * var(--corner-scale) - 4px);        corner-shape: var(--corner-shape);        box-shadow: inset 0 0 0 .5px var(--color-line-on-color);      }      &::-moz-color-swatch {        border: 0;        border-radius: calc(var(--input-radius) - 4px);      }    }    @media (pointer: coarse) {      min-height: max(var(--input-height), 2.75rem);      /* Below 16px iOS zooms the page on focus. */      font-size: max(var(--input-font-size), 1rem);      /* Every size is 44px on touch, and what is derived from the height (a file's button, a swatch) follows. */      &:not(.input-lg) {        --input-height: 2.75rem;      }    }    @media (forced-colors: active) {      outline: 1px solid FieldText;      outline-offset: -1px;      &:focus {        outline: 2px solid Highlight;        outline-offset: -2px;      }    }  }  /*   * Input group: one well around a bare field and what belongs to it: an icon, a unit, a shortcut or a button.   * The group draws the field's states; the field inside keeps the semantics and the caret.   */  .input-group {    --input-height: 2.375rem;    --input-padding-x: .75rem;    --input-font-size: .875rem;    --input-radius: var(--radius-control);    --icon-size: 1rem;    --icon-offset: -.125rem;    display: flex;    align-items: center;    gap: .5rem;    min-width: 0;    min-height: var(--input-height);    padding: 0 var(--input-padding-x);    border-radius: calc(var(--input-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    background: var(--color-surface-inset);    color: var(--color-text-muted);    box-shadow: var(--shadow-pressed);    font: 400 var(--input-font-size)/1.45 var(--font-family-sans);    cursor: text;    transition-property: box-shadow, background-color;    transition-duration: var(--duration-quick);    transition-timing-function: var(--easing-glide);    @media (hover: hover) {      &:hover:not(:has(.input-group-field:is(:focus, :disabled, [readonly]))) {        background: var(--color-surface-inset-hover);      }    }    &:has(.input-group-field:focus) {      background: var(--color-surface-raised);      box-shadow: inset 0 0 0 .5px var(--color-line-mark), 0 0 0 .5px var(--color-line-mark), 0 0 0 3.5px var(--color-focus-halo);    }    &:has(.input-group-field:is([aria-invalid="true"], :user-invalid)) {      box-shadow: var(--shadow-pressed), inset 0 0 0 .5px var(--color-danger), 0 0 0 .5px color-mix(in oklab, var(--color-danger) 45%, transparent);      &:has(.input-group-field:focus) {        box-shadow: inset 0 0 0 .5px var(--color-danger), 0 0 0 .5px var(--color-danger), 0 0 0 3.5px color-mix(in oklab, var(--color-danger) 15%, transparent);      }    }    &:has(.input-group-field:disabled) {      color: var(--color-text-disabled);      cursor: not-allowed;    }    &.input-group-sm {      --input-height: 2rem;      --input-padding-x: .625rem;      --input-font-size: .8125rem;      --input-radius: var(--radius-control-sm);      --icon-size: .875rem;      --icon-offset: -.0625rem;      gap: .375rem;    }    &.input-group-lg {      --input-height: 2.75rem;      --input-padding-x: .875rem;      --input-font-size: 1rem;      --input-radius: var(--radius-control-lg);      --icon-size: 1.125rem;    }    /* The browser filled the field in: the whole well takes the tint, not a box inside it. */    &:has(.input-group-field:autofill) {      background: color-mix(in oklab, var(--color-info) 7%, var(--color-surface-inset));    }    /*     * Wrap: a field that collects several values. Each value is a chip in the well, the field takes what is     * left of the last line, and the well grows by a line when the values need one. A chip sits --input-inset     * in from the well's edge and takes the concentric corner.     */    &.input-group-wrap {      --input-inset: 6px;      flex-wrap: wrap;      gap: 4px;      padding-block: var(--input-inset);      &:has(.chip:first-child) {        padding-inline-start: var(--input-inset);      }      &.input-group-sm {        --input-inset: 4px;      }      .chip {        --chip-height: calc(var(--input-height) - var(--input-inset) * 2);        --chip-padding-x: .5rem;        border-radius: calc(var(--input-radius) * var(--corner-scale) - var(--input-inset));        corner-shape: var(--corner-shape);      }      .input-group-field {        flex: 1 1 5rem;        align-self: auto;        min-height: calc(var(--input-height) - var(--input-inset) * 2);      }      .chip + .input-group-field {        margin-inline-start: .25rem;      }    }    /* A button in the well sits 4px in, so its corner is the well's corner minus that inset. */    .btn {      --btn-height: calc(var(--input-height) - 8px);      --btn-padding-x: .625rem;      --btn-font-size: .78125rem;      /* The button multiplies its radius by the corner scale itself, so the inset is divided by it here. */      --btn-radius: calc(var(--input-radius) - 4px / var(--corner-scale));      --btn-lift: 0px;      --icon-size: .875rem;      --icon-offset: -.0625rem;      min-height: var(--btn-height);      /* A square one stays square on touch, where a button alone would widen to 44px. */      &.btn-square {        min-width: var(--btn-height);      }      &:first-child {        margin-inline-start: calc(4px - var(--input-padding-x));      }      &:last-child {        margin-inline-end: calc(4px - var(--input-padding-x));      }    }    @media (pointer: coarse) {      min-height: max(var(--input-height), 2.75rem);      font-size: max(var(--input-font-size), 1rem);      /* Every size is 44px on touch, so a button or a chip in the well keeps its inset there. */      &:not(.input-group-lg) {        --input-height: 2.75rem;      }    }    @media (forced-colors: active) {      outline: 1px solid FieldText;      outline-offset: -1px;      &:has(.input-group-field:focus) {        outline: 2px solid Highlight;        outline-offset: -2px;      }    }  }  /* The native field inside a group: no material of its own, it fills the well. */  .input-group-field {    flex: 1 1 0;    align-self: stretch;    min-width: 0;    margin: 0;    padding: 0;    border: 0;    border-radius: 0;    outline: none;    background: transparent;    color: var(--color-text-strong);    box-shadow: none;    font: inherit;    font-feature-settings: inherit;    caret-color: var(--color-text-strong);    appearance: none;    &::placeholder {      color: var(--color-text-muted);      opacity: 1;    }    /* The engine's autofill color never arrives, so the well under the field keeps drawing it. */    &:autofill {      transition: background-color 0s 600000s;    }    &::-webkit-search-cancel-button {      width: 14px;      height: 14px;      margin-inline-start: .5rem;      background: var(--color-text-muted);      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='m4.5 4.5 7 7m0-7-7 7'/%3E%3C/svg%3E") center / contain no-repeat;      appearance: none;      cursor: pointer;    }    /* The same marks as a plain field draws for a date, a time or a number. */    &::-webkit-calendar-picker-indicator {      box-sizing: border-box;      width: 1rem;      height: 1rem;      margin-inline: .5rem 0;      padding: 0;      background: var(--color-text-muted);      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 4h9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM2.5 7h11M5.5 2.5V5M10.5 2.5V5'/%3E%3C/svg%3E") center / contain no-repeat;      opacity: 1;      cursor: pointer;    }    /* The engine pads each part of a date by a pixel, which would make the field taller than its neighbors. */    &::-webkit-datetime-edit {      margin-block: -1px;    }    &[type="time"]::-webkit-calendar-picker-indicator {      mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2.5a5.5 5.5 0 1 0 0 11a5.5 5.5 0 0 0 0-11M8 5v3l2 1.5'/%3E%3C/svg%3E");    }    /* The upper half of the mark steps up and the lower half steps down, as the browser's own does. */    &::-webkit-inner-spin-button {      width: 1rem;      margin-inline: .5rem 0;      background: var(--color-text-muted);      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5.5 6.25 2.5-2.5 2.5 2.5M10.5 9.75 8 12.25 5.5 9.75'/%3E%3C/svg%3E") center / 1rem no-repeat;      opacity: 1;      appearance: none;      cursor: pointer;    }    &:disabled::-webkit-calendar-picker-indicator {      background: var(--color-text-disabled);      cursor: not-allowed;    }    &:disabled::-webkit-inner-spin-button {      background: var(--color-text-disabled);      cursor: not-allowed;    }    @media (forced-colors: active) {      &::-webkit-calendar-picker-indicator {        background: FieldText;        forced-color-adjust: none;      }      &::-webkit-inner-spin-button {        background: FieldText;        forced-color-adjust: none;      }    }    &:disabled {      color: var(--color-text-disabled);      cursor: not-allowed;      &::placeholder {        color: var(--color-text-disabled);      }    }  }  /* Fixed text beside the value, such as a protocol, a unit or a domain. */  .input-group-addon {    flex: none;    color: var(--color-text-muted);    white-space: nowrap;    user-select: none;    -webkit-user-select: none;  }  /* A <fieldset> that groups related controls under one caption, without the browser's groove. */  .fieldset {    display: flex;    flex-direction: column;    gap: .75rem;    min-width: 0;    margin: 0;    padding: 0;    border: 0;  }  /* Its <legend>: the group's label, in the same voice as a field's. */  .fieldset-legend {    margin-bottom: .75rem;    padding: 0;    color: var(--color-text-strong);    font-size: .875rem;    font-weight: var(--font-weight-control);    line-height: 1.4;  }  /* A label stacked over its control, with optional guidance below, all on one edge. */  .field {    display: grid;    gap: .5rem;    min-width: 0;    color: var(--color-text-strong);    font-size: .875rem;    font-weight: var(--font-weight-control);    line-height: 1.4;  }  /* Guidance sits closer to its control than the label does: 6px below against 8px above. */  .field-hint, .field-error {    margin-top: -.125rem;    font-size: .75rem;    font-weight: 400;    line-height: 1.5;    text-wrap: pretty;  }  .field-hint {    color: var(--color-text-muted);  }  /* The error appears with the invalid state it explains, and steps aside while the value is valid. */  .field-error {    color: var(--color-danger);  }  .field:not(:has([aria-invalid="true"], :user-invalid)) .field-error {    display: none;  }  /* While an error shows, it replaces the hint instead of stacking under it. */  .field:has([aria-invalid="true"], :user-invalid):has(.field-error) .field-hint {    display: none;  }}