Patterns

input[type="checkbox"]要素のswitch属性を使用したスイッチUIの実装例

input[type="checkbox"]要素のswitch属性を使用したスイッチの実装メモです。まだ実務で使用したわけではないのであくまで自由研究としてですが、将来的にスイッチUIはinput[type="checkbox"]要素にswitch属性を指定して実装するケースが増えそうなので実装例としてまとめておきます。

switch属性とは

現在のHTMLの仕様を提供しているWHATWGより提案された、スイッチUIを実装するためのinput[type="checkbox"]要素に新たに追加された属性です。switch属性を指定することでWeb標準でスイッチUIを作成することが可能となります。

原則的にはこれでOK
<input type="checkbox" switch />

Add switch attribute to the input element to allow for a two-state switch control. by lilyspiniolas · Pull Request #9546 · whatwg/html

Addresses #4180. This change would add a switch attribute that applies to the input element when it's in the checkbox state. When set, the input would…

github.com

switch属性は現在のHTMLの仕様にはまだ含まれていないものの、iOS 含む Safari 17.4 より先行的にサポートされています。

デフォルトのswitch属性の表示例

switch属性を使用するメリットとしてはデフォルトのroleがswitchに変更されるということです。これにより、switch属性がサポートされている環境では「スイッチ」と読み上げられ、スイッチUIがトグルされた場合には「オン」「オフ」と現在の状態が読み上げられます。また、従来のinput[type="checkbox"]と同じくキーボード操作時はスペースキー押下でトグルすることが可能です。

表示例を Voice Over + Safari 17.5 で確認したもの。「オン」「オフ」と現在の状態が読み上げられている。

現時点では iOS 含む Safari 17.4 以降でしかサポートされておりませんが、チェックボックスと同じ読み上げをされても差し支えないスイッチUIであればJS不要ということもありプログレッシブ・エンハンスメントの一環で導入しても問題ないでしょう。仮にチェックボックスとして読み上げられるのが望ましくないスイッチUIであれば、button要素とaria-pressed属性の組み合わせでトグルボタンとして実装するのがベターだと思います。

スイッチUIをCSSで実装する

switch属性がサポートされていない環境ではユーザーエージェントで用意されたスイッチUIを使用することができなかったり、デザインの要件によっては自作でスイッチUIを実装したほうが都合が良いケースも考えられるため、スイッチUIをCSSで作成します。この実装はbutton要素とaria-pressed属性の組み合わせで作成するトグルボタンにも流用可能です。

以下はスイッチUIの実装例です。

スイッチUIの実装例

スイッチUIのスタイリングは input 要素に直接適用する

前提としてスイッチUIのスタイリングは input 要素に直接適用するようにします。

過去に投稿したZennの記事(新しいタブで開く)のように、かつては自作のラジオボタンやチェックボックスを作成する際は元のinput要素を非表示にした上で空のspan要素などを装飾し、隣接セレクタなどで状態管理するのが一般的でした。しかし、現在ではinput要素を直接スタイリングすることが可能です。

加えて[type="radio"]および[type="checkbox"]には::before ::after疑似要素も適用できるため、実装コストやアクセシビリティチェックの観点からもinput要素を直接スタイリングするのが望ましいでしょう。

🙅‍♂ Not Recommended
<label>
	<input type="checkbox" switch />
	<span class="icon" aria-hidden="true"></span>
	このスイッチを押す
</label>

<style>
	[switch] {
		position: absolute;
		opacity: 0;
		pointer-events: none;
	}

	.icon {
		/* スイッチUIのCSS */
	}
</style>
🙆‍♂ Recommended
<label>
	<input type="checkbox" switch />
	このスイッチを押す
</label>

<style>
	[switch] {
		/* スイッチUIのCSS */
	}
</style>

input[type=“checkbox”] のデフォルトのスタイルをリセットする

下準備としてユーザーエージェントで用意されているスイッチおよびチェックボックスのスタイルを無効化するためにappearanceプロパティの値をnoneにします。また、デフォルトのmarginは不要であり、line-heightも後述するlhの使用に支障があるためリセットしておくと良いでしょう。

リセットCSSの例
[switch] {
	padding: unset;
	margin: unset;
	font: unset;
	appearance: unset;
	background-color: unset;
	border: unset;
}

スイッチUIの外枠をスタイリングする

続いてスイッチUIの外枠をスタイリングしていきます。形状は錠剤型にしたいのでborder-radius:calc(infinity * 1px)で角丸を実装します。

角丸用のCSSを追加する
[switch] {
	padding: unset;
	margin: unset;
	font: unset;
	appearance: unset;
	background-color: unset;
	border: unset;
	border-radius: calc(infinity * 1px);
}

