以前、jQueryで「もっと見る」ボタンを実装する方法を紹介しましたが、最近はjQueryを使わない案件も増えてきたので、バニラJS版を書き直しました。

ついでに、前回のjQuery版で気になっていた「読み込み時にコンテンツがパタンと閉じるCLS(Cumulative Layout Shift)」の問題も解消し、「閉じた時に要素の上部へスクロールで戻る」機能も追加しています。

この記事で実装する機能は以下の通りです。

  • jQuery不要: バニラJavaScriptのみで動作
  • CLSゼロ: CSS側で初期状態を作るため、JS実行前から最終レイアウトで描画
  • レスポンシブ対応: PCとスマホで「隠す高さ」を個別に設定可能
  • グラデーション装飾: 続きがあることを直感的に伝える「ぼかし」効果
  • スムーズなアニメーション: requestAnimationFrameでスライドダウン・アップ
  • 閉じた時に上部へスクロール: 展開前の位置に戻ることで迷子にならない

コピペでそのまま使えるコードを用意しましたので、ぜひ活用してください。

CodePen ソースコードサンプル

実装デモとソースコード

HTML・CSS・JSをそれぞれ見ていきます。jQuery版から構造を見直しているので、前回の記事を参考にされた方は置き換えて使ってください。

HTML

jQuery版との大きな違いは、高さの指定を data-* 属性ではなく CSSカスタムプロパティ(--height-pc / --height-sp)で渡している点です。こうすることで、CSSとJSの両方から同じ値を参照でき、ソースが1つに統一されます。

また、オーバーレイ要素とボタンテキストを最初からHTMLに書いています。これでJSの実行を待たずに初期表示が完成するため、CLSが発生しません。

<div class="viewExpand" style="--height-pc: 200px; --height-sp: 500px;">
  <div class="viewExpand__contents">
      <p>ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...ここにコンテンツが入ります...</p>
  </div>
  <div class="viewExpand__overlay"></div>
  <button class="viewExpand__button" type="button" aria-label="もっと見る" data-close-label="閉じる">もっと見る</button>
</div>

CSS

CSSの最大のポイントは、.viewExpand__contents最初から max-heightoverflow: hidden を指定している点です。これによってJS読み込み前から閉じた状態で描画されます。

.viewExpand{
  position: relative;
}
.viewExpand__contents{
  overflow: hidden;
  /* オーバーレイが被る分、下の余白を確保 */
  padding-bottom: var(--view-expand-overlay-height);
}
/* グラデーションのぼかし部分 */
.viewExpand__overlay{
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: var(--view-expand-overlay-height);
  /* 下に行くにつれて白くなるグラデーション */
  background: linear-gradient(180deg,rgba(255,255,255,0) 0%, rgba(255,255,255,0.8) 100%);
  pointer-events: none;
  z-index: 1;
}
.viewExpand__button{
  -webkit-appearance: none;
  border: 1px solid #000;
  border-radius: 5px;
  background-color: #fff;
  color: #000;
  /* 中央寄せ配置 */
  position: absolute;
  bottom: 10px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  justify-content: center;
  align-items: center;
  width: 200px;
  height: var(--view-expand-btn-height);
  font-size: 13px;
  z-index: 2;
  transition: .5s;
  cursor: pointer;
}
@media (hover: hover){
  .viewExpand__button:hover{
    opacity: 0.7;
  }
}
/* レスポンシブ設定 */
@media screen and (min-width: 768px){
  .viewExpand{
    --view-expand-btn-height: 60px;
    --view-expand-overlay-height: 100px;
  }
  .viewExpand__contents{
    max-height: var(--height-pc);
  }
}
@media screen and (max-width: 767px){
  .viewExpand{
    --view-expand-btn-height: 50px;
    --view-expand-overlay-height: 100px;
  }
  .viewExpand__contents{
    max-height: var(--height-sp);
  }
}

JavaScript(バニラJS)

JavaScriptでは、CSSカスタムプロパティから高さを読み取り、開閉のアニメーションを実行します。初期状態はCSS側で作られているので、JSでの collapse() 初期実行は不要です。

