メインコンテンツまでスキップ

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

Development

タブやアコーディオンの非表示コンテンツにはdisplay:noneがよく用いられますが、hidden="until-found"を利用するほうがメリットがあります。

hidden=“until-found”で非表示にしたコンテンツはページ内検索でアクセスできる

until-foundhidden属性に新たに追加された属性値です。

参考:hidden - HTML: ハイパーテキストマークアップ言語 | MDN

従来のhidden属性とは違い、until-found"属性値を指定した場合はブラウザのページ内検索やページ内リンクでそのコンテンツが検出された場合、自動でhidden属性が取り除かれて表示することができます。

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

従来のdisplay:noneを使用した非表示ではコンテンツ内にページ内検索でマッチすべきワードがあったとしても検出できませんでしたが、hidden="until-found"を使えばページ内検索でヒットさせることが可能となります。

hidden="until-found"は2024年4月現在ではGoogle ChromeとMicrosoft Edgeのみの対応となっております。SafariやFirefoxではサポートされていませんが、従来のhidden属性として扱われるため、display:noneする実装と動作に変わりはありません。そのため全モダンブラウザの対応を待たずとも今から導入して差し支えないでしょう。

参考:HTML attribute: hidden: until-found value | Can I use... Support tables for HTML5, CSS3, etc

当ブログの目次のアコーディオンやフッターのカテゴリタブはこのhidden="until-found"を使って実装されていますので、Chrome系ブラウザ限定にはなりますが是非ページ内検索を使ってヒットするか試してみてください。

トリガー部分はa要素で実装するのがオススメ

detailsを使わないアコーディオンのボタンや、タブコンテンツのタブ部分を多くの方がbutton要素で実装されているかと思いますが、hidden="until-found"を使用するのならhref属性と非表示コンテンツのidを紐づけてrole属性の値を適切なものに変更したa要素で実装するのをオススメします。

アコーディオンを実装する場合
<a role="button" href="#panel" aria-expanded="true" aria-controls="panel">ボタン</a>
タブを実装する場合
<a role="tab" href="#tabpanel0" id="tab0" aria-selected="true" aria-controls="tabpanel0">タブ</a>

前項で触れましたが、ページ内リンクでそのコンテンツが検出された場合は自動でhidden属性が取り除かれて表示されるというのが理由です。とは言え、JSでデフォルトの動作を無効化して制御するから関係ないじゃんと思われる方もいるとは思います。

しかし、制御しているJSが何らかの事情で動作しなくなったり、そもそもJSが動かない環境でページを閲覧している場合、button要素ではそのコンテンツを開くことができません。一方a要素を利用すれば前述した仕様によりJS無効環境でもコンテンツを開くことが可能になります。

JS無効環境での表示例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

しかし、これだけではhidden="until-found"をサポートしていないSafariやFirefoxではJS無効環境下でコンテンツを開くことができません。そこで、サポートされていない場合は従来のhidden属性として扱われる仕様を利用し、:target擬似クラスを使用することで1行のCSS宣言でJS無効環境のフォールバックが可能となります。(トリガーのデフォルトの動作をpreventDefault()で抑止していることが前提です)

.panel:target {
display: revert;
}
/* 右クリックorタップ長押しで遷移した時にパネルが開いたままなのを嫌うなら */
@media (scripting: none) {
.panel:target {
display: revert;
}
}

原則的にpreventDefault()を行っているのなら:target擬似クラスで指定している宣言は腐るため、トリガーを押下して:target擬似クラスが有効になる=JSが動いていないということになります。ただし、トリガーを右クリックorタップ長押しで別タブで開いた際は:target擬似クラスが有効になりhidden="until-found"が非対応の環境で該当箇所が開きっぱなしになってしまうため、それを嫌う場合はscriptingメディア特性でJSを無効化した場合のみに絞るとよいでしょう。

結果としてJS無効環境を考えるのならばhidden="until-found"のサポート関係なく、アコーディオンのボタンやタブコンテンツのタブ部分はa要素で実装するのが良いでしょう。