角丸の指定はborder-radius:999pxやborder-radius:100vmaxでも大抵の場合は問題ありませんが、border-radius:calc(infinity * 1px)であれば確実に角丸を実装することが可能です。

デザインガイドラインで角丸がトークン化されていて、グローバルスコープのCSS変数で定義している場合は角丸用の変数も用意しておくと良いでしょう。長方形であれば錠剤型に、正方形であれば正円となります。

global.css
/* Border Radius */
:root {
	--rounded-sm: 4px;
	--rounded-md: 8px;
	--rounded-lg: 12px;
	--rounded-full: calc(infinity * 1px);
}

スイッチUIのサイズはデザインの仕様に従いますが、指定がない場合は高さを行の高さと同等にしておくと見た目が綺麗になります。前回の記事で紹介した1lhを指定すれば現在のline-heightの高さと同等になるため柔軟性が高くなります。1lhがサポートされていない iOS 16.4 未満を考慮する場合は@supports機能クエリを使用して適当な値をフォールバックしておくと良いでしょう。

svg {
width: auto;
height: 1lh;
}

👍 https://t.co/z7mAliJiip

ポストを別枠で表示する

そのままだと横幅と高さの指定が効かないためdisplay:inline-blockなどの値を指定する必要がありますが、後述する理由からスイッチUIの場合はdisplay:inline-flexを指定するようにします。ついでにvertical-align:middleを指定してラベルのテキストの縦中央に揃えておきます。

スイッチUIのCSS
[switch] {
	--size-unit: 1lh;

	display: inline-flex;
	inline-size: calc(var(--size-unit) * 2);
	block-size: var(--size-unit);
	padding: unset;
	margin: unset;
	font: unset;
	vertical-align: middle;
	appearance: unset;
	background-color: unset;
	border: unset;
	border-radius: var(--rounded-full);

	/* フォールバック */
	@supports not (top: 1lh) {
		--size-unit: 1.5em;
	}
}

ちなみにサンプルおよびこのブログではdisplayの値に2値構文を使用していますが、論理プロパティ・論理値の指定とは違い2値構文を使用するメリットは「displayの指定が論理的になる」以外特に無いため、display:inline-flexのように従来の指定をすることを推奨します。

また、縦横比が決まっている場合であってもinput[type="checkbox"]にaspect-ratioを指定するとSafariで潰れて表示されてしまうため、inline-size(width)とblock-size(height) でサイジングすることを強く勧めます。

🙅‍♂ Not Recommended
[switch] {
	--size-unit: 1lh;

	aspect-ratio: 2 / 1;
	block-size: var(--size-unit);
}

また、「オン」「オフ」時の背景色も設定します。サンプルではHEX値を直書きしていますが、グローバルスコープのCSS変数で定義されているカラースキームを参照するほうが望ましいです。

スイッチUIのCSS
[switch] {
	--size-unit: 1lh;
	--background: #ccc;
	--duration: 0.15s;

	display: inline-flex;
	inline-size: calc(var(--size-unit) * 2);
	block-size: var(--size-unit);
	padding: unset;
	margin: unset;
	font: unset;
	vertical-align: middle;
	appearance: unset;
	background-color: unset;
	background-color: var(--background);
	border: unset;
	border-radius: var(--rounded-full);
	transition: background-color var(--duration);

	&:checked {
		--background: #0095c2;
	}

	/* フォールバック */
	@supports not (top: 1lh) {
		--size-unit: 1.5em;
	}
}

スイッチのハンドル部分を作成する

スイッチのハンドル部分はinput要素の::before ::after疑似要素を使用して作成します。

switch属性を付与したUIは::thumb疑似要素を装飾することでハンドルをカスタマイズできますが、switch属性がサポートされていることが前提であることに加えてinput要素の::before ::after疑似要素でもハンドルは作成できるため、::thumb疑似要素を使うメリットはあまり感じられませんでした。

ハンドル部分のCSS
[switch] {
	--size-unit: 1lh;
	--background: #ccc;
	--foreground: #fcfcfc;
	--duration: 0.15s;

	/* 省略 */

	&::after {
		display: inline-block;
		flex-shrink: 0;
		block-size: 100%;
		aspect-ratio: 1;
		content: '';
		background-color: var(--foreground);
		border-radius: var(--rounded-full);
	}
}

ハンドル部分は::after疑似要素を正円形にして実装します。潰れるのを防止するためflex-shrink:0も指定しておきます。

サイジングは高さ(block-size)を100%にした上でaspect-ratio:1で縦横比を1:1にするようにしてください。こうすることでスイッチUIの高さが可変になっても正円形を保つことができます。


