Web Topics

少しの記述でユーザビリティやアクセシビリティを向上させるHTML/CSSテクニック集

少しの記述・工夫でユーザビリティやアクセシビリティを向上させるHTML/CSSテクニックを独断と偏見で集めてみました。最近クローズドな場所で登壇を行ったのですが、そちらで話した内容を纏めたものにいくつか内容を追加したものとなります。

原則的にこのブログで取り入れられている手法だったり過去の記事で触れた手法を紹介したものです。

button要素には touch-action:manipulation を指定する

iOS限定の話ではありますが、button要素をつい連続でタップすると画面が拡大表示されてしまい非常に煩わしいです。

ポストを別枠で表示する

そのため、パンおよびズームのジェスチャーは有効にしつつダブルタップ時のズームなどの標準外の追加的なジェスチャーを無効にするtouch-action:manipulationを指定して誤作動を防止しておくと良いでしょう。

🙆‍♂ Recommended
:where(button, [type='button'], [type='reset'], [type='submit']) {
	touch-action: manipulation;
}

ハンバーガーボタンやその他のJSで操作するクリッカブルな要素をbuttonで実装している場合はevent.preventDefault()を適用しておけばこの現象は防止できますが、指定忘れだったりevent.preventDefault()の適用が望ましくないケースも存在するため、原則的にはプロジェクトのベースCSSに指定しておくと良いと思います。

overflow:auto を指定している要素には overscroll-behavior:contain も指定する

デスクトップで操作している際は気にならない項目ではありますが、overflow:autoが指定されたスクロールが可能な要素でラバーバンド効果(スワイプで更新や履歴を戻る処理をする際のバウンス効果)が有効なままだと暴発して操作がしにくい印象となります。

特にモーダル内でラバーバンド効果が発生すると「下にスクロールしようとしたら更新アクションに入ってしまう」みたいなケースが多く、そのたびに毛根へのダメージが加わるので原則的にoverflow:autoを指定している要素には overscroll-behavior:containも指定するようにしています。

🙆‍♂ Recommended
.scroller {
	overflow: auto;
	overscroll-behavior-block: contain;
}

当ブログのtable要素のようにインライン軸(横書き時:X軸)をスクロールさせる際にoverscroll-behavior:containを指定すると縦スワイプに影響が出てしまうので、原則的にはインライン軸(横書き時:X軸)のスクロールであればoverscroll-behavior-inline、ブロック軸(横書き時:Y軸)のスクロールであればoverscroll-behavior-blockにcontain値を指定することを推奨します。

「端っこ」におけるスクロールの挙動を制御する overscroll-behavior プロパティ

※この記事の内容は、まだブラウザに実装されていない内容を含みます。また、勧告前の仕様について言及しているため、最新の仕様では変更になっている場合があります。 要約 overscroll-behavior プロパティを使う […]

necomesi.jp

画面下部にコンテンツを固定配置する際はセーフエリアを考慮した位置調整を行う

position:fixedでハンバーガーボタンやトップへ戻るボタンを画面下部に固定配置する実装はよく用いられますが、iPhoneXの登場以降はホームバーが画面下部に表示されるようになった関係でそのまま固定配置するとホームバーにかぶさって表示されたり、ボタンを押したつもりがSafariのメニューボタンが出現して2度タップする手間が発生するといった問題があります。

🙅‍♂ Not Recommended
.return-top-button {
	--offset: 16px;

	position: fixed;
	inset-block-end: var(--offset);
	inset-inline-end: var(--offset);
}

env(safe-area-inset-bottom)で画面下部のセーフエリアの高さを取得できるため、calc()関数などと合わせてセーフエリアを考慮した位置調整を行うと良いでしょう。

🙆‍♂ Recommended
.return-top-button {
	--offset: 16px;

	position: fixed;
	inset-block-end: var(--offset);
	inset-block-end: calc(var(--offset) + env(safe-area-inset-bottom));
	inset-inline-end: var(--offset);
}

ホバー時のスタイルは any-hover メディア特性内に指定する

タップ操作orポインター操作の区別を行い、タップデバイスでのホバーアクションを無効にするためにany-hoverメディア特性内にてホバー時のスタイルを指定することを推奨します。

🙆‍♂ Recommended
@media (any-hover: hover) {
	.button:hover {
		background-color: var(--background-hover);
	}
}

タップデバイスでのhoverアクションを無効にすべき理由や@media (hover:hover)よりも@media (any-hover: hover)を優先したほうが良い理由は過去に投稿した記事「hoverを指定するならany-hoverメディア特性を使いなさい!俺流hover実装例も紹介します」にて詳細に解説しているのでそちらを参照してください。

hoverを指定するならany-hoverメディア特性を使いなさい!俺流hover実装例も紹介します – TAKLOG

タップデバイスでもhoverが動いているWebサイトが多すぎる!hoverを指定するならany-hoverメディア特性を使いなさい!ついでに俺流hover実装例も紹介します。

www.tak-dcxi.com

for 属性を指定している label 要素や summary 要素には cursor:pointer を指定しておく

for属性を指定しているlabel要素やsummary要素にホバーしてもカーソルはデフォルトのままですが、クリックしたら何かが行われる要素は原則的にカーソルをポインターアイコンに変えたほうが良いのでベースのCSSでcursor:pointerを指定しておくと良いでしょう。

🙆‍♂ Recommended
:where(
		:any-link,
		button,
		[type='button'],
		[type='reset'],
		[type='submit'],
		label[for],
		select,
		summary,
		[role='tab'],
		[role='button']
	) {
	cursor: pointer;
}

