T-CREATOR

htmx でページネーション最適化:履歴操作・スクロール保持・a11y 対応まで

htmx でページネーション最適化:履歴操作・スクロール保持・a11y 対応まで

Web アプリケーションにおいて、ページネーションは大量のデータを扱う際に欠かせない機能です。htmx を使えば JavaScript を最小限に抑えつつ、ブラウザの履歴管理やスクロール位置の保持、アクセシビリティ対応まで実現できます。

この記事では、htmx を活用したページネーション実装の最適化手法を、実践的なコード例とともに解説していきます。単なる動作だけでなく、ユーザー体験を向上させる細かな配慮まで含めた実装方法をご紹介しますね。

背景

ページネーションの重要性と課題

現代の Web アプリケーションでは、数百件から数万件のデータを一覧表示するケースが増えています。すべてのデータを一度に読み込むと、ページの読み込み時間が長くなり、ユーザー体験が損なわれてしまいます。

そこで登場するのがページネーションです。データを適切な単位(10 件、20 件など)に分割して表示することで、初期表示を高速化し、ユーザーが必要な情報に素早くアクセスできるようになります。

従来の実装アプローチ

従来のページネーション実装には、主に 2 つのアプローチがありました。

フルページリロード方式では、ページ番号をクリックするたびにページ全体が再読み込みされます。この方式はシンプルですが、ヘッダーやサイドバーなど変更不要な部分まで再描画されるため、ユーザー体験が損なわれます。

SPA(Single Page Application)方式では、React や Vue.js などのフレームワークを使い、必要な部分だけを動的に更新します。ただし、この方式では大量の JavaScript コードが必要になり、開発・保守コストが高くなってしまいます。

htmx を使ったページネーション実装の全体像を、以下の図で確認しましょう。

mermaidflowchart TB
  user["ユーザー"] -->|"ページ番号<br/>クリック"| htmx["htmx 属性付き<br/>リンク"]
  htmx -->|"AJAX リクエスト<br/>(hx-get)"| server["サーバー<br/>(API エンドポイント)"]
  server -->|"HTML フラグメント<br/>返却"| htmx
  htmx -->|"部分更新<br/>(hx-target)"| dom["DOM の<br/>特定エリア"]
  htmx -->|"履歴追加<br/>(hx-push-url)"| history["ブラウザ履歴"]
  htmx -->|"スクロール制御<br/>(hx-swap)"| scroll["スクロール<br/>位置管理"]
  dom --> user

  style htmx fill:#e1f5ff
  style server fill:#fff4e1
  style dom fill:#e8f5e9

この図から分かるように、htmx は単一の HTML 要素に属性を追加するだけで、AJAX リクエスト、DOM 更新、履歴管理、スクロール制御を統合的に処理できます。

htmx による第三のアプローチ

htmx は、HTML 属性だけで AJAX リクエストや部分的な DOM 更新を実現できるライブラリです。従来の両アプローチの良いところを組み合わせた「第三のアプローチ」として注目されています。

フルページリロードのシンプルさを保ちつつ、SPA のような滑らかなユーザー体験を提供できるのが htmx の魅力です。さらに、ブラウザの標準機能(履歴 API、スクロール API)との親和性が高く、アクセシビリティ対応も自然に実現できますよ。

課題

ページネーション実装における 3 つの技術的課題

htmx を使ったページネーション実装には、解決すべき 3 つの主要な課題があります。これらを適切に処理しないと、ユーザー体験が大きく損なわれてしまいます。

以下の図は、これらの課題がどのように関連し合っているかを示しています。