「オン」「オフ」時のハンドルの移動は::before疑似要素をflexで収縮することで実現します。他のスイッチUIの実装記事を見ているとtranslate等で頑張っている印象ですが、空の疑似要素を収縮させた方が実装コストは少ない印象ですし、スイッチUIの横幅が可変しても動作に影響は起こらなくなります。

ハンドル部分のCSS
[switch] {
	--size-unit: 1lh;
	--background: #ccc;
	--foreground: #fcfcfc;
	--duration: 0.15s;

	/* 省略 */

	&::before {
		flex: 0;
		content: '';
		transition: flex var(--duration) linear;
	}

	&:checked::before {
		flex: 1;
	}

	&::after {
		display: inline-block;
		flex-shrink: 0;
		block-size: 100%;
		aspect-ratio: 1;
		content: '';
		background-color: var(--foreground);
		border-radius: var(--rounded-full);
	}
}

あとはinput要素のpaddingの値を調整すれば簡単にスイッチUIのできあがりです。

スイッチUIのCSS
[switch] {
	--size-unit: 1lh;
	--foreground: #fcfcfc;
	--background: #ccc;
	--duration: 0.15s;

	display: inline-flex;
	inline-size: calc(var(--size-unit) * 2);
	block-size: var(--size-unit);
	padding: 2px;
	font: unset;
	vertical-align: middle;
	appearance: unset;
	background-color: var(--background);
	border: unset;
	border-radius: var(--rounded-full);
	transition: background-color var(--duration);

	@supports not (top: 1lh) {
		--size-unit: 1.5em;
	}

	&:checked {
		--background: #0095c2;
	}

	&::before {
		flex: 0;
		content: '';
		transition: flex var(--duration) linear;
	}

	&:checked::before {
		flex: 1;
	}

	&::after {
		display: inline-block;
		flex-shrink: 0;
		block-size: 100%;
		aspect-ratio: 1;
		content: '';
		background-color: var(--foreground);
		border-radius: var(--rounded-full);
	}
}

強制カラーモードの対応を行う

デバイスによっては文章を読みやすくするために背景色や文字色をコントラストが明白な配色に変更する「強制カラーモード」が備わっています。原則的にテキストやSVGを含む画像であれば強制カラーモード適用下でも表示されるため問題にはなり得ないことが多いですが、疑似要素を使ったアイコンやUIに関しては消失してしまう可能性があります。

forced-colors - CSS: カスケーディングスタイルシート | MDN

forced-colors は CSS のメディア特性で、ユーザーエージェントが強制カラーモードを有効にしているかどうかを検出するために使用されます。強制カラーモードの例としては、 Windows のハイコントラストモードがあります。

developer.mozilla.org

また、以下のプロパティは、強制カラーモードでは特別な動作をします。

box-shadow は ‘none’ に強制されます

text-shadow は ‘none’ に強制されます

background-image は URL ベースでない値では ‘none’ に強制されます

color-scheme は ‘light dark’ に強制されます

scrollbar-color は ‘auto’ に強制されます

forced-colors - CSS | MDN より引用

先程のスイッチUIの実装例に関しても強制カラーモード適用下では無事消失していましたので、forced-colorsメディア特性を使用してスイッチUIを認識できるようにします。

原則的にborderは強制カラーモード適用下でも消失せず、システムカラーを指定したbackground-colorは認識できる色として表示されるため、forced-colors:active時はborderの付与とハンドルの背景色をCanvasText(ユーザーエージェントで提供されるテキスト色)にすることで強制カラーモード適用下でもスイッチUIを表示することができます。

強制カラーモード対応を行ったスイッチUIのCSS
[switch] {
	--size-unit: 1lh;
	--foreground: #fcfcfc;
	--background: #ccc;
	--duration: 0.15s;

	display: inline-flex;
	inline-size: calc(var(--size-unit) * 2);
	block-size: var(--size-unit);
	padding: 2px;
	font: unset;
	vertical-align: middle;
	appearance: unset;
	background-color: var(--background);
	border: unset;
	border-radius: var(--rounded-full);
	transition: background-color var(--duration);

	@supports not (top: 1lh) {
		--size-unit: 1.5em;
	}

	@media (forced-colors: active) {
		--foreground: canvastext;

		border: 1px solid;
	}

	&:checked {
		--background: #0095c2;
	}

	&::before {
		flex: 0;
		content: '';
		transition: flex var(--duration) linear;
	}

	&:checked::before {
		flex: 1;
	}

	&::after {
		display: inline-block;
		flex-shrink: 0;
		block-size: 100%;
		aspect-ratio: 1;
		content: '';
		background-color: var(--foreground);
		border-radius: var(--rounded-full);
	}
}
強制カラーモードを有効にした状態で、黒い背景に2つのスイッチが表示されている
表示例をChromeのレンダリングタブより確認した場合