サードパーティー製のプラグインにはdiv要素にrole="button"を指定してボタンとするみたいな実装も多く見受けられるので、[role='tab']と[role='button']にもcursor:pointerを指定しておくのを推奨します。

デフォルトのフォーカスインジケーターは :focus:not(:focus-visible) 時に非表示にする

一般的にクリックやタップ時に表示されるフォーカスリングはデザイン的に好ましくないという場合で非表示にされることが多いです。

しかし、a要素やbutton要素に直接outline:noneを指定するとキーボード操作時のフォーカスリングも消えてしまい、アクセシビリティに悪影響を与えるため厳禁とされています。

🙅‍♂ Not Recommended
:where(a, button) {
	outline: none;
}

キーボード操作時のフォーカスリングを維持しつつクリックやタップ時のフォーカスリングを抑えたい場合は、以下のCSSをベースとしてプロジェクトに追加するようにします。

🙆‍♂ Recommended
:focus:not(:focus-visible) {
	outline: none;
}

:focus-visibleはユーザーエージェントが要素にフォーカスを明示するべきであると推測的に判断した場合に適用される擬似クラスです。開発者がoutline:noneを指定してキーボード操作時での悪影響を防ぐために設計されたという経緯があり、まさにこのようなケースの対応策としてベストな選択肢と言えるでしょう。

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

:focus-visible 擬似クラスは、要素が :focus 擬似クラスに一致している時で、ユーザーエージェントが要素にフォーカスを明示するべきであると推測的に判断した場合に適用されます (多くのブラウザーではこの場合、既定で「フォーカスリング」を表示します)。

developer.mozilla.org

ただし、「フォーカスを明示するべきであると推測的に判断した場合」とあるようにユーザーエージェントによって動作に多少の違いがあることは留意してください。具体的にはdialog要素やポップオーバー APIでのautofocusはSafariのみ:focus-visibleのスタイルがあたってしまいます。過去の投稿にてSafariでの非キーボード操作時のoutlineの出現を抑制する方法を紹介していますのでこちらも参照してください。

ホバーのスタイルは :focus-visible 擬似クラスにも適用する

キーボード操作時にフォーカスされている要素を明確に示すために:hover擬似クラスに指定している定義を:focus-visibleにも適用することを推奨します。

🙆‍♂ Recommended
.button {
	&:focus-visible {
		background-color: var(--background-hover);
	}

	@media (any-hover: hover) {
		&:hover {
			background-color: var(--background-hover);
		}
	}
}

フォーカスの明示をユーザーエージェントで用意されているoutlineのみで行うとフォーカスリングの色が背景色と似ている場合はフォーカスリングが見えにくくなる可能性がありますし、フォーカスリングは要素の外側に表示されるため親要素にoverflow:hidden等が指定されたりすると最悪フォーカスリングが隠れてしまうリスクもあります。

フォーカス時のスタイルを別途考えるとなるとデザイナーへの負担だったりコミュニケーションコスト増加の懸念があるため、大抵の場合はfocusableな要素には状態変化が明確なホバーのスタイルが指定されているでしょうからそれを流用するのが手っ取り早いと思います。

上下マージンを要素に含めたい場合は親要素に display:flow-root を指定する

要素に上下マージンを完全に含むようにする場合、親要素にoverflow:hiddenを指定するのが一つの回答として有名ですが、前項で説明した理由からフォーカスリングが切り取られてしまうリスクがあります。

🙅‍♂ Not Recommended
.prose {
	overflow: hidden;
}

.prose > * {
	margin-block: 1em;
}

上下マージンを完全に含むようにしたい場合は親要素にoverflow:hiddenではなくdisplay:flow-rootを指定することを推奨します。

ポストを別枠で表示する

🙆‍♂ Recommended
.prose {
	overflow: hidden;
	display: flow-root;
}

.prose > * {
	margin-block: 1em;
}

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

display は CSS のプロパティで、要素をブロックボックスとインラインボックスのどちらとして扱うか、およびその子要素のために使用されるレイアウト、例えば フローレイアウト、グリッド、フレックスなどを設定します。

developer.mozilla.org

transition を指定する際は transition-property も同時に指定する

これは過去の有料noteとかでも触れていますが、transition-propertyの値はアニメーションで動くものだけに絞るようにします。

🙆‍♂ Recommended
.button {
	transition: background-color 0.3s;

	&:focus-visible {
		background-color: var(--background-hover);
	}

	@media (any-hover: hover) {
		&:hover {
			background-color: var(--background-hover);
		}
	}
}

transition: 0.3sのようにtransition-propertyの記述をサボると初期値がall故にブレイクポイント跨いだ時や配色をデバイス設定で変えた際などに意図しないアニメーションが起こる可能性があります。僕は昔からtransition-propertyの値の指定を必須としています。

チェックボックスやラジオボタンを自作する際はフォーカスリングも追加する

チェックボックスやラジオボタンを自作するテクニックは多くの技術記事で紹介されていますが、残念ながらそれらの方法の多くはフォーカスが可視化されないことが多く、a要素やbutton要素にoutline:noneを指定しているのと変わらない実装がされている場合が多いです。

フォーカスが可視化していない自作チェックボックス

親要素(大抵の場合はlabel要素で囲まれるはずなのでそれ)に:has()セレクタで子孫要素に:focus-visibleが適応されている場合にフォーカスリングを表示するなどしてフォーカスを可視化することを推奨します。前述したようにホバー時のスタイルも適用しておくと良いでしょう。

