この記事について

htmxは、JavaScriptをほとんど書かずにAjax通信とDOM更新を実現できる軽量ライブラリです。HTML属性を追加するだけでサーバーからHTMLの断片を取得し、ページの一部を差し替えられます。

この記事では、htmxの主要なhx-*属性の使い方と、実装時につまずきやすいポイント(swap後のJS実行やCLS対策など)をまとめます。

htmxの導入

CDNから1行追加するだけで使えます。

<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>

npmの場合はインストール後にバンドルへ含めます。

npm install htmx.org

hx-get / hx-post — リクエストの発行

htmxの基本は、HTML属性でAjaxリクエストを定義することです。hx-getでGETリクエスト、hx-postでPOSTリクエストを送ります。hx-puthx-deleteも同様に使えます。htmxはどんな要素にも付与でき、<div><a>でも動作します。

<!-- GETリクエスト -->
<div hx-get="/api/items" hx-target="#item-list">一覧を取得</div>

<!-- POSTリクエスト(フォーム送信) -->
<form hx-post="/api/items" hx-target="#item-list">
  <input type="text" name="title">
  <button type="submit">追加</button>
</form>

<!-- aタグでも使える -->
<a hx-get="/api/items/1" hx-target="#detail">詳細を見る</a>

サーバーが返すのは完全なHTMLページではなく、差し込みたい部分のHTMLだけです。<html><head>タグは含めません。

// サーバー側(PHP)はHTMLの断片だけを返す
echo '<p>こんにちは!現在の時刻は ' . date('H:i:s') . ' です。</p>';

hx-target — 更新先の指定

hx-targetで、レスポンスHTMLを差し込む先の要素を指定します。CSSセレクタで指定できます。

<div hx-get="/api/greeting" hx-target="#result">
  クリックであいさつを取得
</div>
<div id="result"></div>

hx-targetを省略すると、リクエストを発行した要素自身の中身が置き換わります。意図しない場合は明示的に指定しましょう。

CSSセレクタ以外にも、this(自分自身)、closest セレクタ(最も近い祖先要素)、find セレクタ(子孫要素の最初の一致)、next セレクタ(次の兄弟要素)、previous セレクタ(前の兄弟要素)といったキーワードが使えます。

<!-- テーブル行内のボタンから、最も近い親のtrを更新 -->
<button hx-get="/api/row/1" hx-target="closest tr">更新</button>

<!-- 次の兄弟要素の.outputを更新 -->
<div hx-get="/api/data" hx-target="next .output">クリックで取得</div>
<div class="output"></div>

hx-swap — innerHTMLとouterHTMLの違い

hx-swapは、レスポンスHTMLをどのように挿入するかを指定する属性です。デフォルトはinnerHTMLで、ターゲット要素の中身だけを置き換えます。

innerHTMLとouterHTMLの違い

この2つの違いは、ターゲット要素自体を残すか・消すかです。

<!-- innerHTML(デフォルト): #boxの中身だけ入れ替え -->
<div hx-get="/content" hx-target="#box" hx-swap="innerHTML">
  クリックで中身を入れ替え
</div>
<div id="box">ここが置き換わる</div>

<!-- 結果: <div id="box">が残り、中身だけ変わる -->
<!-- <div id="box">(レスポンスのHTML)</div> -->
<!-- outerHTML: #box要素ごと置き換え -->
<div hx-get="/content" hx-target="#box" hx-swap="outerHTML">
  クリックで要素ごと入れ替え
</div>
<div id="box">ここが丸ごと消える</div>

<!-- 結果: <div id="box">自体が消え、レスポンスHTMLに置き換わる -->

使い分けの目安は次の通りです。

innerHTMLはコンテナのスタイルやIDを維持したまま中身だけ更新したい場合に使います。リスト内のアイテム更新、タブのコンテンツ切り替え、検索結果の入れ替えなどが典型例です。

outerHTMLは要素全体(属性含む)を新しいものに差し替えたい場合に使います。インライン編集で表示と入力フォームを切り替えるパターンや、コンポーネント単位の更新に向いています。

その他のswapオプション

要素の前後にHTMLを追加するbeforeendafterbeginも実務でよく使います。

<!-- ターゲットの末尾に追加(追加読み込みに最適) -->
<div hx-get="/api/items?page=2" hx-target="#list" hx-swap="beforeend">
  もっと読み込む
</div>

<!-- ターゲットの先頭に追加(新着表示などに) -->
<div hx-get="/api/latest" hx-target="#list" hx-swap="afterbegin">
  新着を取得
</div>
動作
innerHTMLターゲットの中身を置き換え(デフォルト)
outerHTMLターゲット要素自体を置き換え
beforebeginターゲットの直前に挿入
afterbeginターゲットの先頭に挿入
beforeendターゲットの末尾に挿入
afterendターゲットの直後に挿入
deleteターゲットを削除(レスポンス無視)
noneDOMを更新しない