mermaidflowchart TD
  pagination["ページネーション<br/>実装"]

  pagination --> history["課題 1:<br/>履歴操作の管理"]
  pagination --> scroll["課題 2:<br/>スクロール位置<br/>の保持"]
  pagination --> a11y["課題 3:<br/>アクセシビリティ<br/>対応"]

  history --> h1["ブラウザの<br/>戻る/進むボタン"]
  history --> h2["URL とページ内容<br/>の同期"]
  history --> h3["直接 URL アクセス<br/>への対応"]

  scroll --> s1["ページ遷移後の<br/>スクロール位置"]
  scroll --> s2["無限スクロール<br/>との競合"]
  scroll --> s3["戻る操作時の<br/>位置復元"]

  a11y --> a1["スクリーンリーダー<br/>への通知"]
  a11y --> a2["キーボード<br/>操作対応"]
  a11y --> a3["ARIA 属性<br/>の適切な設定"]

  style pagination fill:#ffebee
  style history fill:#fff3e0
  style scroll fill:#e3f2fd
  style a11y fill:#f3e5f5

それぞれの課題について、具体的な問題点と対応方法を見ていきましょう。

課題 1:履歴操作の管理

ページネーションで別のページに移動した後、ブラウザの「戻る」ボタンを押したとき、適切に前のページに戻れるでしょうか。この問題は、ユーザー体験に直結する重要な課題です。

htmx でページ内容を部分的に更新する場合、デフォルトではブラウザの履歴に記録されません。そのため、ユーザーが「戻る」ボタンを押しても、ページネーションの状態が元に戻らず、サイト全体から離脱してしまう可能性があります。

また、特定のページ(例:3 ページ目)の URL をブックマークして後からアクセスした場合、正しくそのページが表示される必要があります。URL とページ内容の同期は、Web の基本原則として非常に重要です。

課題 2:スクロール位置の保持

ページを切り替えた際、スクロール位置がどこに移動すべきかという問題があります。ユーザーが意図しない位置にスクロールされると、操作性が著しく低下してしまいます。

例えば、ページ下部の「次へ」ボタンをクリックして新しいページを読み込んだとき、画面がページトップに戻ってしまうと、ユーザーは再びスクロールし直す必要があります。これでは、快適な閲覧体験とは言えませんね。

逆に、ページ番号をクリックして特定のページにジャンプした場合は、リストの先頭(またはページネーションコンポーネント)にスクロールする方が自然です。状況に応じた適切なスクロール制御が求められます。

課題 3:アクセシビリティ対応

視覚的には問題なく動作していても、スクリーンリーダーを使用するユーザーや、キーボードのみで操作するユーザーにとって使いやすいでしょうか。この点を見落とすと、多くのユーザーを排除してしまいます。

AJAX でコンテンツが更新されたとき、スクリーンリーダーにその変更が適切に通知されないと、ユーザーは何が起きたのか理解できません。また、ページ番号のリンクやボタンに適切な ARIA 属性が設定されていないと、現在どのページにいるのかが分かりません。

さらに、キーボードのみで操作する場合、Tab キーでフォーカスが適切に移動し、Enter キーやスペースキーで操作できる必要があります。マウス操作だけを前提とした実装では、アクセシビリティの観点で不十分なのです。

解決策

htmx 属性による統合的アプローチ

これら 3 つの課題に対して、htmx は HTML 属性だけで統合的に解決できる仕組みを提供しています。複雑な JavaScript コードを書かずに、宣言的な記述だけで実装できるのが htmx の強みです。

以下の表に、各課題と対応する htmx 属性の関係をまとめました。

#課題主な htmx 属性役割
1履歴操作の管理hx-push-urlURL を履歴に追加し、ブラウザの戻る/進むボタンに対応
2スクロール位置の保持hx-swapコンテンツ更新時のスクロール動作を制御
3アクセシビリティ対応hx-target, role, aria-*DOM 更新の範囲と支援技術への情報提供

それぞれの属性を組み合わせることで、最小限のコードで最大限のユーザー体験を実現できます。

解決策 1:履歴操作の実装

ブラウザの履歴管理を適切に行うために、hx-push-url 属性を使用します。この属性を設定すると、htmx が自動的にブラウザの履歴 API を呼び出し、URL を更新してくれます。

さらに、サーバー側でも URL パラメータに基づいて適切なページを返す実装が必要です。これにより、直接 URL にアクセスした場合でも正しいページが表示されます。