🙆‍♂ Recommended
label {
	display: inline grid;
	grid-template-columns: auto 1fr;
	gap: 1ex;
	cursor: pointer;

	&:has(:focus-visible) {
		outline: auto oklch(60% 0.4 240deg);
		outline-offset: 4px;
		text-decoration: underline;
		text-underline-offset: 0.25em;
	}

	@media (any-hover: hover) {
		&:hover {
			text-decoration: underline;
			text-underline-offset: 0.25em;
		}
	}
}

input {
	position: absolute;
	opacity: 0;
	pointer-events: none;
}
フォーカスが可視化された自作チェックボックス

これは過去に僕も通った道ですが、チェックボックスやラジオボタンを自作する際にinput要素をdisplay:noneやvisibility:hiddenするとキーボード操作時にフォーカスが不可能になるため厳禁です。

ただし、現在ではinput要素を直接スタイリングすることができるため、input要素を非表示にして空のspan要素でパーツを自作する必要があるかは考えたほうがいいでしょう。加えて、[type="radio"]および[type="checkbox"]には::before ::after疑似要素も適用できるため、実装コストやアクセシビリティチェックの観点からもinput要素を直接スタイリングするのが望ましいかもしれません。

入力欄の font-size は計算上 16px 以上とする

inputやtextareaの入力欄に計算上16px未満のfont-sizeが指定されているとiOSでフォーカスするたびにズームされてしまいます。入力欄のfont-sizeは計算上16px以上にしてください。

つい最近某通販サイトにてこの現象に苦しめられたので、文部科学省はそろそろ義務教育で教えるべきだと考えます。

🙅‍♂ Not Recommended
input[type='text'] {
	font-size: 14px;
}
🙆‍♂ Recommended
input[type='text'] {
	font-size: 1rem; /* = 16px */
}

もしもデザインの都合上入力欄のfont-sizeを16px未満にすることが求められ、政治活動では覆せないような場合は:focus-visible時にfont-sizeを16px以上にするか、scaleプロパティで入力欄を縮小するようにしてください。

16px未満の入力欄の実装例

実装例1の:focus-visible時にfont-sizeを16px以上する方法はコストが低いですが、入力前後と入力中でfont-sizeに変動が起こるため見た目が不格好になる可能性があります。:active時にfont-sizeを16px以上にする実装方法もありますが、こちらはオートコンプリート時に拡大されてしまうので非推奨です。

実装例1
input[type='text'] {
	font-size: 0.875rem;

	&:focus-visible {
		font-size: 1rem;
	}
}

実装例2のscaleプロパティで入力欄を縮小する方法は見た目が不格好になりにくいメリットがありますが、それに至るためのプロセスに様々な工夫を要する必要があり、導入コストが高いです。

実装例2
.input-wrapper {
	--ratio: 1;
	--origin: 0 0;

	block-size: 3lh; /* 親要素で高さを設ける */
	overflow: clip; /* 横スクロールバーの出現を抑制する */
	border: 1px solid #aaa; /* scaleで縮小されるためinputにボーダーを指定すると細すぎて表示される可能性があります */

	@media (scripting: none) {
		--font-size: 1rem;
	}
}

input[type='text'] {
	inline-size: calc(100% / var(--ratio));
	block-size: calc(100% / var(--ratio));
	font-size: var(--font-size);
	transform-origin: var(--origin);
	scale: var(--ratio);
}

font-sizeの変動ごとに縮小率を変動させるのは面倒ですし、clamp()との相性が悪いのでJSで縮小率を計算するようにします。

JSの実装例
const BASE_FONT_SIZE = 16;

const initializeTextField = (element: HTMLElement): void => {
  if (!element) {
    console.error("initializeTextField: element is not found.");
    return;
  }

  element.style.setProperty("--font-size", `${BASE_FONT_SIZE}px`);
  element.style.setProperty(
    "--origin",
    isWritingModeVertical(element) ? "right top" : "0 0"
  );

  const debouncedResize = debounce(() => handleResize(element));
  window.addEventListener("resize", debouncedResize);
  debouncedResize();
};

const handleResize = (element: HTMLElement): void => {
  const { fontSize } = window.getComputedStyle(element);
  const ratio = parseFloat(fontSize) / BASE_FONT_SIZE;

  element.style.setProperty("--ratio", `${ratio}`);
};

const isWritingModeVertical = (element: HTMLElement): boolean => {
  const { writingMode } = window.getComputedStyle(element);
  return writingMode.includes("vertical") || writingMode.includes("sideways");
};

const debounce = <T extends any[], R>(callback: (...args: T) => R): ((...args: T) => void) => {
  let timeout: number | undefined

  return (...args: T): void => {
    if (timeout !== undefined) cancelAnimationFrame(timeout)
    timeout = requestAnimationFrame(() => callback.apply(this, args))
  }
}

// 🤞 Use
document.addEventListener("DOMContentLoaded", () => {
  const target = document.querySelector(".input-wrapper") as HTMLElement;

  if (target) initializeTextField(target);
});
  • 初期化時に--ratioカスタムプロパティに縮小率をセットしつつ、--font-sizeカスタムプロパティに16pxをセットします。読み込み時にフォントサイズが変動するため、挙動が不格好になるリスクがあるのに加えてレイアウトシフトも生じる可能性があるためです。scriptingメディア特性でJS無効環境ではCSS側で1remをセットします。JSでセットするfont-sizeを16pxとする理由は、1remとするとscaleで縮小率を管理している兼ね合いでブラウザの文字サイズ調節機能有効時に期待されるfont-size以上に拡大されてしまうためです。
  • 要素の書字方向をチェックし、--originカスタムプロパティを適切に設定します。
  • リサイズイベントはrequestAnimationFrameを使用したdebounce関数で最適化します。

