Skip to content

Components

Text field

Variants

html
<label class="field">
  <span class="label">Label</span>
  <input type="text" placeholder="Placeholder" />
</label>

<label class="field filled">
  <span class="label">Label</span>
  <input type="text" placeholder="Placeholder" />
</label>

Sizes

html
<label class="field small">
  <!--  -->
</label>

Supporting text

html
<label class="field">
  <span class="label">Label</span>
  <input type="text" placeholder="Placeholder" />
  <span class="supporting-text">Supporting text</span>
</label>

Validation

  • Add [required] to the <input> element to toggle required styles
  • The .error class toggles the error styles. Make use of the supporting text to give extra feedback on the error.
html
<label class="field">
  <span class="label">Label</span>
  <input type="text" placeholder="Placeholder" required/>
</label>

<label class="field error">
  <span class="label">Label</span>
  <input type="text" placeholder="Placeholder" />
  <span class="supporting-text">Supporting text</span>
</label>

Auto-fit

When enabled the Field changes size depending on its content.

html
<label class="field auto-fit">
  <!--  -->
</label>

Input types

Numeric vs <input type="number">

html
<label class="field">
  <span class="label">Numeric</span>
  <input type="text" inputmode="numeric" pattern="[0-9]*" placeholder="Numeric">
  <input type="number" placeholder="Number">
</label>

File

  • Use aria-label instead of the <label> element.

File is a weird one. Should it really be an <input> element? Well, it's what we've got 😅

html
<div class="field" aria-label="Label">
  <input type="file" placeholder="File" />
</div>

Autosuggest

Leverages the <input> + <datalist> element combo.

  • <input list="DATALISTID">
  • <datalist id="DATALISTID">
html
<div class="field">
  <input type="text" list="datalist-id" />
  <datalist id="datalist-id">
    <option value="option value"></option>
  </datalist>
</div>

Do I have to use <label>?

No. But you get some accessibility wins for free with <label>. It's recommended to label your inputs somehow.

html
<div class="field auto-fit">
  <input type="text" placeholder="Placeholder" />
</div>

Accessibility

Anatomy

  1. label.field: Container element
  2. .label: Field label element
  3. <input>: Input element
  4. .supporting-text: Supporting text element

API

Field API

TypeModifiersDefaultDescription
Auto-fit.auto-fit-When enabled, the element changes size depending on its content.
Children.label, <input>, <select>, <textarea>, <datalist>, .supporting-text-Optional children.
Sizes.small-The size of the element.
Validation.error-When applied, error styles are shown.
Variantsoutlined, .filledoutlinedThe variant to use.

Browser compatibility

Installation