以下の図は、履歴操作がどのように機能するかを示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Browser as ブラウザ
  participant HTMX as htmx
  participant Server as サーバー

  User->>Browser: ページ 2 をクリック
  Browser->>HTMX: イベント発火
  HTMX->>Server: GET /items?page=2
  Server->>HTMX: HTML フラグメント返却
  HTMX->>Browser: DOM 更新
  HTMX->>Browser: history.pushState()<br/>(URL を /items?page=2 に更新)
  Browser->>User: 表示更新

  Note over User,Server: ユーザーが戻るボタンをクリック

  User->>Browser: 戻るボタン
  Browser->>HTMX: popstate イベント
  HTMX->>Server: GET /items?page=1
  Server->>HTMX: HTML フラグメント返却
  HTMX->>Browser: DOM 更新
  Browser->>User: ページ 1 を表示

この仕組みにより、ブラウザの戻る/進むボタンが期待通りに動作し、URL の共有やブックマークも正しく機能します。

解決策 2:スクロール位置の制御

スクロール位置の制御には、hx-swap 属性の修飾子を活用します。htmx は複数のスクロール制御オプションを提供しており、状況に応じて使い分けることができます。

主要なスクロール制御オプションを以下の表にまとめました。

#修飾子動作使用場面
1show:top更新された要素を画面上部に表示ページ番号クリック時
2show:bottom更新された要素を画面下部に表示前のページに戻る時
3show:noneスクロール位置を変更しない自動更新や背景更新
4scroll:topターゲット要素の上部にスクロールリスト全体の表示
5scroll:bottomターゲット要素の下部にスクロール無限スクロール風の実装

これらのオプションを適切に組み合わせることで、ユーザーの意図に沿った自然なスクロール動作を実現できます。

解決策 3:アクセシビリティの確保

アクセシビリティ対応には、ARIA 属性と htmx の連携が重要です。適切な ARIA 属性を設定することで、支援技術を使用するユーザーにも情報が正しく伝わります。

特に重要な ARIA 属性として、aria-livearia-currentaria-label があります。aria-live を設定した領域が更新されると、スクリーンリーダーが自動的にその変更を読み上げてくれます。

また、ページネーションコンポーネント全体を nav 要素でマークアップし、role="navigation"aria-label を設定することで、ランドマークとして認識されやすくなります。現在のページには aria-current="page" を設定し、無効なリンクには aria-disabled="true" を付与します。

キーボード操作への対応として、すべての操作可能な要素がフォーカス可能であり、Enter キーで操作できることを確認しましょう。htmx は標準的な <a> タグや <button> タグで動作するため、基本的なキーボード操作は自動的にサポートされます。

具体例

サーバー側の実装(Node.js + Express)

まずはサーバー側の実装から見ていきましょう。ページネーション用のエンドポイントを作成し、リクエストに応じて適切な HTML フラグメントを返します。

パッケージのインストール

必要なパッケージを Yarn でインストールします。

bashyarn add express ejs
yarn add -D @types/express @types/ejs typescript

サーバーのセットアップ

Express サーバーの基本設定を行います。EJS をテンプレートエンジンとして使用します。

typescript// server.ts
import express from 'express';
import path from 'path';

const app = express();
const PORT = 3000;

// EJS をテンプレートエンジンとして設定
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// 静的ファイルの配信
app.use(express.static('public'));

データの準備

サンプルデータを作成します。実際のアプリケーションではデータベースから取得することになります。

typescript// データ型の定義
interface Item {
  id: number;
  title: string;
  description: string;
}

// サンプルデータの生成(実際はデータベースから取得)
const generateItems = (count: number): Item[] => {
  return Array.from({ length: count }, (_, i) => ({
    id: i + 1,
    title: `アイテム ${i + 1}`,
    description: `これは ${
      i + 1
    } 番目のアイテムの説明文です。`,
  }));
};

const allItems = generateItems(100); // 100 件のサンプルデータ

