[CSS] シンプルで汎用性のあるフォームコントロールのデザイン

input要素, button要素, select要素, textarea要素, progress要素, meter要素といったフォームコントロールの見た目を CSS で整えるのは、昔に比べてずいぶんと簡単になりました。といっても、ブラウザ毎に独自の擬似要素・擬似クラスを使っていたり、変更不可能な部分もあるため、他の要素と比べてスタイルが設定しづらいのは変わりありません。

今回は、これらフォームコントロールをシンプルで統一感のあるデザインにするためのCSSコードを紹介していきます。

まずは各種CSSコードを設定したフォームコントロールをご覧ください。

See the Pen Designing simple form controls by nov (@numerofive) on CodePen.

このデザインのポイントは、背景色を透明にして、文字色と同じ色のボーダーで統一していることです。フォームコントロールを配置する祖先要素の背景色や文字色に左右されない、汎用性の高さを重視したデザインです。コピペして使い回してください。

スタイルを一元管理するためのCSS変数

フォームコントロールに限らず、繰り返し使用する値をCSS変数 (カスタムプロパティ) にしておくと、メンテナンスが容易になります。今回は下記のCSS変数を使用しています。

:root {
  /* 色 */
  --red: #f64;
  --yellow: #c80;
  --green: #3a0;
  --blue: #58f;
  --gray: #888;
  --black: #111;
  --white: #fff;
  --black-white: light-dark(var(--black), var(--white));
  --white-black: light-dark(var(--white), var(--black));

  /* 不透明度・色の混合量 */
  --alpha_lowest: 10%;
  --alpha_low: 30%;
  --alpha_middle: 50%;
  --alpha_high: 70%;
  --alpha_highest: 90%;

  /* ボーダーの幅 */
  --line_thin: 1px;
  --line_medium: 2px;
  --line_thick: 4px;

  /* 丸角の半径 */
  --radius_small: 2px;
  --radius_medium: 4px;
  --radius_large: 8px;
  --radius_largest: calc(1px * infinity);

  /* フォームコントロールに適用するボーダーの幅と色と丸角 */
  --formctrl_border-width: var(--line_thin);
  --formctrl_border-color: currentcolor;
  --formctrl_border-radius: var(--radius_small);
}

フィールド

形状が幅広な矩形の input, select, textarea のスタイルです。

HTML
<input class="__field__" … />
<select class="__field__" … > … </select>
<textarea class="__field__" … > … </textarea>
CSS
[class*="__field__"] {
  & {
    appearance: none;
    background: none;
    border: solid var(--formctrl_border-width) var(--formctrl_border-color);
    border-radius: var(--formctrl_border-radius);
    box-sizing: border-box;
    color: inherit;
    font: inherit;
    margin-block: calc(0.5lh - 0.5em); /* ハーフレディング (行の高さ / 2 - 文字サイズ / 2) */
    margin-inline: 0;
    max-inline-size: 100%;
    padding-block: calc(0.5lh - 0.5em); /* ハーフレディング (行の高さ / 2 - 文字サイズ / 2) */
    padding-inline: calc(1lh - 1em); /* レディング (行の高さ - 文字サイズ) */
    vertical-align: middle;
  }
  &:where(input) {
    &[type="search"] {
      -webkit-appearance: textfield;
    }
    &[type="file"] {
      & {
        cursor: pointer;
      }
      &::file-selector-button {
        display: none; /* 見た目を揃えるためボタンを非表示 */
      }
    }
    &:not([size]) {
      inline-size: 100%; /* size属性がなければ最大幅 */
    }
  }
  &:where(select) {
    * { /* optgroup と option のリセット */
      font-size: inherit;
      font-style: inherit;
      line-height: inherit;
    }
    &:is([multiple], [size]) {
      inline-size: 100%; /* multiple属性かsize属性があれば最大幅 */
    }
    &:is([multiple], [size]:not([size="1"])) { /* 複数行の設定 */
      vertical-align: bottom;
    }
    &:is(:not([multiple], [size]), [size="1"]:not([multiple])) { /* プルダウンメニューの設定 */
      & {
        cursor: pointer;
      }
      * {
        color: FieldText; /* 文字色の継承による不具合を解消 */
        color-scheme: light only; /* 常にライトモード */
      }
      :disabled {
        color: GrayText; /* 文字色の継承による不具合を解消 */
      }
      [selected]:disabled {
        display: none; /* ラベルとしてのオプションを非表示 */
      }
    }
  }
  &:where(textarea) {
    & {
      min-block-size: 1lh; /* リサイズ時の最小の高さを行の高さにする */
      vertical-align: bottom;
    }
    &:not([cols]) {
      inline-size: 100%; /* cols属性がなければ最大幅 */
    }
    &:not([rows]) {
      field-sizing: content; /* rows属性がなければ入力した文章量に応じて高さを拡張 */
    }
  }
  &[readonly] { /* readonly属性があれば薄い背景色を設定 */
    background:
      color-mix(
        in srgb,
        transparent,
        var(--black-white) var(--alpha_lowest)
      );
  }
  &::placeholder { /* placeholderの文字色を設定 */
    color:
      color-mix(
        in srgb,
        transparent,
        currentcolor var(--alpha_middle)
      );
    opacity: 1;
  }
  &::-webkit-search-decoration {
    -webkit-appearance: none;
  }
  &::-webkit-datetime-edit-fields-wrapper {
    padding: 0;
  }
  &::-webkit-calendar-picker-indicator { /* カレンダーピッカーが見えづらくなる不具合を解消 */
    background-color: Field;
    block-size: 1em;
    border-radius: 50%;
    color-scheme: light only;
    cursor: pointer;
    inline-size: 1em;
  }
  &::-webkit-inner-spin-button {
    height: auto;
  }
  &:invalid { /* 入力した内容が妥当でない場合は赤のラインを表示 */
    box-shadow: 0 0 0 var(--line_thin) var(--red) inset;
  }
  &:user-valid:not(:focus) { /* 入力した内容が妥当である場合は緑のラインを表示 */
    box-shadow: 0 0 0 var(--line_thin) var(--green) inset;
  }
  &:disabled {
    cursor: auto;
    opacity: var(--alpha_low);
    pointer-events: none;
  }
}