SPAのようにJSの利用が前提となっているWebアプリケーションであれば別ですが、一般的なWebサイトであれば実装コストとの兼ね合いにはなりますがJSが無効になった場合でもなるべく多くの情報を得られるように対応しておきたいところです。

また、a要素をそのまま利用すると支援技術は「リンク」と読み上げるので、ボタンであればrole="button"タブであればrole="tab"を指定することも忘れずに。

リセットCSSのhidden属性に対するdisplay:noneには注意

古いリセットCSSやnormalize.cssを利用している場合、次のような指定が含まれているとhidden="until-found"を指定してもページ内検索やページ内リンクでコンテンツを開くことができなくなります。

🙅‍♂ Not Recommended
[hidden] {
display: none !important;
}

もしこのような指定がされている場合は次のCSSに書き換えるか、acab/reset.cssのようなhidden="until-found"に対応した指定がされているリセットCSSを利用すると良さそうです。

🙆‍♂ Recommended
[hidden]:not([hidden='until-found']) {
display: none !important;
}

hidden=“until-found”のデフォルトのUAスタイルシートはcontent-visibility:hiddenなので注意

従来のhidden属性のデフォルトのUAスタイルシートはdisplay:noneですが、hidden="until-found"の場合はcontent-visibility:hiddenで非表示が行われます。

content-visibility:hiddenは内容物に対してはdisplay:noneに近い動きをしますが、指定している要素そのものにはmargin, border, padding, backgroundがレンダリングされます。

参考:content-visibility - CSS: カスケーディングスタイルシート | MDN

現状ではSafari、Firefoxともにcontent-visibilityをサポートしていないこともあり、hidden="until-found"を指定している要素にレンダリングされるスタイルをあてるとブラウザ間で非表示の際のスタイリングに差異が生じてしまいます。

なるべくならhidden="until-found"を指定している要素にそのようなスタイルはあてないほうが良いでしょう。

hidden=“until-found”を利用したアコーディオンの実装例

最後にhidden="until-found"を利用したアコーディオンの実装例を紹介します。当ブログの目次で使われているものと同じものです。

アコーディオンの実装例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

こちらのJSの実装はGitHub Gistに纏めています。コードにはTypeScriptを使用していますので、TypeScriptを利用していないWeb制作現場で使用する場合はChatGPTなどに依頼してJSファイルに変換してください。

initializeAccordion.ts

アコーディオンの実装には基本的にdetailsを用いると思いますが、以下のHTML構造のようにランドマークの下にトリガーとなる見出しと開閉コンテンツといった構成の場合はdetailsを使わずにhidden="until-found"aria-expanded属性などで実装することを推奨します。

detailsを使わないマークアップ例
<section aria-labelledby="headingId">
<h2 id="headingId">
<a role="button" href="#panelId" aria-expanded="false" aria-controls="panelId">見出し</a>
</h2>
<div id="panelId" hidden="until-found">コンテンツ</div>
</section>

理由としてはdetailsだとsummary要素の中に見出し要素を含むのはHTMLの仕様違反ではないものの、見出しのroleが消失するアクセシビリティの問題が孕んでいるとのことなのでこちらの構成にしたほうが良さそうという判断です。

role消失の件はMarkuplint開発者の平尾さんから以前教わりました。ありがとうございます。

動作は後に投稿した記事「details要素のname属性を使用した排他的なアコーディオンの実装例」の流用となりますので、アニメーションの動作および各オプション(アニメーションの設定や印刷時に全展開するオプションなど)についてはそちらの記事を参照してください。

details要素を用いるアコーディオンと違う点として、open属性の切り替えを行う代わりにhidden属性とaria-expanded属性の切り替えを行うようにします。