また、iOSでの拡大を防ぐ方法としてmeta[name="viewport"]にuser-scalable=noおよびmaximum-scale=1を指定することを紹介している文献が多く見受けられます(Google検索で上位にヒットする記事は大体これ)が、現在のユーザーエージェントの多くはこの値を無視するとは言えユーザーへのズーム機能を制限する記述であるためアクセシビリティには良くないとされています。なるべくなら避けるべきでしょう。

🙅‍♂ Not Recommended
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no" />

いずれの方法もデメリットが目立つため、可能な限り入力欄のfont-sizeは計算上16px以上とするように政治活動をしたほうが良いと思います。

textarea には field-sizing:content を指定しておく

field-sizing:contentはtextareaの高さを内容に応じて調整することができるCSSプロパティです。

field-sizing - CSS: Cascading Style Sheets | MDN

The field-sizing CSS property enables you to control the sizing behavior of elements that are given a default preferred size, such as form control ele…

developer.mozilla.org

高さが固定されたtextarea要素は領域が狭い場合に長い文章の入力が煩わしくなったり、長い文章に備えて大きな領域を確保すると無駄にスクロールする必要が生じるなど使い勝手が少し悪い印象ですが、field-sizing:contentを利用することでこれらの問題を解決することができます。

field-sizing:contentの実装例

field-sizing:contentは現状Google ChromeとMicrosoft Edgeのみの対応となりますが、非対応のブラウザでfield-sizing:contentの指定が問題を起こすことはないのでプログレッシブエンハンスメントの一環として導入する価値はあります。

field-sizing

新しく利用可能 2026年6月から

執筆時点(2024年5月)は「一部のブラウザのみ」でした

Chrome
123
Edge
123
Firefox
152
Safari
26.2
iOS Safari
26.2

web-features 3.44.0 のデータ

webstatus.dev で見る

CSSの例
textarea {
	--cols: 30rem;
	--min-rows: 5lh;
	--max-rows: 20lh;
	--padding: 1em;

	box-sizing: border-box;
	inline-size: min(100%, var(--cols));
	min-block-size: calc(var(--min-rows) + var(--padding) * 2);
	max-block-size: calc(var(--max-rows) + var(--padding) * 2);
	padding: var(--padding);
	font-size: 1rem;
	field-sizing: content;
	resize: block;
}

field-sizing:contentが指定された場合cols属性とrows属性は無効となるため、横幅はCSSで調整しつつある程度の高さを確保しておきたい場合はmin-block-sizeを指定しておきます。また、高さの可変に制限がないとレイアウトの見た目に影響が出る可能性もあるのでそれが気になる場合はmax-block-sizeで上限値も設定しておくと良いでしょう。

ローディングオーバーレイはJS無効環境では非表示にする

このブログでは使用していませんが、読み込み完了までローディング画面をposition:fixedでオーバーレイする実装をよく見かけます。これらのローディングオーバーレイは読み込みが完了するとJSで非表示にする関係上、JS無効の状況ではローディング画面が消えずにコンテンツを見ることができないケースが多発しています。

SPAのようにJS依存のWebサイトであれば話は別ですが、JSにそこまで依存しない一般的なWebサイトであればJS無効環境でコンテンツが見えなくなってしまうのは避けるべきです。scriptingメディア特性を使用してJS無効の際はdisplay:noneするなど、JS無効環境の際はローディングオーバーレイを非表示にするようにしておくと良いでしょう。

🙆‍♂ Recommended
@media (scripting: none) {
	.loading {
		display: none;
	}
}

他にもスクロール連動アニメーションを採用しているサイトではJS無効時にアニメーション発火前のスタイルが当たったままになっている関係でコンテンツの大部分が見えなくなっているケースも散見されます。こういった場合の対処法は過去に投稿した記事『スクロール連動アニメーションの実装例』を参照してください。

ローディング画面のように絶対に最前面に表示させたい要素には z-index:calc(infinity) を指定する

ローディング画面やshowModal()を使わないモーダルのように絶対に最前面に表示させたい要素のz-indexにはcalc(infinity)を指定しておくと良いでしょう。z-index:calc(infinity)は上限値の2147483647と同等になるため、同一レイヤー内であれば原則的に最前面に表示させることができます。(z-index:calc(infinity)が複数存在する場合は要素の順序が後ろのものが最前面に来ます)

🙆‍♂ Recommended
.loading {
	z-index: calc(infinity);
}

showModal()したdialog要素やpopoverされた要素のような最上位レイヤーに位置する要素には勝つことができませんし、z-index:calc(infinity)の要素の親にスタッキングコンテキストが生成された場合はその中でイキることしかできませんのでスタッキングコンテキスト外のz-index:1に負けるなど z-index管理の銀の弾丸になる実装方法ではありません が、外部ライブラリのz-indexも考慮して絶対に最前面に表示させたい要素にz-index:calc(infinity)を明示しておくのはアリだと思います。


calc(infinity)の他の使い道としては、錠剤型のコンテンツを作成する際のborder-radiusにcalc(infinity * 1px)を指定することで確実に角丸を維持することが可能となります。よっぽどのことがない限り999emや100vmaxでも角丸を維持することはできると思いますが、確実性を持たせるならこちらの指定が良いでしょう。

border-radiusにcalc(infinity * 1px)を指定する
.button {
	border-radius: calc(infinity * 1px);
}

視差効果(アニメーション)を減らす設定がされている場合にはアニメーションを行わないようにする

