Skip to content

Components

Select

Leverages the List component to provide markup for the Select popover.

Non-experimental Select: Uses less modern features and the native option list.

Variants

html
<label class="field">
  <select>
    <button>
      <selectedcontent></selectedcontent>
    </button>
    <div class="list">
      <option>Outlined (default)</option>
      <option>Option Two</option>
      <option>Option Three</option>
    </div>
  </select>
</label>

<label class="field filled">
  <select>
    <!--  -->
  </select>
</label>

Supporting text

  • .supporting-text: supporting text element
html
<label class="field">
  <span class="label">Label</span>
  <select>
   <!--  -->
  </select>
  <span class="supporting-text">Supporting text</span>
</label>

Validation

  • Add [required] to the <select> 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>
  <select required> <!----> </select>
</label>

<label class="field error">
  <span class="label">Label</span>
  <select> <!----> </select>
  <span class="supporting-text">Supporting text</span>
</label>

Sizes

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

Grouped

Wrap your options in an element with role="group". The <label> inside will be used as a group heading.

html
<label class="field">
  <span class="label">Small</span>
  <select>
    <button>
      <selectedcontent></selectedcontent>
    </button>
    <div class="list">
      <option>Select car</option>
      <div role="group">
        <label class="text">Swedish cars</label>
        <option>Volvo</option>
        <option>SAAB</option>
      </div>
      <div role="group">
        <label class="text">French cars</label>
        <option>Renault</option>
        <option>Citroën</option>
      </div>
    </div>
  </select>
</label>

Dense

Since the Select popover uses the List component we can simply apply its .dense modifier class.

html
<div class="field">
  <select>
    <button>
      <selectedcontent></selectedcontent>
    </button>
    <div class="list dense">
      <option>Dense</option>
      <option>Dense Two</option>
      <option>Dense Three</option>
    </div>
  </select>
</div>

Validation

The .error class toggles the error styles. Make use of the supporting text to give extra feedback on the error.

html
<label class="field error">
  <span class="label">Label</span>
  <select> <!-- --> </select>
  <span class="supporting-text">Supporting text</span>
</label>

Non-experimental Select

Just implement the Select as you normally would.

html
<label class="field">
  <span class="label">Label</span>
  <select>
    <option value="">-</option>
    <option>Option 1</option>
    <option>Option 2</option>
  </select>
</label>

Anatomy

  1. Select container: <select>
  2. Select button: <button>
  3. Select button selected option: <selectedcontent>
  4. Select button arrow
  5. Popover list: .list
  6. List option/s: <option>
  7. List option group/s (optional): <optgroup>

Accessibility

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.

List API

TypeModifiersDefaultDescription
Denseul.dense-When enabled list appears tighter packed
Gutterlessul.gutterless-When enabled list inline padding is removed
Borderedul.bordered-When enabled a border is rendered on all list items

List item