const isOpened = (button: HTMLAnchorElement): boolean => {
return button.getAttribute('aria-expanded') === 'true'
}
let isAnimating: boolean = false
const toggleAccordion = (
button: HTMLAnchorElement,
panel: HTMLElement,
options: AccordionOptions,
show: boolean,
): void => {
if (isOpened(button) === show) return
isAnimating = true
if (show) panel.removeAttribute('hidden')
button.setAttribute('aria-expanded', String(show))
panel.style.overflow = 'clip'
const { blockSize } = window.getComputedStyle(panel)
const keyframes = show
? [{ maxBlockSize: '0' }, { maxBlockSize: blockSize }]
: [{ maxBlockSize: blockSize }, { maxBlockSize: '0' }]
const isPrefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const animationOptions: AccordionOptions = {
duration: isPrefersReduced ? 0 : Math.max(0, options.duration || 0),
easing: options.easing,
}
const onAnimationEnd = () => {
requestAnimationFrame(() => {
panel.style.overflow = ''
if (!show) panel.setAttribute('hidden', 'until-found')
isAnimating = false
})
}
requestAnimationFrame(() => {
const animation = panel.animate(keyframes, animationOptions)
animation.addEventListener('finish', onAnimationEnd)
})
}

details要素のname属性のように排他的な動作を希望する場合は、初期化時に参照するアコーディオンの親要素にdata-name属性を指定してください。

data-name属性でグルーピングをする
<section aria-labelledby="headingId" data-name="groupA">
<h2 id="headingId">
<a role="button" href="#panelId">見出し</a>
</h2>
<div id="panelId" hidden="until-found">コンテンツ</div>
</section>

任意のアコーディオン要素を開いて表示しておきたい場合はhidden="until-found"を取り除いておいてください。

data-name属性でグルーピングをする
<section aria-labelledby="heading1Id" data-name="groupA">
<h2 id="heading1Id">
<a role="button" href="#panel1Id">見出し</a>
</h2>
<!-- 最初に開いておきたい要素のhidden="until-found"は取り除く -->
<div id="panel1Id">コンテンツ</div>
</section>
<section aria-labelledby="heading2Id" data-name="groupA">
<h2 id="heading2Id">
<a role="button" href="#panel2Id">見出し</a>
</h2>
<div id="panel2Id" hidden="until-found">コンテンツ</div>
</section>

JSで操作することが前提のaria-expanded属性とそれに関連するaria-controls属性は初期化時にJSで付与するようにします。

const initializeAccordion = (element: HTMLElement, options: AccordionOptions = {}): void => {
// ...
setAttribute(button, panel)
// ...
}
const setAttribute = (button: HTMLAnchorElement, panel: HTMLElement): void => {
button.setAttribute('aria-expanded', String(!panel.hasAttribute('hidden')))
const panelId = panel.getAttribute('id')
if (panelId) button.setAttribute('aria-controls', panelId)
}

加えて、button要素やsummary要素とは違ってa要素はスペースキーでの操作ができないためkeydownイベントを追加してそれらに合わせます。

const handleClick = (
event: MouseEvent | KeyboardEvent,
element: HTMLElement,
button: HTMLAnchorElement,
panel: HTMLElement,
options: AccordionOptions,
): void => {
event.preventDefault()
if (isAnimating) return
toggleAccordion(button, panel, options, !isOpened(button))
if (isOpened(button)) hideOtherAccordion(element, options)
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === ' ') {
handleClick(event)
}
}

最後に、beforematchイベントリスナーを使用してページ内検索で開いた時にaria-expandedの値を変えるように変更し、data-name属性でグルーピングがされている場合には既に展開されている要素を閉じるようにします。ちなみにページ内検索時のアニメーションは邪魔なので無効にします。

const handleBeforeMatch = (
element: HTMLElement,
button: HTMLAnchorElement,
panel: HTMLElement,
options: AccordionOptions,
): void => {
button.setAttribute('aria-expanded', 'true')
hideOtherAccordion(element, options, false)
}

detailsの実装でも同様ですが、アニメーションでheightの値を操作する際はCSS側でheight:0を指定するのは避けて、animationを使用しているのならfill-modeにforwardsbothを指定しないでください。また、transitionを使用している場合は完了したらheightの値を初期値(auto)に戻すのを忘れないでください。閉じている時にheight:0が指定されているとページ内検索やJS無効環境下でのクリックで要素を開くことができなくなります。

また、閉じた後にdisplay:noneをうっかり指定しないように注意しましょう。

Share

本文上部へ戻る