document.addEventListener('DOMContentLoaded', () => {
  const SP_BREAK = 767;

  document.querySelectorAll('.viewExpand').forEach((view) => {
    const cont = view.querySelector('.viewExpand__contents');
    const btn = view.querySelector('.viewExpand__button');
    let resizeTimer;

    function getLimit() {
      const prop = window.innerWidth < SP_BREAK ? '--height-sp' : '--height-pc';
      return parseInt(getComputedStyle(view).getPropertyValue(prop), 10);
    }

    function animateHeight(from, to, duration, onComplete) {
      const start = performance.now();
      cont.style.overflow = 'hidden';

      function step(now) {
        const progress = Math.min((now - start) / duration, 1);
        const eased = 0.5 - Math.cos(progress * Math.PI) / 2;
        cont.style.height = (from + (to - from) * eased) + 'px';
        if (progress < 1) {
          requestAnimationFrame(step);
        } else if (onComplete) {
          onComplete();
        }
      }
      requestAnimationFrame(step);
    }

    function collapse() {
      const limit = getLimit();
      // max-heightを一旦解除して現在の全高を測る('none'でCSSを上書き)
      cont.style.maxHeight = 'none';
      const curH = cont.scrollHeight;

      animateHeight(curH, limit, 400, () => {
        cont.style.height = '';
        cont.style.maxHeight = limit + 'px';
        btn.textContent = btn.getAttribute('aria-label');

        const overlay = view.querySelector('.viewExpand__overlay');
        if (overlay) overlay.style.display = '';
      });
    }

    function expand() {
      const limit = getLimit();
      const fullH = cont.scrollHeight;
      // CSSのmax-heightを上書きするため'none'を指定(''だとCSSが効いて動かない)
      cont.style.maxHeight = 'none';

      animateHeight(limit, fullH, 400, () => {
        cont.style.height = '';
        cont.style.overflow = '';
        // max-heightは'none'のまま維持(CSSの300px制限を超えた表示を保つため)
        btn.textContent = btn.dataset.closeLabel;

        const overlay = view.querySelector('.viewExpand__overlay');
        if (overlay) overlay.style.display = 'none';
      });
    }

    function handleClick() {
      if (view.classList.contains('is-expanded')) {
        view.classList.remove('is-expanded');
        collapse();
        view.scrollIntoView({ behavior: 'smooth', block: 'start' });
      } else {
        view.classList.add('is-expanded');
        expand();
      }
    }

    function handleResize() {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        if (!view.classList.contains('is-expanded')) {
          cont.style.maxHeight = getLimit() + 'px';
        }
      }, 200);
    }

    btn.addEventListener('click', handleClick);
    window.addEventListener('resize', handleResize);
  });
});

CLS(レイアウトシフト)を起こさない設計のポイント

jQuery版では $(function(){...}) の中で初期 collapse() を呼んでいたため、ページ読み込み直後はコンテンツが全高で表示 → JS実行後に閉じた状態にパタンと変わるという挙動になっていました。これがCLS(Cumulative Layout Shift)を発生させ、Core Web Vitalsのスコアを下げる原因になります。

これを防ぐには、JSに頼らずHTMLとCSSだけで初期状態を完成させるのがポイントです。今回の実装で対策したのは以下の3点です。

① 高さ制限をCSSで付ける

.viewExpand__contentsmax-heightoverflow: hidden を最初から指定しておくことで、JS実行前からコンテンツが閉じた状態で描画されます。

ここで必要なのがCSSカスタムプロパティの活用です。高さの値を data-* 属性で書いているとJSからしか読めませんが、インラインスタイルで --height-pc のように書いておけば、CSS側からも var(--height-pc) で参照できます。

<!-- 値はHTMLのstyle属性で渡す -->
<div class="viewExpand" style="--height-pc: 300px; --height-sp: 500px;">
/* CSSからはvar()で参照できる */
.viewExpand__contents{
  max-height: var(--height-sp);
  overflow: hidden;
}
@media screen and (min-width: 768px){
  .viewExpand__contents{
    max-height: var(--height-pc);
  }
}

JS側でも getComputedStyle().getPropertyValue() で同じ値を取得できるので、ソースが一箇所に集約されてメンテナンスしやすくなります。

② オーバーレイをHTMLに最初から書く

jQuery版ではJSでオーバーレイ要素を insertBefore していましたが、これもJS実行を待つためCLSの一因になります。HTMLに最初から <div class="viewExpand__overlay"></div> を書いておき、展開時はJSで display: none、閉じる時は display: '' で切り替えるだけにしました。

③ ボタンのラベルもHTMLに書く

jQuery版ではボタンの初期テキストをJSで text() 設定していましたが、これもHTMLに最初から もっと見る と書いておけばOKです。aria-labeldata-close-label はJSから参照する用に残しつつ、見た目のテキストはHTML側で確定させるという考え方です。

