detailsとsummaryタグで作るアコーディオンUI - アニメーションのより良い実装方

158
72

アコーディオン型ユーザーインターフェイス(UI)はウェブページでよくみられる表現です。巷ではさまざまな方法でアコーディオンUIを作る方法が紹介されていますが、みなさんはどのような方法で実装していますか? 見た目だけでなくアクセシビリティ対策までしっかりとできているでしょうか?

<details>要素と<summary>要素は、アコーディオンUIを実装するのに最適です。過去にIE対策として<button>要素や<div>要素、<input>要素などでアコーディオンUIを作っていた方は、アクセシビリティ対策が簡単にできるので、<details>要素と<summary>要素の採用がオススメです。

この記事では、<details>要素と<summary>要素がアコーディオンUIに最適と言える理由と、HTMLのマークアップからCSSでのスタイリング、JavaScriptでのアニメーション制御まで順を追ってアコーディオンUIの作り方をご紹介します。

アコーディオンUIを作る際によくある課題

<details>要素と<summary>要素はHTML 5.1(リンク先は2016年当時の仕様書)で登場した比較的新しいタグです。IEサポート終了前の対策としてなのか、<button>要素や<div>要素、<input>要素のtype="checkbox"などに開閉の動作を追加した作例がアコーディオンUIの作り方として多く見られます。これらはアコーディオンのような開閉動作は作れるものの、以下のようにさまざまな面で課題があります。

コード面

HTMLの構造が複雑。本来の使用用途からずれてしまっているタグで作ると、ひと目見ただけでは何を表すのかが分かりにくいでしょう。

<input>要素で作った例

<input id="accordion" type="checkbox" class="open">
<label class="summary" for="accordion">概要</label>
<div class="content">
  コンテンツ詳細
</div>

実装工数の面

必要最低限のアコーディオンの開閉の動きを作る場合でもCSSを使った実装が必須です。

アクセシビリティ面・ユーザビリティ面

  • キーボード操作の対策を行わなければ、タブフォーカスを行えないことが多いです。エンターキーやスペースキーによるアコーディオンの開閉操作もJavaScriptなしではできません。
  • スクリーンリーダーの対策を行わなければ、開閉状態を読み上げてくれることはありません。
  • サイト内単語検索でアコーディオンの中にある単語が引っかからないことはありがちです。また単語が引っかかったとしてもアコーディオンが開かず、単語を見つけられないことがあります。

アコーディオンUIを作る際に<details>要素と<summary>要素を使うメリット

課題がわかったところで、今度はアコーディオンUIで<details>要素と<summary>要素を使うメリットを見てみましょう。先にあげた課題点をすべてカバーでき、アクセシビリティ面ではとくに優れていると言えます。

コード面

HTMLの構造がシンプル。タグ名から構造を理解できることがおわかりいただけるでしょう。

<details>
  <summary>概要</summary>
  コンテンツ詳細
</details>

実装工数の面

必要最低限のアコーディオンの開閉動作を作る場合にはCSSは不要で、HTMLだけで十分です。

アクセシビリティ面・ユーザビリティ面

特別なケアをしなくてもアクセシビリティ面で最適化されています。

  • JavaScriptでキーボードイベントを登録することなく、タブフォーカスとエンターキー・スペースキーでの開閉操作ができます。

  • スクリーンリーダーが開閉状態について適切に読み上げてくれます。たとえばmacOSのVoiceOverを使ってGoogle Chromeを読み上げさせてみましょう。アコーディオンが閉じている状態では「(概要文)下位項目が折りたたまれました、三角形の展開ボタン、グループ」、開いている状態では「(概要文)字間広く、三角形の展開ボタン、グループ」のように開閉状態を判断した内容が読まれます。

  • サイト内で単語検索を行うと、検索した単語の含まれるアコーディオンが開き、中身の単語に直接移動できます。

アクセシビリティに優れたアコーディオン

以上で、<details>要素と<summary>要素が最適と言える理由を説明しました。つづいて、STEP1〜3の手順でアコーディオンUIを作成していきましょう。