ページネーション用のユーティリティ関数

ページネーションに必要な計算処理を行う関数を作成します。

typescript// ページネーション設定の型定義
interface PaginationConfig {
  currentPage: number;
  itemsPerPage: number;
  totalItems: number;
}

// ページネーション情報の計算
const calculatePagination = (config: PaginationConfig) => {
  const { currentPage, itemsPerPage, totalItems } = config;
  const totalPages = Math.ceil(totalItems / itemsPerPage);
  const startIndex = (currentPage - 1) * itemsPerPage;
  const endIndex = Math.min(
    startIndex + itemsPerPage,
    totalItems
  );

  return {
    totalPages,
    startIndex,
    endIndex,
    hasPrevious: currentPage > 1,
    hasNext: currentPage < totalPages,
  };
};

この関数は、現在のページ番号と 1 ページあたりの表示件数から、必要な情報を計算します。前のページや次のページが存在するかどうかも判定していますね。

ページネーションエンドポイントの実装

メインのエンドポイントを実装します。クエリパラメータからページ番号を取得し、該当するデータを返します。

typescript// メインのページネーションエンドポイント
app.get('/items', (req, res) => {
  // ページ番号の取得(デフォルトは 1)
  const page = parseInt(req.query.page as string) || 1;
  const itemsPerPage = 10;

  // ページネーション情報の計算
  const pagination = calculatePagination({
    currentPage: page,
    itemsPerPage,
    totalItems: allItems.length,
  });

  // 現在のページに表示するアイテムを取得
  const items = allItems.slice(
    pagination.startIndex,
    pagination.endIndex
  );
typescript  // htmx リクエストかどうかを判定
  const isHtmxRequest = req.headers['hx-request'] === 'true';

  // htmx からのリクエストの場合は部分テンプレート、
  // 通常のリクエストの場合は完全なページを返す
  if (isHtmxRequest) {
    res.render('partials/items-list', {
      items,
      currentPage: page,
      totalPages: pagination.totalPages,
      hasPrevious: pagination.hasPrevious,
      hasNext: pagination.hasNext,
    });
  } else {
    res.render('pages/items', {
      items,
      currentPage: page,
      totalPages: pagination.totalPages,
      hasPrevious: pagination.hasPrevious,
      hasNext: pagination.hasNext,
    });
  }
});

htmx からのリクエストには HX-Request ヘッダーが自動的に付与されるため、それを判定して返すテンプレートを切り替えています。

サーバーの起動

サーバーを起動するコードを追加します。

typescript// サーバーの起動
app.listen(PORT, () => {
  console.log(
    `Server is running on http://localhost:${PORT}`
  );
});

クライアント側の実装(HTML + htmx)

次に、クライアント側の HTML テンプレートを実装します。htmx の属性を活用して、宣言的にページネーションを実現します。

完全なページテンプレート

まず、初回アクセス時に表示される完全なページのテンプレートを作成します。

html<!-- views/pages/items.ejs -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>htmx ページネーション実装例</title>

    <!-- htmx の読み込み -->
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>

    <link rel="stylesheet" href="/styles.css" />
  </head>
  <body>
    <main>
      <h1>htmx ページネーション実装例</h1>

      <!-- ページネーションコンテナ -->
      <!-- このエリアが htmx によって更新される -->
      <div id="items-container">
        <%- include('../partials/items-list', { items,
        currentPage, totalPages, hasPrevious, hasNext }) %>
      </div>
    </main>
  </body>
</html>

このテンプレートは、初回アクセス時とブックマークからの直接アクセス時に使用されます。

部分テンプレート(アイテムリスト)

htmx によって更新される部分のテンプレートを作成します。ここに htmx 属性を設定します。

html<!-- views/partials/items-list.ejs -->
<!-- アクセシビリティのための領域設定 -->
<!-- aria-live で更新を通知、aria-atomic で全体を読み上げ -->
<div
  id="items-list"
  role="region"
  aria-live="polite"
  aria-atomic="false"
  aria-label="アイテム一覧"