一部のユーザーはWebサイトのアニメーション効果により前庭機能障害によるめまい、頭痛、吐き気などを引き起こす可能性があるため、アクセシビリティの観点から視差効果(アニメーション)を減らす設定がされている場合にはアニメーションの無効化を行うようにします。

ユーザーのプリファレンスに応じて過度なアニメーションを無効にする「prefers-reduced-motion」 | Accessible & Usable

ウェブコンテンツにおけるアニメーションは、アクセシビリティの問題を引き起こすことがあります。CSS の「prefers-reduced-motion」を用いることで、ユーザーのプリファレンス設定に応じて過度なアニメーションを無効にすることができますが、これを免罪符にしないようにしたいものです。

accessible-usable.net

prefers-reduced-motionメディア特性を使用して、ワイルドカードでアニメーションに纏わるスタイルを無効化するのを推奨します。

当ブログで指定されているCSS
@media (prefers-reduced-motion: reduce) {
	*,
	::before,
	::after,
	::backdrop {
		background-attachment: scroll !important;
		transition-delay: 0s !important;
		transition-duration: 1ms !important;
		animation-duration: 1ms !important;
		animation-delay: 0s !important;
		animation-iteration-count: 1 !important;
		scroll-behavior: auto !important;
	}
}

transitionおよびanimationの間隔(duration)を1msに強制することで実質的にアニメーションを無効化します。間隔を0msもしくはtransitionとanimationをnoneにするとJSのtransitionendイベントおよびanimationendイベントが発火せずに表示面で不具合を起こす可能性があるため、1msを指定するようにしてください。

ul 要素や ol 要素のリストマーカーは list-style-type:"" で非表示にする

ul要素やol要素のデフォルトのリストマーカーを非表示にする際、list-style:noneを指定するケースが殆どだと思いますが、SafariとVoiceOverの組み合わせではlist-style:noneが指定されているリスト要素を「リスト」として認識しないようになっています。

🙅‍♂ Not Recommended
:where(ul, ol) {
	list-style: none;
	padding: unset;
}

これはVoiceOverの不具合ではなく仕様であり、VoiceOverユーザーから寄せられた「リストが多すぎるためにリストの情報が繰り返し読み上げられるのが煩わしい」というフィードバックに起因しているものです。

例外としてnav要素内に含まれているlist-style:noneが指定されているリスト要素は「リスト」として認識されます。

とは言え、デフォルトのリストマーカーはスタイリングに制約が大きく、「リスト」として認識されて欲しいけどデザインの都合上list-style:noneを指定するケースが多いのが実情です。この問題の解決策としてlist-style:noneが指定されているリスト要素にrole="list"を指定する方法が提案されていますが、一般的に暗黙のロールを同一のロールで上書きするのは冗長であるため推奨される方法ではありません。

🙅‍♂ Not Recommended
<ul role="list">
	<li>...</li>
</ul>

そういった事情もあり、a11y css reset(新しいタブで開く)で取り入れられていてMDNにも記述(新しいタブで開く)のあるlist-style-typeに空のSVG要素を指定する方法でリストマーカーは非表示にしつつ「リスト」として認識してもらうようにしていましたが、最近Xにてlist-style-type:""の指定でも問題を解決できると教えていただいたので穴を突いている感は否めませんが現在はそちらを指定しています。

ポストを別枠で表示する

🙆‍♂ Recommended
:where(ul, ol) {
	list-style: none;
	list-style-type: '';
	padding: unset;
}

ただし、SafariとVoiceOverの組み合わせで読み上げられなくなった理由にもある通り実装者はリスト要素の乱用をしがちだということを念頭に入れておくべきでしょう。リスト要素はdisplay:flexやdisplay:gridでレイアウトを組むために存在しているわけではないので、それは本当に「リスト」としてマークアップすべきかどうかは慎重に検討したほうが良いと思います。

Removing list styles without affecting semantics. - Manuel Matuzovic

I'm a frontend developer in Graz, specialized in HTML, accessibility, and CSS layout and architecture.

matuzo.at

[id] 属性セレクタに固定ヘッダー分の scroll-margin-block を指定する

ページ内リンクで遷移した際、position:stickyやposition:fixedが指定されている固定ヘッダーが存在する場合、ページ内リンクで遷移した際に見出しやコンテンツと被ってしまいます。

[id]属性セレクタに固定ヘッダーの高さ分のscroll-margin-blockを指定することにより、その分多めにスクロールされるので見出しやコンテンツと被ってしまう問題を解決できます。ついでにフォーカス時の被さりも防止しておくと良さそうです。

🙆‍♂ Recommended
:root {
	--header-block-size: 3.75em;
	--scroll-margin: var(--header-block-size);
}

[id],
:focus {
	scroll-margin-block-start: var(--scroll-margin);
}

これだけだとヘッダーの高さに変動があった際にスクロール位置がズレてしまいかねないので、JSでヘッダーの高さを監視して動的にカスタムプロパティの値を変えるようにしておくと良いでしょう。

動的な監視にはResizeObserverを使用すると少ない記述量なのにパフォーマンスに優れていて、かつ論理プロパティ(blockSize)で出力してくれるためオススメです。

ポストを別枠で表示する

ResizeObserverで固定ヘッダーの高さを取得する
const observeHeaderBlockSize = new ResizeObserver((entries) => {
	const header = entries[0]

	if (header.borderBoxSize) {
		const { blockSize } = header.borderBoxSize[0]
		const roundedBlockSize = Math.round(blockSize)

		document.documentElement.style.setProperty('--header-block-size', `${roundedBlockSize}px`)
	}
})