STEP 1: HTMLでマークアップ

概要文などを入れる<summary>要素を<details>要素で囲うと基本的な形ができます。つづいて、<summary>要素の下に、コンテンツ詳細文などの折りたたませておく部分をマークアップすればアコーディオンが完成です。以下の例では折りたたまれている部分にタグをつけていませんが、この部分にはさまざまなタグが使えます。

▼基本的なHTMLの形

<details>
  <summary>概要</summary>
  折りたたまれている部分です。
</details>

ブラウザで確認してみましょう。なんとたった2つのタグを使うだけでアコーディオンを開閉できました!

▼基本的なHTMLで作ったアコーディオンの動作

基本的なHTMLで作ったアコーディオン

STEP 2: CSSでスタイリング

次にCSSで見た目を変えていきます。以下のようなアイコンを持つ、アコーディオンUIを作っていきます。

CSSでスタイリングした例

まず、アイコンを変えるため表示されているデフォルトの三角形アイコンを消しましょう。初期値のdisplay: list-itemで三角形アイコンが表示されている状態なのでdisplay: block等に変更します。

summary {
  /* display: list-item;以外を指定してデフォルトの三角形アイコンを消します */
  display: block;
}

注意点として、Safariだけ-webkit-details-markerというCSSの疑似要素で三角形アイコンが表示されています。以下のように別途指定して疑似要素を消しましょう。

summary::-webkit-details-marker {
  /* Safariで表示されるデフォルトの三角形アイコンを消します */
  display: none;
}

つづいて、新しくアイコンを作っていきます。アイコンの作り方は、アイコン用タグを用意する方法とCSS疑似要素で作る2通りの方法が考えられます。

アイコン用タグを追加する場合は以下のように<span>要素等をHTMLに追加します。アイコン自体のCSSは長くなるのでサンプルCSSの該当箇所をご覧ください。

<details>
  <summary>
    概要<span class="icon"></span>
  </summary>
  折りたたまれている部分です。
</details>

CSS疑似要素でアイコンを作る場合は、アニメーションさせる際に注意が必要になるので後述の「Safariの注意点」をご覧ください。

さらに、<summary>要素のアイコンとテキストのレイアウトを整えていきましょう。今回はdisplay: flexを使いました。

サンプルCSSでは、iOS 15未満の<summary>要素でdisplay: flexが機能しないバグ対策として、.summary_innerに対してdisplay: flexを指定してあります。 対応させたい要件に合わせて以下のようなケアを行ったり、新たにタグを追加したくない場合はposition: absoluteでアイコン位置を指定するなど他の方法を検討すると良いでしょう。

<summary>要素に中身を囲むインナー要素を追加したサンプルHTML

<details>
  <summary>
    <span class="summary_inner">
      概要<span class="icon"></span>
    </span>
  </summary>
  折りたたまれている部分です。
</details>

▼アイコンとテキストのレイアウトを調整するサンプルCSS(一部抜粋)

.summary_inner{
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  align-items: center;
}

CSSでアイコンをアニメーションさせる

アイコンだけならJavaScript不要でアニメーションできます。以下のようにdetails[open]セレクターでアコーディオンが開いたときのスタイルを登録しアイコンをアニメーションさせることが可能です。

.icon {
  /* 省略 */
  
  transition: transform 0.4s;
}

/* アコーディオンが開いた時のスタイル */
details[open] .icon {
  transform: rotate(180deg);
}

Safariの注意点

2022年9月時点で、SafariのみbeforeafterといったCSS疑似要素をtransform: rotate()でトランジションさせようとすると動かなくなるバグがあります。このバグは@keyframesで作成したアニメーションを開閉時それぞれに呼び出すことで回避できます(サンプルCSSの該当箇所を参照)。ただし、閉じるときのアニメーションがリロード時に一度発火してしまうので、アイコン用タグを用意する方法をオススメします。

STEP 3: JavaScriptでアニメーションをつける