>
  <!-- アイテムリストの表示 -->
  <ul class="items-list">
    <% items.forEach(item => { %>
    <li class="item-card">
      <h2><%= item.title %></h2>
      <p><%= item.description %></p>
    </li>
    <% }); %>
  </ul>
</div>

aria-live="polite" を設定することで、この領域が更新されたときにスクリーンリーダーが自動的に通知してくれます。aria-atomic="false" は、変更された部分のみを読み上げる設定です。

html<!-- ページネーションコントロール -->
<nav
  class="pagination"
  role="navigation"
  aria-label="ページネーション"
>
  <!-- 前のページへのリンク -->
  <% if (hasPrevious) { %>
  <a
    href="/items?page=<%= currentPage - 1 %>"
    hx-get="/items?page=<%= currentPage - 1 %>"
    hx-target="#items-container"
    hx-swap="innerHTML show:top"
    hx-push-url="true"
    class="pagination-link"
    aria-label="前のページへ"
  >
    &laquo; 前へ
  </a>
  <% } else { %>
  <span
    class="pagination-link disabled"
    aria-disabled="true"
  >
    &laquo; 前へ
  </span>
  <% } %>
</nav>

ここがページネーション実装の核心部分です。hx-get でリクエスト先を指定し、hx-target で更新対象を指定、hx-swap でスワップ方法を指定しています。

各属性の役割を詳しく見ていきましょう。

  • hx-get:AJAX リクエストを送信する URL
  • hx-target:更新対象の DOM 要素(CSS セレクター)
  • hx-swap:コンテンツの置き換え方法とスクロール制御
  • hx-push-url:URL を履歴に追加するかどうか

show:top 修飾子により、更新後にターゲット要素が画面上部に表示されるようスクロールします。

html<!-- ページ番号のリンク -->
<div class="pagination-numbers">
  <% for (let i = 1; i <= totalPages; i++) { %> <% if (i ===
  currentPage) { %>
  <!-- 現在のページ(リンクなし) -->
  <span
    class="pagination-number current"
    aria-current="page"
  >
    <%= i %>
  </span>
  <% } else { %>
  <!-- 他のページ(リンクあり) -->
  <a
    href="/items?page=<%= i %>"
    hx-get="/items?page=<%= i %>"
    hx-target="#items-container"
    hx-swap="innerHTML show:top"
    hx-push-url="true"
    class="pagination-number"
    aria-label="ページ <%= i %> へ移動"
  >
    <%= i %>
  </a>
  <% } %> <% } %>
</div>

現在のページには aria-current="page" を設定し、スクリーンリーダーに現在位置を明示しています。

html    <!-- 次のページへのリンク -->
    <% if (hasNext) { %>
      <a
        href="/items?page=<%= currentPage + 1 %>"
        hx-get="/items?page=<%= currentPage + 1 %>"
        hx-target="#items-container"
        hx-swap="innerHTML show:top"
        hx-push-url="true"
        class="pagination-link"
        aria-label="次のページへ"
      >
        次へ &raquo;
      </a>
    <% } else { %>
      <span
        class="pagination-link disabled"
        aria-disabled="true"
      >
        次へ &raquo;
      </span>
    <% } %>
  </nav>

  <!-- 現在のページ情報(スクリーンリーダー向け) -->
  <div
    class="sr-only"
    aria-live="polite"
    aria-atomic="true"
  >
    <%= totalPages %> ページ中 <%= currentPage %> ページ目を表示しています
  </div>
</div>

sr-only クラスの要素は視覚的には非表示ですが、スクリーンリーダーには読み上げられます。ページ切り替え時に現在位置の情報が音声で通知されます。

スタイルの実装(CSS)

見た目を整えるための CSS を追加します。アクセシビリティのための視覚的なフィードバックも含めます。