document.addEventListener('DOMContentLoaded', () => {
	const header = document.querySelector('header')

	if (header) observeHeaderBlockSize.observe(header)
})

また、ページ内リンク繋がりでスムーススクロールを実現するためにhtml要素にscroll-behavior:smoothを指定する方法が広く普及していますが、僕はこの方法に賛成しておりません。詳細な理由は『スムーススクロールの実装例』にて詳しく解説しているため、そちらを参照してください。

画像の上にテキストを表示する際は文字色と反対の background-color も指定する

img要素や画像をbackground-imageで指定しているコンテンツの上にテキストを表示する際は文字色と反対のbackground-colorも指定するようにしましょう。

🙆‍♂ Recommended
.text {
	color: #fff;
}

/* `img`要素の上にテキストを表示するならラッパーに背景色を指定する */
.image-wrapper {
	aspect-ratio: 16 / 9;
	background-color: #000;
}

/* `background-image`を指定するなら同時に`background-color`も指定する */
.hero-header {
	background-image: url('hero-header.avif');
	background-color: #000;
}

通信障害や万が一のリンク切れで画像の読み込みに失敗しても背景色が設定されていればテキストを読めるようになります。

リンクの下線は位置の調整や色の微調整などを行う

リンクに下線を設ける場合、ディスレクシアの方はテキストと下線が被ると読みにくくなったり、線のバリエーションによっては煩雑になるという問題点が指摘されています。

参考になったポスト

ポストを別枠で表示する

text-decoration-lineを使用してリンクに下線を設けるのであればtext-underline-offsetやtext-decoration-colorを使用して位置の調整や色の微調整などを行うと良さそうです。

🙆‍♂ Recommended
:where(:any-link) {
	text-decoration-color: color-mix(in srgb, currentcolor, transparent 40%);
	text-underline-offset: 0.25em;
}

Designing dyslexia-friendly navigational components

Accessibility insights and atomic design patterns.

uxdesign.cc

スマートフォン横向き時のテキストの拡大を防ぐために text-size-adjust:100% を指定する

多くの場合リセットCSSに含まれている記述ですが、スマートフォンを縦向き・横向きに切り替えた際に文字サイズを自動調整する機能が働いて意図せないフォントサイズになる場合があります。

そのため、text-size-adjust:100%を指定して自動調整を無効にしておくことを推奨します。

🙆‍♂ Recommended
html {
	-webkit-text-size-adjust: 100%; /* Safariではまだベンダープレフィックスが必要 */
	text-size-adjust: 100%;
}

タブメニューやアコーディオンの非活性コンテンツは hidden=“until-found” 属性で非表示にする

タブメニューやアコーディオンの非活性コンテンツは多くの場合display:noneで非表示にされるケースが多いですが、hidden="until-found"属性を使用して非表示にするほうが次のようなメリットを享受できます。

  • ページ内検索で検出することが可能となり、ヒットした際は自動でhidden属性が取り除かれて表示することが可能になる。
  • JS無効環境のフォールバックが容易になる。
  • Chrome for Developers(新しいタブで開く)の文書によればhidden="until-found"が指定された要素内のコンテンツには検索エンジンのロボットもアクセスできるようになるようなので、SEOに良い影響を与える可能性がある。
🙅‍♂ Not Recommended
<section aria-labelledby="headingId">
	<h2 id="headingId">
		<button type="button" aria-expanded="false" aria-controls="panelId">見出し</button>
	</h2>
	<div id="panelId" style="display:none">コンテンツ</div>
</section>
🙆‍♂ Recommended
<section aria-labelledby="headingId">
	<h2 id="headingId">
		<!-- a要素で実装することを推奨 -->
		<a role="button" href="#panelId" aria-expanded="false" aria-controls="panelId">見出し</a>
	</h2>
	<div id="panelId" hidden="until-found">コンテンツ</div>
</section>

hidden="until-found"の詳細や注意点、詳しい使い方については『タブやアコーディオンの非表示コンテンツにはhidden=“until-found”を使うべし』にて詳細に解説しているのでこちらを参照してください。

非常に恐れ多いのですがICSの池田さんにお褒めいただきました。ありがとうございます🙏

ポストを別枠で表示する

また、hidden="until-found"を用いたタブメニューやアコーディオンの実装例については次の項目を参照してください。

アクセシビリティに配慮したタブメニューの実装例 – TAKLOG

アクセシビリティに配慮したタブメニューの実装メモです。他の技術記事との差別化要素として、タブパネルの非表示の状態管理にはhidden="until-found"を使用しています。

www.tak-dcxi.com

タブやアコーディオンの非表示コンテンツにはhidden="until-found"を使うべし – TAKLOG

タブやアコーディオンの非表示コンテンツにはdisplay:noneがよく用いられますが、hidden="until-found"属性を使ったほうがメリットがあります。この記事ではその具体例を説明します。

www.tak-dcxi.com

ブランド名やコピーライト等の誤翻訳されて欲しくないテキストには translate=“no” 属性を指定する

Google翻訳などの機械翻訳はブランド名やコピーライトなどを上手く翻訳できず、誤った表記で表示される可能性があります。

サイト名が「ヴィーテ」と訳された、Viteのドキュメントの「はじめる」のページ
Viteの英語版ドキュメントをGoogle翻訳したもの。Viteの読み方は「ヴィート」が正しいが機械翻訳は「ヴィーテ」と翻訳する。

日本語のブランド名に関してはたとえ誤訳されたとしても翻訳自体はされた方が良いケースもあり、一概にすべてのブランド名とは言えませんが、ロゴやコピーライト等にはtranslate="no"属性の指定を推奨します。