クリックまたはキーボード操作によって、アコーディオンがアニメーションしながら開閉するという動きを作っていきましょう。今回はブラウザー標準機能であるWeb Animations APIを使ってアコーディオンの中身をアニメーションさせます。 アニメーションライブラリ等はお好みのものを使うとよいでしょう。迷ったときは 『現場で使えるアニメーション系JSライブラリまとめ』をご覧ください。

▼今回作る作例

JavaScript操作を加えた作例

JavaScript実装にあたってHTMLとCSSを少し変えます。まずHTMLにJavaScript操作用のクラスを追加し、折りたたまれている部分(<summary>要素の下)は二重の<div>要素で囲うようにしました。

▼中身をアニメーションさせるために変更したHTML

<details class="js-details">
  <summary class="js-summary">概要<span class="icon"></span></summary>
  <div class="content js-content">
    <div class="content_inner">
      折りたたまれている部分です。
    </div>
  </div>
</details>

次に、外側の<div>要素であるcontentクラスにCSSでoverflow: hiddenを指定します。そして内側の<div>要素であるcontent_innerクラスに対して、paddingを指定します。<details>タグ直下であるcontentクラスには上下のpaddingを指定しないことがポイントです。上下paddingを指定するとアニメーションするときにカクついてしまうためです。

▼中身をアニメーションさせるために変更したCSS(一部抜粋)

/* --------アコーディオンの中身のスタイル-------- */
.content {
  overflow: hidden;
  
  /* details直下のタグにpaddingを設定すると挙動がおかしくなるので、ここには指定しない */
}

.content_inner {
  padding: 24px 48px;
}

アニメーションの登録

HTMLとCSSの準備ができたところで、JavaScriptの実装に入ります。まず、中身のjs-contentを表示・非表示させるアニメーションを登録します。 今回は高さと透明度を変えるシンプルなアニメーションを用意しました。

/**
 * アニメーションの時間とイージング
 */
const animTiming = {
    duration: 400,
    easing: "ease-out"
};

/**
 * アコーディオンを閉じるときのキーフレーム
 */
const closingAnimKeyframes = (content) => [
  {
    height: content.offsetHeight + 'px', // height: "auto"だとうまく計算されないため要素の高さを指定する
    opacity: 1,
  }, {
    height: 0,
    opacity: 0,
  }
];

/**
 * アコーディオンを開くときのキーフレーム
 */
const openingAnimKeyframes = (content) => [
  {
    height: 0,
    opacity: 0,
  }, {
    height: content.offsetHeight + 'px',
    opacity: 1,
  }
];

クリックイベントの登録

次に、<summary>要素に対してクリックイベントを登録します。<details>要素はopen属性の有無(論理属性)によってアコーディオンの中身を表示・非表示と切り替えています。<details>要素のopenプロパティ(論理値)で開閉状態の判定が可能なので、details.openで判定して、先ほど登録したアニメーションを実行します。

ところが、<details>要素からopen属性が取り除かれると中身は一瞬で非表示になってしまいます。これではいくらアニメーションさせても見えません(display: noneのようなイメージ)。そこで、アニメーションを表示させるため<summary>要素のクリックイベントにpreventDefault()メソッドを追加してデフォルトの挙動を無効化します。

また、デフォルトの挙動を無効化すると当然アコーディオンの開閉操作ができなくなってしまうので、手動でopen属性の切り替えを行います。アコーディオンが開くときはクリック時にopen属性を付与し、閉じるときはアニメーション完了後にopen属性を取り除きます。

const details = document.querySelector(".js-details");
const summary = document.querySelector(".js-summary");

summary.addEventListener("click", (event) => {
  // デフォルトの挙動を無効化
  event.preventDefault();

  // detailsのopen属性を判定
  if (details.open) {
    // アコーディオンを閉じるときの処理
    // ...略

    // アニメーションを実行
    const closingAnim = content.animate(closingAnimKeyframes(content), animTiming);
    
    closingAnim.onfinish = () => {
      // アニメーションの完了後にopen属性を取り除く
      details.removeAttribute("open");
    };
  } else {
    // アコーディオンを開くときの処理
    // open属性を付与
    details.setAttribute("open", "true");
    
    // アニメーションを実行
    const openingAnim = content.animate(openingAnimKeyframes(content), animTiming);
    
    // ...略
  }
});