この3点を押さえるだけで、JSが遅延読み込みでもレイアウトが崩れず、JSエラー時もコンテンツが制限されたまま表示されるという堅牢な実装になります。


jQuery版からの主な変更点

jQueryからバニラJSに書き換える際の主なポイントを整理します。

ループ処理:$.each()forEach()

jQueryの $('.viewExpand').each() は、document.querySelectorAll('.viewExpand').forEach() に置き換えます。NodeList にも forEach が使えるので、Array.from() での変換は不要です。

値の受け渡し:data属性CSSカスタムプロパティ

jQuery版では data-height-pc を使っていましたが、今回はCSSカスタムプロパティ(--height-pc)に変更しました。CSSとJSの両方から同じ値を参照できるため、CLS対策のCSSを書きつつJSでもアニメーションの目標値として使える、という一石二鳥の構成になります。

JS側での取得は getComputedStyle(element).getPropertyValue('--height-pc') で行います。

アニメーション:.animate()requestAnimationFrame

jQueryの .animate({ height: ... }, 400, 'swing') に相当する処理は、バニラJSには存在しないため自前で実装する必要があります。今回は requestAnimationFrame を使い、jQueryの「swing」イージング(0.5 - Math.cos(progress * Math.PI) / 2を再現しました。これによってjQuery版とほぼ同じ滑らかな挙動になります。

function animateHeight(from, to, duration, onComplete) {
  const start = performance.now();
  cont.style.overflow = 'hidden';

  function step(now) {
    const progress = Math.min((now - start) / duration, 1);
    // swingイージング
    const eased = 0.5 - Math.cos(progress * Math.PI) / 2;
    cont.style.height = (from + (to - from) * eased) + 'px';
    if (progress < 1) {
      requestAnimationFrame(step);
    } else if (onComplete) {
      onComplete();
    }
  }
  requestAnimationFrame(step);
}

リサイズイベントにデバウンスを追加

jQuery版ではリサイズ時に毎回 collapse() が走っていましたが、ウィンドウリサイズは連続的に発火するため、デバウンス(200ms)を入れて処理を間引いています。またリサイズ時はアニメーション不要なので、max-height を直接セットする方式に変えました。


追加機能:閉じた時に要素の上部へスクロール

今回のもうひとつのポイントが、閉じた時に要素の上部までスクロールで戻る機能です。該当箇所は handleClick() の中の以下の1行だけです。

function handleClick() {
  if (view.classList.contains('is-expanded')) {
    view.classList.remove('is-expanded');
    collapse();
    // 閉じた時に要素の上部までスムーズスクロール
    view.scrollIntoView({ behavior: 'smooth', block: 'start' });
  } else {
    view.classList.add('is-expanded');
    expand();
  }
}

scrollIntoView() は、指定した要素が画面内に入るようにスクロールを動かすネイティブAPIです。behavior: 'smooth' でスムーズスクロール、block: 'start' で要素の上端に合わせる指定になります。

長文を展開してスクロールで読み進めた後、「閉じる」を押すと画面下にコンテンツが消えて違和感が出ることがあります。上部に戻ることで、ユーザーが「今どこを見ていたか」を見失わずに済みます

固定ヘッダーがある場合の対処

サイトに固定ヘッダーがある場合、scrollIntoView で上部に戻ると要素がヘッダーの裏に隠れてしまいます。そんな時はCSSの scroll-margin-top を使うことで、JSを修正せずにスクロール位置を調整できます。

.viewExpand{
  position: relative;
  scroll-margin-top: 80px; /* 固定ヘッダーの高さ分 */
}

scroll-margin-topscrollIntoView などのスクロール移動時に自動で考慮されるプロパティで、JS側で座標計算する必要がないのが便利です。

まとめ

jQueryを使わずに「もっと見る」ボタンを実装する方法と、CLS対策、閉じた時に要素の上部へスクロールで戻る機能を紹介しました。

バニラJSに置き換える上でのハードルは主にアニメーション部分ですが、requestAnimationFrame と swingイージングの組み合わせで十分滑らかな挙動が作れます。また、CLSを起こさないためには「初期状態はJSではなくCSSで作る」というのが基本原則で、今回のようにCSSカスタムプロパティを介してHTML・CSS・JS間で値を共有するとスッキリまとまります。

また、scrollIntoView + scroll-margin-top の組み合わせは、「モーダルを閉じた時」「アコーディオンを閉じた時」など様々な場面で応用できるので、覚えておくと便利です。