hx-trigger — リクエストの発火タイミング

デフォルトでは<input><select>change<form>submit、それ以外の要素(<div><a><button>など)はclickでリクエストが発火します。hx-triggerで発火タイミングを変更できます。

<!-- 入力中にリアルタイム検索(500msの遅延付き) -->
<input type="text" name="q"
       hx-get="/search"
       hx-trigger="keyup changed delay:500ms"
       hx-target="#search-results">

<!-- ページ読み込み時に自動取得 -->
<div hx-get="/notifications"
     hx-trigger="load"
     hx-target="this">
  読み込み中...
</div>

<!-- スクロールで画面内に入ったら取得(無限スクロール向き) -->
<div hx-get="/items?page=2"
     hx-trigger="revealed"
     hx-swap="afterend">
</div>

<!-- 一度だけ発火 -->
<div hx-get="/api/data" hx-trigger="click once">
  一度だけ取得
</div>

hx-select — レスポンスから一部だけ抽出する

サーバーがページ全体を返すとき、そのすべてを使いたいわけではないケースがあります。hx-selectを使うと、レスポンスHTMLの中からCSSセレクタに一致する要素だけを抜き出せます。

<!-- レスポンス全体から#main-contentだけを取り出す -->
<a hx-get="/page/about"
   hx-target="#content"
   hx-select="#main-content">
  Aboutページを読み込む
</a>
<div id="content"></div>

たとえばサーバーが以下のようなHTMLを返した場合を考えます。

<!-- サーバーのレスポンス(ページ全体) -->
<header>ヘッダー</header>
<div id="main-content">
  <h1>Aboutページ</h1>
  <p>ここだけ使いたい</p>
</div>
<footer>フッター</footer>

hx-select="#main-content"を指定していれば、<header><footer>は無視され、#main-contentの中身だけが#contentに差し込まれます。

これは既存のサーバーサイドテンプレートを変更できない場合や、htmx専用のエンドポイントを用意せず通常のページURLをそのまま使いたい場合に役立ちます。

hx-select-oob — 複数箇所を同時に更新

hx-select-oobを組み合わせると、メインのターゲット以外の要素も同時に更新できます。

<div hx-get="/api/update"
     hx-target="#main"
     hx-select="#main"
     hx-select-oob="#notification">
  更新
</div>

<div id="main">メインコンテンツ</div>
<div id="notification">通知エリア</div>

レスポンスに#main#notificationの両方が含まれていれば、#mainはターゲットとして通常のswap、#notificationはOut of Bandで同時に更新されます。

hx-indicator — ローディング表示

hx-indicatorを使うと、通信中にローディング表示を自動で切り替えられます。htmxはリクエスト中に指定した要素へhtmx-requestクラスを付与します。

<div hx-get="/api/data" hx-target="#data-area" hx-indicator="#spinner">
  データ取得
</div>
<span id="spinner" class="htmx-indicator">読み込み中...</span>
<div id="data-area"></div>
.htmx-indicator {
  display: none;
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  display: inline-block;
}

swap後にJavaScriptを実行する方法

htmxでDOMが更新された後に、差し込まれた要素に対してJavaScriptを実行したい場面は多いです。たとえばswap後にスライダーの初期化、アニメーションの開始、サードパーティライブラリの再適用などが該当します。

htmx:afterSwap イベントを使う

htmxはswap完了後にhtmx:afterSwapイベントを発火します。このイベントをリッスンすることで、差し込まれた要素に対してJSを実行できます。

document.body.addEventListener('htmx:afterSwap', function(event) {
  // event.detail.target — swapされたターゲット要素
  const target = event.detail.target;

  // ターゲット内のスライダーを初期化
  const sliders = target.querySelectorAll('.slider');
  sliders.forEach(slider => {
    new SomeSliderLibrary(slider);
  });
});

event.detail.targetにはswap先の要素が入っています。ここを起点にquerySelectorAllで差し込まれた要素を取得できます。

htmx:afterSettle を使う

htmx:afterSwapはDOM挿入直後、htmx:afterSettleはhtmx内部の後処理(CSSトランジションの準備など)が完了した後に発火します。高さの計算やレイアウト依存の処理はhtmx:afterSettleの方が安全です。

document.body.addEventListener('htmx:afterSettle', function(event) {
  const target = event.detail.target;

  // レイアウトが確定した後に高さ計算やアニメーションを実行
  const newItems = target.querySelectorAll('.fade-in');
  newItems.forEach(item => {
    item.classList.add('is-visible');
  });
});

hx-on属性でインラインに書く

イベントリスナーをJSファイルに書かず、HTML属性で直接指定することもできます。