お気づきの方もいるかもしれませんが、開くときには表示状態になってから中身がアニメーションするので、普通なら手動でopen属性をつける必要はないはずです。しかし、Safariでアニメーションされないバグがあるため、開くとき・閉じるときどちらに対しても、手動でopen属性を切り替えるようにしてあります。

▼アニメーションの実行・open属性の切り替えタイミングは異なる

アニメーションの実行とopen属性の切り替えタイミングを整理したタイムライン

詳しい解説は省略しますが、アイコンのアニメーションを行うタイミングを管理するため、is-openedクラスをサンプルコードには追加しています(CSS該当箇所JavaScript該当箇所)。どのタイミングでアイコンが動作するのかぜひ確認してみてください。

連打対策

これで中身がアニメーションして開閉するようになりました。しかしこのままでは不完全です。連打するとアニメーション実行中に次のアニメーションが始まってしまい、アニメーションの挙動が不安定になります。

そこでアニメーション中に連打しても開閉状態が切り替わらないように対策します。今回は連打防止用のdata-anim-statusというカスタムデータ属性を用意し、値runningでアニメーション状態を管理する処理を追加しました。アニメーション中だけ値を入れておき、値が入っている間はクリックしてもリターンするようにします。JavaScriptではdataset.animStatusで、用意したカスタムデータ属性への参照が行えます。

▼連打防止用の処理を追加したJavaScript(一部抜粋)

summary.addEventListener("click", (event) => {
  // 連打防止用。アニメーション中だったらクリックイベントを受け付けないでリターンする
  if (details.dataset.animStatus === "running") {
    return;
  }
  
  // detailsのopen属性を判定
  if (details.open) {
    // ...略
    // アニメーション実行中用の値を付与
    details.dataset.animStatus = "running";
    
    closingAnim.onfinish = () => {
      // アニメーションの完了後に値を取り除く
      details.dataset.animStatus = "";
    };
  } else {
    // ...略
    // アニメーション実行中用の値を付与
    details.dataset.animStatus = "running";

    openingAnim.onfinish = () => {
      // アニメーションの完了後に値を取り除く
      details.dataset.animStatus = "";
    };
  }
});

注意点

<details>要素にクリックイベントを追加すると、開いた中身の部分もクリックできてしまいます。クリックイベントは<summary>要素に対して追加するようにしましょう。

おまけ:連打対応バージョン

アコーディオンの開閉アニメーション途中でもクリックできる、連打対応バージョンも用意しました。連打してみると挙動の違いがわかるでしょう。詳しくはデモとサンプルコードをご覧ください。作成にはJavaScriptアニメーションライブラリGSAPジーサップを使っています。

連打対応アコーディオン

まとめ

アコーディオン型UIを<details>要素と<summary>要素で作ると得られるメリットから、詳しい作り方までを紹介しました。 実装するデザインに応じて、以下のように段階を踏んで手を加えてくとよいでしょう。

  • とりあえずアコーディオンUIをお手軽に作りたい→HTMLだけで
  • アコーディオンUIの見た目にこだわって作りたい→HTML・CSSで
  • アコーディオンUIの見た目も動きもこだわって作りたい→HTML・CSS・JavaScriptで

アコーディオンUIを作る際のさまざまな課題ケアには、<details>要素と<summary>要素が最適です。ぜひ使っていきましょう。

参照記事

※この記事が公開されたのは2年前ですが、1年前の2023年5月に内容をメンテナンスしています。

澤田 悠

フロントエンドエンジニア。大学はデザイン専攻だったもののコードから作りたくてICSへ。趣味は、お絵描き・CG・美術館巡りです。

この担当の記事一覧