フィールド内側の余白は、行の高さ (lh) から文字の高さ (em) を引いたレディング (行間) を元に設定しています。これにより、配置する親要素の行間に合わせて余白が最適化されます。

背景色を透明にしたことで、祖先要素の文字色と背景色によっては、カレンダーピッカーのアイコンやselect要素のプルダウンメニュー内の文字に適切な色が設定されないという不具合が発生します。

これらは Field, FieldText, GrayText といった「システムカラー」の使用や、color-scheme: light only; でライトモードを強制することで解消できます。

レンジスライダー

HTML
<input class="__range__" type="range" … />
CSS
[class*="__range__"] {
  @supports selector(::-webkit-slider-thumb)
  or selector(::-moz-range-thumb) {
    & {
      --thumb_block-size: calc(var(--formctrl_border-width) * 2 + 1rem);
      --thumb_border: solid var(--formctrl_border-width) var(--formctrl_border-color);
      --thumb_border-radius: var(--radius_largest);
      --thumb_box-shadow:
        calc(-100dvmax - var(--formctrl_border-width) - var(--thumb_block-size))
        0
        0
        100dvmax
        var(--blue), /* min属性値からvalue属性値までを表す左側の影 */
        calc(100dvmax + var(--formctrl_border-width) + var(--thumb_block-size))
        0
        0
        100dvmax
        color-mix(
          in srgb,
          var(--white-black),
          var(--formctrl_border-color) var(--alpha_low)
        ); /* value属性値からmax属性値までを表す右側の影 */
      --thumb_clip-path:
        polygon(
          0 0,
          100% 0,
          100% calc(50% - var(--formctrl_border-width)),
          calc(100% + 100dvmax) calc(50% - var(--formctrl_border-width)),
          calc(100% + 100dvmax) calc(50% + var(--formctrl_border-width)),
          100% calc(50% + var(--formctrl_border-width)),
          100% 100%,
          0 100%,
          0 calc(50% - var(--formctrl_border-width)),
          -100dvmax calc(50% - var(--formctrl_border-width)),
          -100dvmax calc(50% + var(--formctrl_border-width)),
          0 calc(50% + var(--formctrl_border-width))
        ); /* 影をバー状にトリミング */
      appearance: none;
      background: none;
      block-size: var(--thumb_block-size);
      box-sizing: border-box;
      color: inherit;
      cursor: pointer;
      font: inherit;
      inline-size: 100%;
      margin: 0;
      overflow: hidden; /* はみ出した影を非表示 */
      vertical-align: middle;
    }
    &::-webkit-slider-thumb {
      appearance: none;
      background: none;
      block-size: var(--thumb_block-size);
      border: var(--thumb_border);
      border-radius: var(--thumb_border-radius);
      box-shadow: var(--thumb_box-shadow);
      box-sizing: border-box;
      clip-path: var(--thumb_clip-path);
      color: inherit;
      inline-size: var(--thumb_block-size);
    }
    &::-moz-range-thumb {
      background: none;
      block-size: var(--thumb_block-size);
      border: var(--thumb_border);
      border-radius: var(--thumb_border-radius);
      box-shadow: var(--thumb_box-shadow);
      box-sizing: border-box;
      clip-path: var(--thumb_clip-path);
      color: inherit;
      inline-size: var(--thumb_block-size);
    }
    &:dir(rtl) { /* 書字方向が右から左ならスライダーを反転 */
      &::-webkit-slider-thumb {
        rotate: 180deg;
      }
      &::-moz-range-thumb {
        rotate: 180deg;
      }
    }
    &:disabled {
      cursor: auto;
      opacity: var(--alpha_low);
      pointer-events: none;
    }
  }
}