🙆‍♂ Recommended
<hgroup>
	<h1 translate="no">TAKLOG</h1>
	<p>フロントエンドエンジニアの私的メモブログ</p>
</hgroup>

<p class="copyright" translate="no">© 2024 TAK / Web Creator</p>

他にも『テキストを1文字ずつアニメーションさせる時の注意点と実装例』で触れたように、アニメーションの実装でdisplay:inline-blockを指定したspan要素で文字ごとに区切る際にもtranslate="no"属性の指定を推奨します。これを怠るとGoogle翻訳はspanごとに翻訳する故に支離滅裂な翻訳がされてしまい、更には「し」を”death”と翻訳するなど不穏な文章に化ける可能性もあります。

ただし、至極当たり前のお話ですが、なんでもかんでもtranslate="no"属性を指定するのは避けて本当に誤翻訳されることを望まない要素のみに指定するようにしましょう。

Google翻訳を防ぐtranslate属性はcopyrightには必須!

translate属性とは Webページを翻訳するかどうかをyesかnoで指定できる。 Google翻訳に翻訳させたくない場合はHTMLタグにtranslate="no"を指定すれば翻訳されない。 copyrightには … <p class="link-more"><a href="https:/…

iwb.jp

動的にコンテンツが差し込まれる場合は親要素に contain プロパティを指定することも検討する

containプロパティはDOMのレンダリングを開発者側で調整できるようにするパフォーマンス系のプロパティです。指定することでページが効率的にレンダリングされるようにユーザーエージェントに伝えることが可能となります。

🙆‍♂ Recommended
.wrapper {
	contain: content;
}

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

contain は CSS のプロパティで、ある要素とその内容が、できる限り多く、文書ツリーの他の部分から独立していることを示します。これによってブラウザーがレイアウト、スタイル、描画、寸法、およびその組み合わせの再計算を、ページ全体ではなく DOM の限られた領域に対して行うことで、性能上の明らか…

developer.mozilla.org

静的なコンテンツではそこまで効果を発揮しませんが、コンテンツを動的に差し替える場合はブラウザに再レンダリング部分を指定することができるためパフォーマンスの向上に大きく貢献する可能性があります。

例えば日経電子版では各要素にcontain:contentを付与したところPaint Timeが半分になったとのことです。

CSS Containment によるパフォーマンス改善 — HACK The Nikkei

CSS Containment の紹介と電子版での利用例について書きます

hack.nikkei.com

ただし、何でもかんでもcontainプロパティを指定すれば良いというわけではなく、次のデメリットには注意してください。

  • overflow:hiddenのようにコンテンツを境界の外に表示しなくなります。故に先述したフォーカスリングの消失が起こり得るので注意してください。
  • 新しいスタッキングコンテキストを生成するのでz-indexの管理には注意してください。
  • 子孫要素のposition:fixedが軒並み無効化されるので気をつけてください。body要素などに指定するのは避けたほうが良いでしょう。

containプロパティの値はプリミティブな値としてsize,layout,paintが存在し、size layout paintを複合したstrictとlayout paintを複合したcontentが存在します。contain:strictは扱いが難しいため、原則的にはcontain:contentで問題ないでしょう。

また、上記のデメリットをクリアできるのなら静的なコンテンツで使用するのもアリだと思います。例えば当ブログの記事一覧のサムネイルのように、ホバーしたら画像をscaleで拡大しつつabsoluteでテキストをオーバーレイするといった実装の場合、position:relativeとoverflow:clipの2つを指定するところをcontainプロパティ1つで済ませることができます。

🙆‍♂ Recommended
.thumbnail {
	aspect-ratio: var(--aspect-thumbnail);
	position: relative;
	overflow: clip;
	contain: strict;
}

フォームの入力欄のオートコンプリートを有効にする

オートコンプリートの利便性は皆さんがよく知っていることでしょうから説明するまでもないかもしれませんが、ブラウザに保存された補完機能を利用できるようになることは次のようなメリットがあります。

  • オートコンプリートはユーザーが以前に入力したデータを利用してフォームフィールドを自動的に補完します。ユーザーは何度も同じ情報を入力する手間が省け、入力速度が向上します。
  • 手動入力の際に起こりうるタイプミスやフォーマットエラーが減少します。オートコンプリートは正しい情報を提案するため、正確なデータ入力が促進されます。
  • 煩雑なフォーム入力が原因でユーザーが途中で離脱するのを防ぎます。オートコンプリートにより入力が簡単になるためフォームの送信完了率が上がります。
  • 支援技術を使用しているユーザーもオートコンプリート機能によりスムーズに情報を入力できます。

input要素にautocomplete属性を指定してオートコンプリートを有効にすることを推奨します。

🙆‍♂ Recommended
<input
	id="name"
	type="text"
	name="name"
	autocomplete="name"
	title="お名前の入力は必須です"
	required
	aria-required="true"
/>

主に使用される代表的な項目は次のとおりです。

値補完する内容
name氏名
family-name名字
given-name名前
tel電話番号
emailメールアドレス
usernameユーザー名orアカウント名
postal-code郵便番号
address-level1都道府県
address-level2市区町村
address-level3町域
address-level4番地など
organization企業・団体・組織名
cc-nameクレジットカード登録名
cc-numberカード番号
cc-expカードの有効期限

オートコンプリートを無効にするためにautocomplete="off"をするケースも考えられますが、現在は殆どのユーザーエージェントで無視されてしまうためオフに設定してもあまり意味がありません。

iframe を埋め込む際は参照元のリンクも用意しておく