<div hx-get="/api/content"
     hx-target="this"
     hx-on:htmx:after-swap="console.log('swap完了:', this)">
  コンテンツ
</div>

hx-onを使う場合はイベント名をケバブケースで書きます(htmx:after-swap)。なお、outerHTMLでswapする場合、発火元の要素自体がDOMから消えるため、その要素にhx-on:htmx:after-swapを付けても発火しません。この場合は親要素かdocument.bodyでリッスンする必要があります。

htmx:load — 新しく追加されたノードに反応する

htmx:loadイベントは、htmxによって新しいノードがDOMに追加されたときに発火します。ページ初回読み込み時にも発火するため、初期化処理を共通化できます。

document.body.addEventListener('htmx:load', function(event) {
  // event.detail.elt — 新しく読み込まれた要素
  const newElement = event.detail.elt;

  // 新しく追加されたツールチップを初期化
  const tooltips = newElement.querySelectorAll('[data-tooltip]');
  tooltips.forEach(el => initTooltip(el));
});

htmxによるCLS(レイアウトシフト)対策

htmxでコンテンツを動的に挿入すると、ページのレイアウトが押し下げられてCLS(Cumulative Layout Shift)が悪化する可能性があります。CLSはCore Web Vitalsの指標の一つで、スコアが0.1を超えるとGoogleに「改善が必要」と判定されます。

ただし、ユーザー操作(クリックなど)から500ms以内のレイアウトシフトはCLSにカウントされません。つまり、ボタンクリックで「もっと見る」を読み込む場合、レスポンスが速ければCLSへの影響はありません。

問題になるのは、ユーザー操作なしで自動的にコンテンツが挿入されるケースです。

問題になるパターン

hx-trigger="load"でページ読み込み時に自動取得するケースが典型です。レスポンスが返ってくるまでの間、挿入先の高さが0のため、レスポンスが差し込まれた瞬間に下のコンテンツが押し下げられます。

<!-- ❌ CLS悪化の原因になりやすい -->
<div hx-get="/api/sidebar-banner"
     hx-trigger="load"
     hx-target="this">
</div>
<!-- ↓ この下のコンテンツが押し下げられる -->
<main>メインコンテンツ</main>

対策①:min-heightで領域を確保する

最もシンプルな対策は、コンテンツが挿入される前から高さを確保しておくことです。

<!-- ✅ min-heightで領域を事前確保 -->
<div hx-get="/api/sidebar-banner"
     hx-trigger="load"
     hx-target="this"
     style="min-height: 250px;">
</div>

高さが固定できない場合は、想定される最小の高さをmin-heightに指定するだけでもCLSスコアの改善につながります。

対策②:スケルトンスクリーンを表示する

高さを確保しつつ、ユーザーに「読み込み中」であることを伝えるにはスケルトンスクリーンが有効です。

<div hx-get="/api/card-list"
     hx-trigger="load"
     hx-target="this">
  <!-- スケルトン(読み込み後にhtmxが中身を差し替える) -->
  <div class="skeleton" style="height: 200px; background: #e0e0e0; border-radius: 8px;"></div>
</div>

スケルトンの高さを実際のコンテンツと近い値にしておけば、swap後のレイアウトシフトを最小限に抑えられます。

対策③:ビューポート外で読み込む

ビューポート外で発生するレイアウトシフトはCLSにカウントされません。ファーストビュー以下の要素であれば、hx-trigger="revealed"を使ってスクロールして画面内に入ったタイミングで取得する方法も有効です。

<!-- 画面に表示されたタイミングで取得 -->
<div hx-get="/api/recommendations"
     hx-trigger="revealed"
     hx-target="this"
     style="min-height: 300px;">
</div>

対策④:hx-swap=”none”でDOMを更新しない

レスポンスを受け取るだけでDOMを更新したくない場合(データ送信だけのケースなど)は、hx-swap="none"を指定すればレイアウトシフトは発生しません。

まとめ

htmxの基本的な流れは以下の通りです。

1. hx-get / hx-postでリクエスト先を指定する
2. hx-targetで更新する要素を指定する
3. hx-swapでHTMLの挿入方法を選ぶ(innerHTMLかouterHTMLか、追加か置き換えか)
4. 必要に応じてhx-triggerで発火タイミングを変更する
5. hx-selectでレスポンスの一部だけを使う
6. swap後のJS実行はhtmx:afterSwapイベントで対応する
7. 自動読み込み時はCLS対策(min-height確保・スケルトン)を忘れない

htmxはJSONではなくHTMLの断片をやり取りするシンプルなアプローチなので、WordPressやPHPベースのサイトと特に相性が良いです。SPAフレームワークを導入するほどではないが、部分的にAjaxを使いたいという場面で検討してみてください。