レンジスライダーは標準化されていないブラウザ独自の擬似要素を使用しているため、@supports で擬似要素が使用できるかを確認し、ブラウザ毎に分けて設定します。

レンジスライダーのバーは、サム (つまみ) の左側に青の影、右側にボーダー色を薄くした影を生成し、これらを clip-path でトリミングして作っています。

カラーピッカー

HTML
<input class="__color__" type="color" … />
CSS
[class*="__color__"] {
  @supports selector(::-webkit-color-swatch)
  or selector(::-moz-color-swatch) {
    & {
      appearance: none;
      background: none;
      block-size: calc(var(--formctrl_border-width) * 2 + 2rem);
      border: solid var(--formctrl_border-width) var(--formctrl_border-color);
      border-radius: var(--radius_largest);
      box-sizing: border-box;
      color: inherit;
      cursor: pointer;
      font: inherit;
      inline-size: calc(var(--formctrl_border-width) * 2 + 2rem);
      margin-block: calc(0.5lh - 0.5em); /* ハーフレディング (行の高さ / 2 - 文字サイズ / 2) */
      margin-inline: 0;
      padding: 0.125rem;
      vertical-align: bottom;
    }
    &::-webkit-color-swatch-wrapper,
    &::-webkit-color-swatch {
      border: none;
      border-radius: inherit;
      padding: 0;
    }
    &::-moz-color-swatch {
      border: none;
      border-radius: inherit;
      padding: 0;
    }
    &:disabled {
      cursor: auto;
      opacity: var(--alpha_low);
      pointer-events: none;
    }
  }
}

カラーピッカーも標準化されていないブラウザ独自の擬似要素を使用しているため、@supports で擬似要素が使用できるかを確認し、ブラウザ毎に分けて設定します。

チェックボックス

HTML
<input class="__checkbox" type="checkbox" … />
CSS
[class*="__checkbox__"] {
  & {
    appearance: none;
    background: none;
    block-size: calc(var(--formctrl_border-width) * 2 + 1rem);
    border: solid var(--formctrl_border-width) var(--formctrl_border-color);
    border-radius: var(--formctrl_border-radius);
    box-sizing: border-box;
    color: inherit;
    cursor: pointer;
    font: inherit;
    inline-size: calc(var(--formctrl_border-width) * 2 + 1rem);
    margin-block: max(0px, 0.5lh - var(--formctrl_border-width) - 0.5rem);
    margin-inline: 0;
    padding: 0.0625rem;
    vertical-align: bottom;
  }
  &::before { /* チェックマーク */
    block-size: 100%;
    box-shadow: 0 0 0 1rem currentcolor inset; /* チェックマークの塗りつぶし */
    clip-path:
      polygon(
        0 37.5%,       /* calc(100% *  0 / 16) calc(100% *  6 / 16) */
        31.25% 68.75%, /* calc(100% *  5 / 16) calc(100% * 11 / 16) */
        100% 0,        /* calc(100% * 16 / 16) calc(100% *  0 / 16) */
        100% 31.25%,   /* calc(100% * 16 / 16) calc(100% *  5 / 16) */
        31.25% 100%,   /* calc(100% *  5 / 16) calc(100% * 16 / 16) */
        0 68.75%       /* calc(100% *  0 / 16) calc(100% * 11 / 16) */
      ); /* チェックマークの形にトリミング */
    display: block;
  }
  &:checked::before {
    content: "";
  }
  &:disabled {
    cursor: auto;
    opacity: var(--alpha_low);
    pointer-events: none;
  }
}