これはブログを運営している方向けではありますが、XやCodePen、YouTubeなどの外部サービスをiframeで埋め込む際は参照元のリンクも用意すると良いでしょう。

Xの埋め込みの例
<figure>
	<figcaption>キャプション</figcaption>
	<div class="embed" data-theme="dark">
		<blockquote class="twitter-tweet" data-conversation="none">...</blockquote>
	</div>
	<noscript>このコンテンツはJavaScriptを有効にする必要があります。</noscript>
	<a href="{href}" target="_blank" rel="external">
		ポストを別枠で表示する
		<svg width="16" height="16" viewBox="0 0 24 24" role="img" aria-labelledby="{uuid}">...</svg>
		<span id="{uuid}" style="display:none">新しいウィンドウが開きます</span>
	</a>
</figure>

Safari等のリーダーモードで表示している場合にiframeのみだと表示されずに参照元を辿ることができませんが、参照元のリンクも用意しておくと辿ることが可能になります。

また、参照元にアクセスできなくなって埋め込みが無効になった場合に出典に対する動線を確保することもできます。

ツイートの埋め込みに元ソースへのリンクを設定するようにした

外部サービスを <iframe> で埋め込む際、 <a> 要素で元ソースへのリンクを張ることが重要です。ツイートの埋め込みは例外だと思っていたのですが、これも必要なケースがあることが分かりました。

blog.w0s.jp

スクロールバーのカスタムは強制カラーモードが行われていない時に限定する

『スクロールバーにまつわるエトセトラ』でも触れましたが、強制カラーモードが有効な場合にスクロールバーのスタイリングを行うとスクロールバー自体が表示されなくなるリスクがあります。

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’ に強制されます

(MDN『forced-colors(新しいタブで開く)』より引用)

そのため、forced-colorsメディア特性を使用して、強制カラーモードが有効ではない場合にのみスクロールバーのスタイリングを適応するようにしてください。

🙆‍♂ Recommended
@media not (forced-colors: active) {
	/* スクロールバーのCSS */
}

Don’t use custom CSS scrollbars

While a custom CSS scrollbar may seem flashy and fun, consider that it may present a significant, unnecessary barrier to access.

ericwbailey.website

また、先程の引用にもあるようにbox-shadowは強制カラーモード有効下ではnoneに強制される点は留意したほうが良いでしょう。キーボード操作時のフォーカスをより明確にするためにoulineを非表示にして代わりにbox-shadowでフォーカスリングを実装する方がいらっしゃいますが、その場合強制カラーモード有効下ではフォーカスリングが消失してしまいます。

また、疑似要素を使ったアイコンやUIに関しても消失してしまう可能性があるため注意してください。強制カラーモードの対応に関してはこちらの記事にて解説しています。

一定の画面幅未満ではviewportを固定する

4インチスマートフォンのシェア率は現在ではstatcounterのデータ(新しいタブで開く)に現れないほどに減少していますが、iPadのSlide OverおよびにSplit Viewの論理サイズは320pxであったり、ガジェット系YouTuberが挙って称賛しているGalaxy Foldなどの折りたたみスマートフォンの一部の画面幅は229px〜320px相当にであるため、現在でも320px以下の画面サイズで見られる可能性はあります。

ただし、375px付近でデザインが作られる現在、4インチ未満の対応は実装コストの割にリターンが小さいのは確かであるため、過去に投稿した記事『俺流レスポンシブコーディング(新しいタブで開く)』で紹介した内容ではありますが一定の画面幅未満ではJSでviewportを固定することを推奨します。

JSの実装例
const initializeViewport = () => {
  const debouncedResize = debounce(handleResize)
  window.addEventListener('resize', debouncedResize, false)
  debouncedResize()
}

const handleResize = () => {
  const minWidth = 360
  const value = window.outerWidth > minWidth ? 'width=device-width,initial-scale=1' : `width=${minWidth}`
  const viewport = document.querySelector('meta[name="viewport"]')
  if (viewport && viewport.getAttribute('content') !== value) {
    viewport.setAttribute('content', value)
  }
}

const debounce = <T extends any[], R>(callback: (...args: T) => R): ((...args: T) => void) => {
  let timeout: number | undefined

  return (...args: T): void => {
    if (timeout !== undefined) cancelAnimationFrame(timeout)
    timeout = requestAnimationFrame(() => callback.apply(this, args))
  }
}

document.addEventListener("DOMContentLoaded", () => {
  initializeViewport()
});

現時点で360pxの端末のシェア率が合計6%程度なので、原則的には360px未満はviewportを書き換える方針で問題ないと思います。過去に投稿した内容の変更点としてはdebounce関数を使ってリサイズイベントの最適化をしています。

Web Storageを使うときは必ず例外をキャッチする

HTML/CSSのテクニックではありませんが、設定でCookieを無効にしている状態でlocalStorageやsessionStorageにアクセスするとSecurityErrorが発生して処理が止まる可能性があります。

localStorageやsessionStorageを読み書きする際はtry...catchを用いて例外が起きても処理が止まらないようにしましょう。

🙆‍♂ Recommended
let safeLocalStorage: Storage | null = null

try {
  safeLocalStorage = localStorage
} catch (error) {
  // Web Storage が使えない場合のフォールバック処理
  return
}

if (!safeLocalStorage) return

// Web Storage が使える場合の処理

Web Storage API を使う際は Cookie 無効環境も考慮しよう

Cookie 無効で localStorage 等にアクセスすると SecurityError が発生する話。

blog.w0s.jp

参考リンク

ドキュメント

書籍

技術記事

投げ銭機能を追加しました。下の「Support Me」より投げ銭が可能です!