【おまけ】フォールバックトリックを使って強制カラーモードの対応を行う

実務で導入する価値があるかは皆さんの判断にお任せしますが、このブログではフォールバックトリックを使って強制カラーモードの対応を行っています。

グローバルスコープに以下の指定を適用
:root {
	--is-forced-true: ;
	--is-forced-false: initial;

	@media (forced-colors: active) {
		--is-forced-true: initial;
		--is-forced-false: ;
	}
}
フォールバックトリックを使った例
[switch] {
	--size-unit: 1lh;
	--foreground: var(--is-forced-true, canvastext) var(--is-forced-false, #fcfcfc);
	--background: #ccc;
	--duration: 0.15s;

	display: inline-flex;
	inline-size: calc(var(--size-unit) * 2);
	block-size: var(--size-unit);
	padding: 2px;
	font: unset;
	vertical-align: middle;
	appearance: unset;
	background-color: var(--background);
	border: var(--is-forced-true, 1px solid) var(--is-forced-false, unset);
	border-radius: calc(infinity * 1px);
	transition: background-color var(--duration);

	@supports not (top: 1lh) {
		--size-unit: 1.5em;
	}

	&:checked {
		--background: #0095c2;
	}

	&::before {
		flex: 0;
		content: '';
		transition: flex var(--duration) linear;
	}

	&:checked::before {
		flex: 1;
	}

	&::after {
		display: inline-block;
		flex-shrink: 0;
		block-size: 100%;
		aspect-ratio: 1;
		content: '';
		background-color: var(--foreground);
		border-radius: 50%;
	}
}

フォールバックトリックってなんやねんって方も多いかとは思いますが(ご存じの方は読み飛ばしてください)、CSSカスタムプロパティの仕様(新しいタブで開く)を利用して--is-forced-true: ; --is-forced-false: initial;のようにフラグを書き換えることで強制カラーモードが適用されているかどうか否かで値を出し分ける方法です。

The initial value of a custom property is a guaranteed-invalid value. As defined in § 3 Using Cascading Variables: the var() notation, using var() to substitute a custom property with this as its value makes the property referencing it invalid at computed-value time.

This value serializes as the empty string, but actually writing an empty value into a custom property, like —foo: ;, is a valid (empty) value, not the guaranteed-invalid value. If, for whatever reason, one wants to manually reset a variable to the guaranteed-invalid value, using the keyword initial will do this.

CSS Custom Properties for Cascading Variables Module Level 1「2.2. Guaranteed-Invalid Values」 より引用

日本語に訳すと、次のとおりです(2.2. 保証された無効値)。

カスタムプロパティの初期値は、保証された無効値です。セクション3「カスケーディング変数の使用法:var() 表記」で定義されているように、この値を持つカスタムプロパティを var() を使用して代入すると、そのプロパティは計算値の時点で無効になります。

この値は空文字列としてシリアライズされますが、実際にカスタムプロパティに空の値を書き込む(例:—foo: ;)ことは有効な(空の)値であり、保証された無効値ではありません。何らかの理由で変数を手動で保証された無効値にリセットしたい場合は、キーワード initial を使用します。

上記仕様によれば--is-forced-false: ;は強制カラーモード適用時には「有効な値」かつ「空文字」として扱わるため、サンプルコードの--foreground変数は強制カラーモード適用時には次にような形になります。

[switch] {
	--foreground: var(--is-forced-true, canvastext) var(--is-forced-false, #fcfcfc);
	--foreground: var(--is-forced-true, canvastext) /* */;
}

そして、CSSカスタムプロパティにinitialを指定するとフォールバックで指定した値を返すため、var(--is-forced-true)のフォールバックで指定したcanvastextが適用されます。

[switch] {
	/* --is-forced-true: initial;が適用されているためフォールバックで指定した値が有効になる */
	--foreground: var(--is-forced-true, canvastext) /* */;
}

フォールバックトリックを使うメリットとしては「強制カラーモードが適用されているか否か」といった所謂if...elseのような指定をする際にわざわざメディアクエリを呼び出す必要が無いという点でしょうか。また、CSS Variable Autocomplete(新しいタブで開く)といった拡張機能を導入してCSS変数の補完を有効にすることでコーディング速度が向上したような気がします。

ただし、ややワザップ感ある方法ですし、恐らくはマイナーな手法であるため実務で使用するかどうかは考えたほうがいいと思います。

参考リンク