チェックボックスのチェックマークは擬似要素を box-shadow で塗りつぶし、clip-path でトリミングして作っています。background ではなく box-shadow を使用している理由は、ブラウザの印刷機能を使用したとき、background が無効化される可能性があるためです。

ラジオボタン

HTML
<input class="__radio__" type="radio" … />
CSS
[class*="__radio__"] {
  & {
    appearance: none;
    background: none;
    block-size: calc(var(--formctrl_border-width) * 2 + 1rem);
    border: solid var(--formctrl_border-width) var(--formctrl_border-color);
    border-radius: 50%;
    box-sizing: border-box;
    color: inherit;
    cursor: pointer;
    font: inherit;
    inline-size: calc(var(--formctrl_border-width) * 2 + 1rem);
    margin-block: max(0px, 0.5lh - var(--formctrl_border-width) - 0.5rem);
    margin-inline: 0;
    padding: 0.125rem;
    vertical-align: bottom;
  }
  &::before { /* 黒丸 */
    block-size: 100%;
    border-radius: inherit;
    box-shadow: 0 0 0 1rem currentcolor inset; /* 黒丸の塗りつぶし */
    display: block;
  }
  &:checked::before {
    content: "";
  }
  &:disabled {
    cursor: auto;
    opacity: var(--alpha_low);
    pointer-events: none;
  }
}

CSSコードの多くの部分がチェックボックスと同じなので、チェックボックスと合わせて使用する場合には、まとめて設定して、異なる部分だけ分けるとコード量が抑えられます。

ボタン

HTML
<input class="__button__" type="button" … style="--border-radius: var(--radius-small);" />
<button class="__button__" type="button" … style="--color: var(--red);"> … </button>
<a class="__button__" … style="--background: var(--red);"> … </a>
CSS
[class*="__button__"] {
  &:where(a) { /* a要素にボタンのスタイルを追加 */
    display: inline-block;
    text-align: center;
    text-decoration: none;
    user-select: none;
  }
  & {
    -webkit-appearance: button;
    background: none;
    border: solid var(--formctrl_border-width) var(--formctrl_border-color);
    border-radius: var(--formctrl_border-radius);
    box-sizing: border-box;
    color: inherit;
    cursor: pointer;
    font: inherit;
    font-weight: bolder;
    margin-block: calc(0.5lh - 0.5em); /* ハーフレディング (行の高さ / 2 - 文字サイズ / 2) */
    margin-inline: 0;
    max-inline-size: 100%;
    padding-block: calc(0.5lh - 0.5em); /* ハーフレディング (行の高さ / 2 - 文字サイズ / 2) */
    padding-inline: calc(2lh - 2em); /* レディング * 2 (行の高さ * 2 - 文字サイズ * 2) */
    position: relative;
    text-wrap: balance; /* 複数行になった場合に各行の文字数を揃える */
    text-wrap: pretty; /* 複数行になった場合に各行の文字数を揃え、最終行の文字数を調整する */
    touch-action: manipulation; /* タッチパネルでの誤操作を防止 */
    transition: box-shadow calc(1s / 5);
    vertical-align: middle;
  }
  &[style~="--background:"] { /* ボタンの背景色を上書きするオプション */
    background: var(--background);
    border-color: transparent;
    color: var(--white-black);
  }
  &[style~="--color:"] { /* ボタンの文字色とボーダー色を上書きするオプション */
    color: var(--color);
  }
  &[style~="--border-color:"] { /* ボタンのボーダー色を上書きするオプション */
    border-color: var(--border-color);
  }
  &[style~="--border-radius:"] { /* ボタンの形状を上書きするオプション */
    border-radius: var(--border-radius);
  }
  &:hover { /* ホバー時にボタンが浮き上がるエフェクト */
    box-shadow: 0 0 8px -2px var(--black-white);
    z-index: 1;
  }
  &:active { /* 浮き上がったボタンが押されるエフェクト */
    box-shadow: none;
    transition: none;
  }
  &:disabled {
    cursor: auto;
    opacity: var(--alpha_low);
    pointer-events: none;
  }
}