css
@layer components.has-deps {
  /*
- Common styling for input, textarea and select
- Form related styles such as: label, supporting text, error handling
*/
  :where(.field) {
    --_accent-color: var(--primary);
    --_bg-color: var(--surface-default);
    --_border-color: var(--field-border-color);
    --_field-padding-block: 0.75rem;
    --_field-padding-inline: var(--size-2);
    --_filled-border-color: var(--text-color-1);
    --_height: var(--field-size);
    --_label-color: var(--text-color-2);
    --_supporting-text-color: var(--text-color-2);

    display: inline-grid;
    position: relative;

    /* Input/Select base */
    & input,
    & textarea,
    & select {
      background-color: var(--_bg-color);
      block-size: var(--_height);
      border-radius: var(--field-border-radius);
      border: var(--field-border-width) solid var(--_border-color);
      color: var(--text-color-1);
      font-family: var(--font-sans);
      font-size: var(--font-size-1);
      grid-column: 1/-1;
      grid-row: 1;
      inline-size: 100%;
      line-height: var(--font-lineheight-1);
      min-inline-size: 0;
      padding: var(--_field-padding-block) var(--_field-padding-inline);

      @media (prefers-reduced-motion: no-preference) {
        transition:
          border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
          padding-block 0.2s var(--ease-3);
      }
    }

    /* Required/Invalid */
    &:has(
        :not(:placeholder-shown):invalid,
        :where(
            :placeholder-shown,
            option[value=""]:not(:checked),
            option:checked:not([value=""])
          ):required
      ) {
      .label:after {
        color: var(--red);
        content: "*";
        margin: -0.25em auto auto 0.25em;
      }
    }

    /* File */
    &:has(input[type="file"]) {
      cursor: pointer;

      input {
        align-self: flex-start;
        block-size: var(--_height);
        box-shadow: none;
        color: var(--text-color-1);
        cursor: inherit;
        max-inline-size: 100%;
        padding: 0;
        transition: font-size 0.2s var(--ease-3);

        &::-webkit-file-upload-button,
        &::file-selector-button {
          background-color: var(--surface-tonal);
          border: none;
          block-size: calc(100% - var(--size-2) * 2);
          border-radius: var(--field-border-radius);
          cursor: pointer;
          margin-inline-end: 1ex;
          margin-block-start: var(--size-2);
          margin-inline-start: var(--size-2);
        }
      }

      /* Variants */
      &.filled {
        input {
          &::-webkit-file-upload-button,
          &::file-selector-button {
            background-color: var(--surface-default);
            block-size: calc(100% - var(--size-2) * 2);
            border-radius: var(--field-border-radius);
            cursor: pointer;
            margin-block-start: var(--size-2);
          }
        }
      }

      /* Sizes */
      &.small {
        input {
          font-size: var(--font-size-sm);
          &::-webkit-file-upload-button,
          &::file-selector-button {
            block-size: calc(100% - var(--size-2));
            margin-block-start: var(--size-1);
          }
        }
      }
    }

    /* Autosuggest */
    &:has(input[list]) {
      .label {
        /* Make sure chevron is visible */
        inline-size: calc(100% - var(--size-6));
      }
    }

    /* Experimental Select */
    &:has(select button) {
      select {
        padding: 0;

        button {
          outline: 0;
          padding: var(--_field-padding-block) var(--size-8)
            var(--_field-padding-block) var(--_field-padding-inline);
        }
      }
    }

    /* Non-experimental Select */
    &:has(select):not(:has(button)) {
      select {
        padding: var(--_field-padding-block) var(--size-8)
          var(--_field-padding-block) var(--_field-padding-inline);
      }

      .label {
        /* Make sure chevron is visible */
        inline-size: calc(100% - var(--size-6));
      }
    }

    /* Input - color */
    &:has(input[type="color"]) {
      input {
        appearance: none;
        background: none;
        block-size: var(--_height);
        overflow: hidden;
        padding: 0;

        &::-webkit-color-swatch {
          border: none;
        }

        &::-webkit-color-swatch-wrapper {
          padding: 0;
        }
      }

      .label {
        border: 1px solid var(--field-border-color);
        inline-size: fit-content;
        margin-inline-start: var(--size-2);
      }
    }

    /* Textarea */
    &:has(textarea) {
      .label {
        align-self: start;
        margin-block-start: var(--_field-padding-block);
      }
    }

    /*
  * Variant: Outlined
  */
    &:not(.filled) {
      /* Element states */
      &:hover {
        &:not(.error) {
          :where(input, textarea, select) {
            --_border-color: var(--text-color-1);
          }
        }
      }
    }

    &:not(.filled):focus-within {
      & input,
      & textarea,
      & select {
        border-color: var(--_accent-color);
        outline-offset: -2px;
        outline: 2px solid var(--_accent-color);
      }
    }

    /* Label */
    .label {
      align-self: center;
      background-color: var(--_bg-color);
      border-radius: var(--field-border-radius);
      color: var(--_label-color);
      border-radius: var(--radius-1);
      display: inline-flex;
      font-size: var(--font-size-md);
      grid-column: 1/-1;
      grid-row: 1;
      inline-size: calc(100% - (var(--field-border-width) * 2));
      margin-inline-start: var(--field-border-width);
      padding-inline: var(--_field-padding-inline);
      pointer-events: none;
      z-index: 1;

      @media (prefers-reduced-motion: no-preference) {
        transition:
          border-color 0.2s var(--ease-3),
          font-size 0.2s var(--ease-3),
          inline-size 0.05s var(--ease-3),
          margin 0.2s var(--ease-3),
          padding-inline 0.2s var(--ease-3);
      }
    }

    /*
  * Label transitions
  * Triggered by:
  * - focus
  * - filled form fields (except color inputs)
  * - non-empty select options
  */
    &:focus-within,
    &:has(:where(input:not([type="color"]), textarea):not(:placeholder-shown)),
    &:has(option[value=""]:not(:checked)),
    &:has(option:checked:not([value=""])) {
      .label {
        border-color: transparent;
        color: var(--_accent-color);
        font-size: 0.75rem;
        inline-size: max-content;
        letter-spacing: 0.15px;
        line-height: 1.15;
        margin-block-start: -2.7rem;
        margin-inline-start: var(--_field-padding-inline);
        padding-inline: 0.125rem;
      }

      /* Neutral label color reset */
      &:not(:focus-within):not(.error) {
        .label {
          color: var(--text-color-2);
        }
      }

      &:has(textarea) {
        .label {
          margin-block-start: -0.35rem;
        }

        &.small {
          .label {
            align-self: start;
            margin-block-start: var(--_field-padding-block);
          }
        }
      }
    }

    /* Supporting text */
    .supporting-text {
      color: var(--_supporting-text-color);
      font-size: var(--font-size-xs);
      grid-row: 3;
      line-height: 1.5;
      margin-inline-start: var(--field-border-width);
      padding-inline: var(--_field-padding-inline);
      z-index: 1;
    }

    /* Auto-fit */
    &.auto-fit {
      inline-size: auto;
      :where(& input, & textarea) {
        field-sizing: content;
      }
    }

    /* Validation */
    &.error {
      --_accent-color: var(--color-9);
      --_border-color: var(--color-9);
      --_filled-border-color: var(--color-9);
      --_label-color: var(--color-9);
      --_supporting-text-color: var(--color-9);
    }

    /*
  * Variant: Filled
  */
    &.filled {
      --_bg-color: var(--surface-tonal);
      *:focus-visible {
        outline: 0;
      }

      /* Base style */
      & input,
      & textarea,
      & select {
        border-block-end-color: var(--_filled-border-color);
        border-block-start-color: transparent;
        border-inline-color: transparent;
        border-radius: 0;
      }

      & input[type="color"] {
        border-inline: none;
      }

      /* Bottom line */
      &:before {
        background-color: var(--_filled-border-color);
        block-size: calc(var(--field-border-width) + 1px);
        content: "";
        inline-size: 100%;
        margin-block-end: calc(-1 * (var(--field-border-width) * 2));
        transform: scaleX(0);
        translate: 0 calc(-1 * (var(--field-border-width) * 2));
        z-index: 1;

        @media (prefers-reduced-motion: no-preference) {
          transition:
            transform 0.3s var(--ease-3),
            translate 0.2s var(--ease-3);
        }
      }

      /* Label */
      .label {
        background-color: var(--_bg-color);
      }

      &:not(:has([disabled], :has(input[type="color"]))) {
        /* Hover */
        &:hover {
          --_bg-color: light-dark(
            oklch(from var(--surface-tonal) calc(l * 0.93) c h),
            oklch(from var(--surface-tonal) calc(l * 1.1) c h)
          );
        }
      }

      /*
    * Label transitions
    * Triggered by:
    * - focus
    * - filled form fields (except color inputs)
    * - non-empty select options
    */
      &:has(.label) {
        &:focus-within,
        &:has(
            :where(input:not(:where([type="color"])), textarea):not(
                :placeholder-shown
              )
          ),
        &:has(option[value=""]:not(:checked)),
        &:has(option:checked:not([value=""])) {
          :where(input, textarea) {
            padding-block: calc(var(--_field-padding-block) * 1.7)
              calc(var(--_field-padding-block) * 0.3);
          }

          select > button,
          select:not(:has(button)) {
            padding-block: calc(var(--_field-padding-block) * 1.7)
              calc(var(--_field-padding-block) * 0.3);
          }

          .label {
            margin-block-start: calc(-1 * var(--size-5));
            margin-inline-start: calc(var(--_field-padding-inline) / 2);
            padding-inline: calc(var(--_field-padding-inline) / 2);
          }

          &:has(textarea) {
            .label {
              margin-block-start: var(--size-1);
            }
          }
        }
      }

      /* Element states */
      &:hover {
        &:before {
          transform: scaleX(1);
        }
      }
      &:focus-within {
        & input,
        & textarea,
        & select {
          border-block-end-color: var(--_accent-color);
        }

        &:before {
          background-color: var(--_accent-color);
          transform: scaleX(1) translateX(0px);
        }
      }
    }

    /* Disabled */
    &:where(:has([disabled])) {
      &:before {
        display: none;
      }
      :where(input, textarea, select) {
        cursor: not-allowed;
        opacity: 0.7;

        * {
          pointer-events: none;
        }
      }
    }

    /* Read-only */
    &:where(:has([readonly])) {
      &:before {
        display: none;
      }
      :where(input, textarea, select) {
        cursor: not-allowed;

        * {
          pointer-events: none;
        }
      }
    }

    /* Sizes */
    &.small {
      --_field-padding-block: var(--size-2);
      --_height: var(--field-size-small);

      &:has(input[type="color"]) {
        .label {
          line-height: 1.5;
        }
      }

      &:has(textarea) {
        .label {
          align-self: center;
          margin-block-start: unset;
        }
      }

      /*
    * Label transitions
    * Triggered by:
    * - focus
    * - filled form fields (except color inputs)
    * - non-empty select options
    */
      &:focus-within,
      &:has(
          :where(input:not([type="color"]), textarea):not(:placeholder-shown)
        ),
      &:has(option[value=""]:not(:checked)),
      &:has(option:checked:not([value=""])) {
        .label {
          margin-block-start: -2.2rem;
          margin-inline-start: var(--size-1);
          padding-inline: var(--size-1);
        }

        &:not(.filled):has(textarea) {
          .label {
            margin-block-start: -0.35rem;
          }
        }
      }
    }
  }
}
css
@layer components.has-deps {
  :where(
    .field:has(
        :where(
          input[type="date"],
          input[type="datetime-local"],
          input[type="email"],
          input[type="month"],
          input[type="number"],
          input[type="password"],
          input[type="search"],
          input[type="tel"],
          input[type="text"],
          input[type="time"],
          input[type="url"],
          input[type="week"]
        )
      )
  ) {
    /* Sizes */
    &.small {
      input {
        padding-inline: var(--size-2);
      }
    }
  }

  /* Autosuggest */
  :where(.field:has(input[list])) {
    /* Hide native arrow */
    input::-webkit-calendar-picker-indicator {
      opacity: 0;
      position: absolute;
      cursor: pointer;
      pointer-events: none;
    }
  }
  :where(
    .field:has(input[list]:placeholder-shown),
    .field:has(input[list]):where(:focus-within, :hover)
  ) {
    /* Arrow */
    &:after {
      block-size: 0;
      border-block-start: 5px solid;
      border-inline: 5px solid transparent;
      content: "";
      display: inline-block;
      flex-shrink: 0;
      inline-size: 0;
      inset: 50% var(--size-3) auto auto;
      pointer-events: none;
      position: absolute;
      translate: 0 -50%;
    }
  }
}