css/* public/styles.css */
/* 基本スタイル */
body {
  font-family: -apple-system, BlinkMacSystemFont,
    'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  line-height: 1.6;
  color: #333;
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

h1 {
  color: #2c3e50;
  border-bottom: 3px solid #3498db;
  padding-bottom: 10px;
}
css/* アイテムリストのスタイル */
.items-list {
  list-style: none;
  padding: 0;
  margin: 20px 0;
}

.item-card {
  background: #f8f9fa;
  border-left: 4px solid #3498db;
  padding: 15px 20px;
  margin-bottom: 15px;
  border-radius: 4px;
  transition: transform 0.2s, box-shadow 0.2s;
}

.item-card:hover {
  transform: translateX(5px);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.item-card h2 {
  margin: 0 0 10px 0;
  font-size: 1.2em;
  color: #2c3e50;
}

.item-card p {
  margin: 0;
  color: #7f8c8d;
}
css/* ページネーションのスタイル */
.pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin: 30px 0;
  padding: 20px;
  background: #ffffff;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.pagination-link,
.pagination-number {
  padding: 8px 16px;
  text-decoration: none;
  color: #3498db;
  background: #ffffff;
  border: 2px solid #3498db;
  border-radius: 4px;
  transition: all 0.2s;
  font-weight: 500;
}
css/* ホバー時とフォーカス時のスタイル */
/* キーボード操作のための視覚的フィードバック */
.pagination-link:hover,
.pagination-number:hover {
  background: #3498db;
  color: #ffffff;
  transform: translateY(-2px);
  box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3);
}

.pagination-link:focus,
.pagination-number:focus {
  outline: 3px solid #f39c12;
  outline-offset: 2px;
}

フォーカス時のアウトラインスタイルは、キーボード操作時の視認性を高めるために重要です。明確な視覚的フィードバックを提供しましょう。

css/* 現在のページのスタイル */
.pagination-number.current {
  background: #3498db;
  color: #ffffff;
  border-color: #3498db;
  cursor: default;
  font-weight: bold;
}

/* 無効なリンクのスタイル */
.pagination-link.disabled {
  color: #bdc3c7;
  border-color: #bdc3c7;
  cursor: not-allowed;
  opacity: 0.6;
}

.pagination-numbers {
  display: flex;
  gap: 5px;
}
css/* スクリーンリーダー専用テキスト */
/* 視覚的には非表示だが、支援技術からはアクセス可能 */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

sr-only クラスは、視覚的には完全に非表示にしつつ、スクリーンリーダーには読み上げられるようにする標準的な技法です。

css/* htmx によるローディング中の視覚的フィードバック */
.htmx-request .pagination {
  opacity: 0.6;
  pointer-events: none;
}

.htmx-request .items-list {
  opacity: 0.6;
}

/* アニメーション効果 */
.htmx-swapping {
  opacity: 0;
  transition: opacity 200ms ease-out;
}

.htmx-settling {
  opacity: 1;
  transition: opacity 200ms ease-in;
}

htmx は自動的に特定のクラスを追加・削除するため、それを利用してローディング中の視覚的フィードバックを提供できます。

高度な実装例:スクロール位置の細かな制御

状況に応じてスクロール動作を変える高度な実装例を見てみましょう。

スクロール位置を保持する実装

「次へ」ボタンをクリックしたときは、スクロール位置を保持したい場合があります。無限スクロール風の体験を提供できます。

html<!-- スクロール位置を保持する「次へ」ボタン -->
<a
  href="/items?page=<%= currentPage + 1 %>"
  hx-get="/items?page=<%= currentPage + 1 %>"
  hx-target="#items-list"
  hx-swap="outerHTML show:none"
  hx-push-url="true"
  class="pagination-link"
  aria-label="次のページへ(スクロール位置を保持)"
>
  さらに読み込む
</a>

show:none 修飾子により、コンテンツ更新後もスクロール位置が変わりません。

特定の要素までスクロールする実装

新しいコンテンツが読み込まれたら、そのコンテンツの先頭にスムーズにスクロールしたい場合もあります。

html<!-- 新しいコンテンツの先頭にスクロール -->
<a
  href="/items?page=<%= currentPage + 1 %>"
  hx-get="/items?page=<%= currentPage + 1 %>"
  hx-target="#items-list"
  hx-swap="outerHTML scroll:top"
  hx-push-url="true"
  class="pagination-link"
  aria-label="次のページへ(先頭にスクロール)"
>
  次へ &raquo;
</a>

scroll:top 修飾子により、更新されたターゲット要素の上部にスクロールします。

カスタムイベントを使った拡張

htmx はカスタムイベントを発火するため、それをフックしてさらに高度な処理を追加できます。

html<script>
  // htmx のリクエスト前に実行される処理
  document.body.addEventListener(
    'htmx:beforeRequest',
    (event) => {
      console.log('ページネーションリクエストを開始します');

      // ローディングインジケーターの表示など
      const loader = document.getElementById(
        'loading-indicator'
      );
      if (loader) {
        loader.style.display = 'block';
      }
    }
  );

  // htmx のリクエスト完了後に実行される処理
  document.body.addEventListener(
    'htmx:afterSwap',
    (event) => {
      console.log('ページネーションが完了しました');

      // ローディングインジケーターの非表示
      const loader = document.getElementById(
        'loading-indicator'
      );
      if (loader) {
        loader.style.display = 'none';
      }

      // Google Analytics などへのページビュー送信
      // gtag('event', 'page_view', { page_path: window.location.pathname });
    }
  );
</script>

これらのイベントハンドラーを使うことで、アナリティクスの送信やカスタムアニメーションの実装など、さまざまな拡張が可能になります。

エラーハンドリングの実装

ネットワークエラーやサーバーエラーが発生した場合の処理も重要です。

html<script>
  // リクエストエラー時の処理
  document.body.addEventListener(
    'htmx:responseError',
    (event) => {
      console.error(
        'ページネーションリクエストが失敗しました',
        event.detail
      );

      // エラーメッセージの表示
      const errorDiv = document.createElement('div');
      errorDiv.className = 'error-message';
      errorDiv.setAttribute('role', 'alert');
      errorDiv.textContent =
        'ページの読み込みに失敗しました。もう一度お試しください。';

      const container = document.getElementById(
        'items-container'
      );
      if (container) {
        container.insertBefore(
          errorDiv,
          container.firstChild
        );
      }
    }
  );
</script>
css/* エラーメッセージのスタイル */
.error-message {
  background: #e74c3c;
  color: #ffffff;
  padding: 15px 20px;
  border-radius: 4px;
  margin-bottom: 20px;
  font-weight: 500;
}

エラーメッセージには role="alert" を設定することで、スクリーンリーダーが即座に読み上げてくれます。

まとめ

htmx を活用したページネーション実装について、履歴操作、スクロール保持、アクセシビリティ対応の 3 つの重要な側面から解説してきました。

htmx の最大の魅力は、HTML 属性だけで複雑な機能を実現できる点にあります。hx-push-url で履歴管理を自動化し、hx-swap の修飾子でスクロール動作を細かく制御できました。さらに、適切な ARIA 属性と組み合わせることで、すべてのユーザーにとって使いやすいページネーションが実現できます。

重要なポイントを振り返りましょう。

まず、ブラウザの履歴管理は hx-push-url="true" を設定するだけで実現でき、戻る/進むボタンが期待通りに動作します。次に、スクロール制御は hx-swapshow:topshow:nonescroll:top などの修飾子を状況に応じて使い分けることが大切です。そして、アクセシビリティは aria-livearia-currentaria-label などの属性を適切に設定し、すべてのユーザーに情報を伝えることができます。

JavaScript フレームワークに頼らず、宣言的な HTML 属性だけでモダンな Web アプリケーションを構築できる時代になりました。htmx はその可能性を大きく広げてくれる素晴らしいツールです。

ぜひ、ご自身のプロジェクトでも htmx を活用したページネーション実装に挑戦してみてくださいね。

関連リンク