フィールドと同様にレディングで余白を設定しているため、検索フォームのようにフォームコントロールを一列に並べた際、高さがぴったり揃います。

プログレス

HTML
<progress class="__progress__" … > … </progress>
CSS
[class*="__progress__"] {
  & {
    box-sizing: border-box;
    font: inherit;
    inline-size: 100%;
    margin: 0;
    vertical-align: middle;
  }
  @supports selector(::-webkit-progress-bar)
  and selector(::-webkit-progress-value) {
    &,
    &::-webkit-progress-inner-element,
    &::-webkit-progress-bar {
      background: none;
      block-size: calc(var(--formctrl_border-width) * 2 + 1rem);
      border-radius: var(--radius_largest);
    }
    &::-webkit-progress-bar {
      border: solid var(--formctrl_border-width) var(--formctrl_border-color);
      padding: 0.125rem;
    }
    &::-webkit-progress-value {
      background: var(--blue);
      border-radius: inherit;
    }
  }
  @supports selector(::-moz-progress-bar) {
    & {
      background: none;
      block-size: calc(var(--formctrl_border-width) * 2 + 1rem);
      border: solid var(--formctrl_border-width) var(--formctrl_border-color);
      border-radius: var(--radius_largest);
      padding: 0.125rem;
    }
    &::-moz-progress-bar {
      background: var(--blue);
      border-radius: inherit;
    }
  }
}

プログレスも標準化されていないブラウザ独自の擬似要素を使用しているため、@supports で擬似要素が使用できるかを確認し、ブラウザ毎に分けて設定します。

メーター

<meter class="__meter__" … > … </meter>
[class*="__meter__"] {
  & {
    box-sizing: border-box;
    font: inherit;
    inline-size: 100%;
    margin: 0;
    vertical-align: middle;
  }
  @supports selector(::-webkit-meter-bar)
  and selector(::-webkit-meter-optimum-value)
  and selector(::-webkit-meter-suboptimum-value)
  and selector(::-webkit-meter-even-less-good-value) {
    &,
    &::-webkit-meter-inner-element,
    &::-webkit-meter-bar {
      background: none;
      block-size: calc(var(--formctrl_border-width) * 2 + 1rem);
      border-radius: var(--radius_largest);
    }
    &::-webkit-meter-bar {
      border: solid var(--formctrl_border-width) var(--formctrl_border-color);
      padding: 0.125rem;
    }
    &::-webkit-meter-optimum-value {
      background: var(--green);
      border-radius: inherit;
    }
    &::-webkit-meter-suboptimum-value {
      background: var(--yellow);
      border-radius: inherit;
    }
    &::-webkit-meter-even-less-good-value {
      background: var(--red);
      border-radius: inherit;
    }
  }
  @supports selector(::-moz-meter-bar)
  and selector(:-moz-meter-optimum)
  and selector(:-moz-meter-sub-optimum)
  and selector(:-moz-meter-sub-sub-optimum) {
    & {
      background: none;
      block-size: calc(var(--formctrl_border-width) * 2 + 1rem);
      border: solid var(--formctrl_border-width) var(--formctrl_border-color);
      border-radius: var(--radius_largest);
      padding: 0.125rem;
    }
    &::-moz-meter-bar {
      border-radius: inherit;
    }
    &:-moz-meter-optimum::-moz-meter-bar {
      background: var(--green);
    }
    &:-moz-meter-sub-optimum::-moz-meter-bar {
      background: var(--yellow);
    }
    &:-moz-meter-sub-sub-optimum::-moz-meter-bar {
      background: var(--red);
    }
  }
}

メーターも標準化されていないブラウザ独自の擬似要素を使用しているため、@supports で擬似要素が使用できるかを確認し、ブラウザ毎に分けて設定します。

クラス名の前後にアンダーバー2つを付けている理由

[class*="__field__"] のようにしている理由は、拡張性を持たせるためです。

…
<style>
.__field__half-size {
  inline-size: 50%;
}
</style>
…
<input class="__field__half-size" type="text" … />
…

上記ように拡張すれば、[class*="__field__"].__field__half-size の両方の設定が適用されます。.__field__half-size の設定を削除しても、[class*="__field__"] の設定だけは適用されるので、最低限のデザインは確保できます。

ぬかみそのように熟成させてきたフォームコントロールのCSSコードをご堪能あれ。分からないことがあれば、気軽にコメントしてください。

関連記事