TypeModifiersDefaultDescription
Main partsli > .start, li > .text, li > .end-Building blocks in List item
Insetli.inset-When enabled a list item without a start icon algins with items that do
Border topli.border-top-When enabled the list item will get a top border

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));
      }
    }

    /* Select */
    &:has(select) {
      .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);
      }
    }

    /* 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 > select) {
    position: relative;

    /* Default arrow */
    &:after {
      display: none;
    }

    &:open {
      button {
        &:after {
          rotate: 180deg;
        }
      }
    }

    /* Select popover */
    &::picker(select) {
      border: 0;
      box-shadow: var(--shadow-2);
      padding: 0;

      @media (prefers-reduced-motion: no-preference) {
        transition:
          opacity 0.5s var(--ease-3),
          scale 0.2s var(--ease-3);
      }
    }

    /* Animation start styles */
    &:not(:open)::picker(select) {
      opacity: 0;
      scale: 0.5;
    }

    /* Animation end styles */
    &:open::picker(select) {
      opacity: 1;
      scale: 1;
    }

    button {
      background-color: transparent;
      display: flex;
      inline-size: 100%;
      margin: 0;
      position: relative;

      /* 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%;
      }

      selectedcontent {
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
    }

    .list {
      /* Groups */
      [role="group"] {
        label {
          background-color: light-dark(var(--gray-3), var(--gray-13));
          color: light-dark(
            oklch(from var(--text-color-1) calc(l * 0.75) c h),
            oklch(from var(--text-color-1) calc(l * 1.25) c h)
          );
          font-weight: 500;
          overflow: hidden;
          padding-inline: var(--size-2);
          text-overflow: ellipsis;
          white-space: nowrap;
        }

        &:not(:first-child),
        option:first-of-type {
          margin-block-start: var(--size-2);
        }

        option:last-of-type,
        &:last-child {
          option:last-of-type {
            margin-block-end: 0;
          }
        }
      }

      /* Option */
      option {
        /* Checkmark */
        /* TODO - checkmark should be the final version of the checkmark API. Follow the development of this and remove redundant psuedo stuff. */
        &::check {
          display: none;
        }
        &::checkmark {
          display: none;
        }
        &::before {
          display: none;
        }

        &:focus-visible {
          outline-offset: -1px;
        }
      }
    }
  }

  :where(.field:has(> select)) {
    /* Size */
    &.small {
      button {
        padding-block: var(--size-1);
        padding-inline: var(--size-2) var(--size-7);

        &:after {
          inset-inline-end: var(--size-2);
        }
      }
    }

    /*
    * Non-experimental Select
    *
    * Hack to get the arrow working. Pseudo elements aren't allowed on the `<select>` element, so need to add it on the `.field` class instead. Noting this down if an `:after` element would be needed on a `.field`.
    */
    &:not(:has(button)) {
      select {
        appearance: none;
      }

      /* Arrow */
      &:after {
        align-self: center;
        block-size: 0;
        border-block-start: 5px solid;
        border-inline: 5px solid transparent;
        content: "";
        display: inline-block;
        flex-shrink: 0;
        grid-column: 1/-1;
        grid-row: 1;
        inline-size: 0;
        inset-inline-end: var(--size-3);
        justify-self: end;
        pointer-events: none;
        position: relative;
      }
    }
  }

  select:has(button),
  ::picker(select) {
    appearance: base-select;
  }
}
css
@layer components.has-deps {
  /*
Lists meant to be used stand-alone or as part of Select elements

  Intended use-case:
  - ul.list > li
  - .select > .list > option
*/
  :where(.list) {
    --_bg-color: light-dark(var(--gray-1), var(--gray-15));

    background-color: var(--_bg-color);
    list-style: none;
    padding: var(--size-2) 0;

    @media (pointer: coarse) {
      &,
      * {
        user-select: none;
      }
    }

    /* Borders on all list items */
    &.bordered {
      :where(li + li, option + option) {
        margin-block-start: var(--size-3);
        &:before {
          block-size: 1px;
          border-block-start: 1px solid var(--border-color);
          content: "";
          display: block;
          inline-size: 100%;
          inset: calc(-1 * var(--size-2)) 0 auto 0;
          position: absolute;
          visibility: visible; /* override select > option:before style */
        }
      }
    }

    /* Dense - less gaps and spacing */
    &.dense {
      :where(li, option) {
        gap: var(--size-2);
        min-block-size: var(--size-7);
        padding: var(--size-1) var(--size-2);

        &.border-top {
          margin-block-start: var(--size-2);
          &:before {
            inset: calc(-1 * var(--size-1)) 0 auto 0;
          }
        }

        /* Clickable list item */
        &:has(> a, > button, > label) {
          min-block-size: auto;
          padding: 0;
        }

        & > :where(a, button, label) {
          gap: var(--size-2);
          min-block-size: var(--size-7);
          padding: var(--size-1) var(--size-2);
        }

        /* Checkbox / Radio */
        & > label {
          .end {
            padding-inline-end: 0.125rem;
          }
        }

        /* Leading and trailing content */
        .start,
        .end {
          .avatar {
            max-inline-size: var(--size-6);
          }

          .icon-button,
          svg {
            max-inline-size: var(--size-4);
          }

          .checkbox,
          .radio {
            max-inline-size: var(--size-3);
          }
        }
      }
    }

    /* Gutterless */
    &.gutterless {
      :where(li, option) {
        padding-inline: 0;

        & > :where(a, button, label) {
          padding-inline: 0;
        }
      }
    }

    /* List item */
    :where(li, option, [role="group"] > label) {
      align-items: center;
      background: var(--_bg-color) var(--ripple, none);
      display: flex;
      font-size: var(--font-size-sm);
      gap: var(--size-3);
      isolation: isolate;
      min-block-size: 40px;
      padding: var(--size-2) var(--size-3);
      position: relative;

      &:before {
        display: none; /* removing checkmark from option */
      }

      * {
        font-size: inherit;
      }

      /* Clickable list item */
      &:has(> a, > button, > label) {
        background: transparent;
        display: block;
        min-block-size: auto;
        padding: 0;
      }

      /* Select option */
      &:where(option) {
        align-items: center;
        background-color: var(--_bg-color);
        color: inherit;
        cursor: pointer;
        display: flex;
        gap: var(--size-3);
        inline-size: 100%;
        margin: 0;
        min-block-size: 40px;
        padding: var(--size-2) var(--size-3);
        text-align: start;
        text-decoration: none;
        z-index: 0;

        &:hover {
          background-color: light-dark(var(--gray-2), var(--gray-14));
        }

        &:checked {
          background-color: oklch(from var(--primary) l c h / 30%);
        }
      }

      & > a,
      & > button,
      & > label {
        align-items: center;
        background: var(--_bg-color) var(--ripple, none);
        color: inherit;
        cursor: pointer;
        display: flex;
        gap: var(--size-3);
        inline-size: 100%;
        margin: 0;
        min-block-size: 40px;
        padding: var(--size-2) var(--size-3);
        text-align: start;
        text-decoration: none;
        z-index: 0;

        /*** Ripple effect */
        background-position: center;
        transition: background var(--button-ripple-duration);
        &:where(:not(:active):hover) {
          --ripple: radial-gradient(circle, transparent 1%, var(--_bg-color) 1%)
            center/15000%;
        }

        &:where(:hover:active) {
          background-size: var(--button-ripple-size);
          transition: background 0s;
        }

        &:hover {
          background-color: light-dark(var(--gray-2), var(--gray-14));
        }

        /*** Remove ripple effect when trailing button is clicked */
        &:has(.end:hover) {
          &:where(:not(:active):hover) {
            --ripple: none;
          }
        }
      }

      /* Checkbox / Radio / Switch */
      & > label {
        .end {
          padding-inline-end: var(--size-1);
        }

        &:where(.checkbox, .radio) {
          inline-size: 100%;
        }

        &.switch {
          --_dot-size: 0.75rem;
          --_track-height: var(--size-4);
          --_track-width: 2.5rem;
        }
      }

      /* Video */
      &:has(video) {
        padding: 0.75rem var(--size-3) 0.75rem 0;
      }

      /* Border between list items */
      &.border-top {
        margin-block-start: var(--size-3);
        &:before {
          block-size: 1px;
          border-block-start: 1px solid var(--border-color);
          content: "";
          display: block;
          inline-size: 100%;
          inset: calc(-1 * var(--size-2)) 0 auto 0;
          position: absolute;
        }
      }

      /* Text */
      .text {
        flex: 1;
        line-height: 1.6;

        :where(h1, h2, h3, h4, h5, h6, p, span) {
          color: inherit;
          font-weight: 400;
        }

        p + p {
          font-size: var(--font-size-xs);
        }
      }

      /* Leading content */
      .start {
        align-self: center;
        align-items: center;
        display: grid;
        z-index: 1;

        &:has(svg) {
          max-inline-size: var(--size-5);
        }

        svg {
          padding-block-start: 0.125rem;
        }

        img {
          aspect-ratio: 1;
          inline-size: 56px;
          object-fit: cover;
        }

        video {
          aspect-ratio: 16/9;
          block-size: 64px;
          object-fit: cover;
        }
      }

      /* Trailing content */
      .end {
        align-items: center;
        display: flex;
        font-size: var(--font-size-xs);
        text-align: end;
        z-index: 1;

        &:not(:has(a, button, input)) {
          pointer-events: none;
        }

        kbd {
          background-color: transparent;
          border: 0;
          color: inherit;
          opacity: 0.6;
        }

        svg {
          max-inline-size: var(--size-5);
          inline-size: 100%;
        }
      }

      /* Inset */
      &.inset {
        .text {
          padding-inline-start: calc(var(--size-5) + var(--size-3));
        }

        /* Safety measure so it won't look bad if there for some reason should exist a leading element inside. */
        .start {
          display: none;
        }
      }
    }
  }
}