aspect-ratio は、画像の高さを先に予約してレイアウトのガタつき(CLS)を防ぐための、いまや定番のプロパティです。便利な一方で、「なぜか効かない」「値のメンテナンスが面倒で結局使わなくなる」という2つの壁にぶつかりがちです。

この記事では、前半で 効かないケースの整理と、遅延読み込み時代のCLS対策としての正しい使い方、後半で本題である 縦横比を自動計算・自動付与し、画像の入れ替えが多い現場でも手作業ゼロで維持する実運用の仕組みをまとめます。「効かない」を解決するだけでなく、その先の「メンテナンスし続けられる形」までを扱うのがこの記事のねらいです。


    aspect-ratio とは何か

    aspect-ratio は、要素ボックスの「幅と高さの比率」を指定するプロパティです。aspect-ratio: 16 / 9 のように書くと、幅が決まれば高さがその比率で自動的に決まります。

    .thumb {
      width: 100%;
      aspect-ratio: 16 / 9; /* 幅に対して高さが自動で決まる */
      height: auto;
    }

    なぜ画像に「高さの予約」が必要なのか

    ブラウザは、画像を実際にダウンロードするまでその寸法を知りません。寸法が不明な画像は、いったん高さ0(もしくは極小)で配置され、読み込み完了後に本来の高さへ広がります。このとき下にあるコンテンツが押し下げられ、これが CLS(Cumulative Layout Shift) としてスコアに加算されます。

    ページ内に寸法未指定の画像が5枚あれば、読み込みのたびに最大5回の独立したズレが起こり得ます。とくに 遅延読み込み(lazy loading)と寸法未指定の組み合わせは、大きくズレます

    逆に言えば、寸法さえ先に伝えておけば、遅延読み込みでもCLSは発生しません。


    aspect-ratio が効かない主なケース

    「指定したのに効かない」の原因は、ほとんどが次のいずれかです。

    1. 幅と高さを両方とも固定している(最頻出)

    aspect-ratio幅か高さの少なくとも一方が auto のときにだけ働きます。両方に具体値が入っていると、指定した比率は黙って無視され、明示した値が優先されます。

    /* NG: 両方固定 → aspect-ratio は無視される */
    .box { width: 200px; height: 50px; aspect-ratio: 1; }
    
    /* OK: 片方を auto に */
    .box { width: 200px; height: auto; aspect-ratio: 1; }

    対策として、aspect-ratio を使う要素に横断的に height: auto を当ててしまう手もあります。

    [style*="aspect-ratio:"] { height: auto; }

    2. フレックスアイテムが引き伸ばされている

    親に align-items: stretch(フレックスの初期値)が効いていて、アイテムの高さが交差軸方向へ引き伸ばされる場合、その高さが優先されて aspect-ratio が効きません。height: fit-contentmax-content を併用するか、align-items を見直します。

    3. img の内在サイズが優先されている

    <img> は「置換要素」で、読み込んだ画像そのものの寸法(内在サイズ)を持ちます。CSSの width/height が両方 auto で、かつ HTMLの width/height 属性も無いと、画像は元のサイズで表示され、aspect-ratio だけを足しても比率は変わりません。幅を明示する(width: 100% など)か、width/height 属性を付けることで解消します。

    4. その他

    コンテンツ(長いテキスト等)が比率をはみ出す場合はそちらのサイズが優先されます。また、aspect-ratio のスペルミス(aspect-racio など)という初歩的な原因も、意外と見落とされます。


    CLS対策としての aspect-ratio

    寸法が動的で属性を出しにくい場合や、コンテナ側で比率を固定したい場合は aspect-ratio が向きます。object-fit と組み合わせると、予約したボックスへ画像をきれいに収められます。

    .media { aspect-ratio: 16 / 9; overflow: hidden; }
    .media > img { width: 100%; height: 100%; object-fit: cover; display: block; }

    picture で SP/PC の比率が違うときの罠

    <picture> の中で実際に描画されるボックスは <img> ただ1つです。<source> は「その <img> がどのファイルを読むか」を差し替えているだけで、レイアウト要素は常に <img> 1個。したがって <img> に付けたインラインの aspect-ratio は、どの <source> が選ばれても同じ1つの値として適用されます。

    <picture>
      <source media="(max-width: 743.9px)" srcset="cover_sp.jpg" /><!-- 1080×1258 -->
      <img style="aspect-ratio:2880/1300;" src="cover.jpg" alt="" /><!-- 2880×1300 -->
    </picture>

    この例で aspect-ratio:2880/1300 は「SP画像に効く」のではなく、SPでもPCでも同じ値として効きます。結果、比率の異なるSP表示では予約高さがズレます。

    解決策は、ブレークポイントごとに別々の比率を与えることです。

    要素の style をブレークポイントで書き換える仕組み(自作のJSやデータ属性)で、SP/PCそれぞれの aspect-ratio を差し替える方法もあります。

    (function () {
      //setStyles-breakpoint.js (data-* attributes)
      
      const BREAKPOINT = 744;
    
      //要素の事前キャッシュ
      let cachedElements = null;
      let lastWindowWidth = -1;
    
      function setStyles() {
        if (!cachedElements) {
          cachedElements = [...document.querySelectorAll("[data-style_sp], [data-style_pc]")];
        }
    
        const windowWidth = window.innerWidth;
    
        if (lastWindowWidth !== -1) {
          const lastBreakpoint = lastWindowWidth < BREAKPOINT;
          const currentBreakpoint = windowWidth < BREAKPOINT;
    
          if (lastBreakpoint === currentBreakpoint) {
            return;
          }
        }
    
        lastWindowWidth = windowWidth;
    
        for (const element of cachedElements) {
          const isMobile = windowWidth < BREAKPOINT;
    
          const styleValue = isMobile
            ? element.dataset.style_sp
            : element.dataset.style_pc;
    
          if (!styleValue) continue;
    
          const currentStyle = element.getAttribute('style') || '';
          const cleanedStyle = currentStyle.replace(/(^|[;s])s*(?:width|height|aspect-ratio)s*:[^;]+;?/g, '$1');
          const newStyle = cleanedStyle.trim() + (cleanedStyle && !cleanedStyle.endsWith(';') ? ';' : '') + styleValue;
    
          element.setAttribute('style', newStyle.trim());
        }
      }
    
      let rafId = null;
      let rafTicking = false;
    
      function throttledSetStyles() {
        if (!rafTicking) {
          rafTicking = true;
          rafId = requestAnimationFrame(() => {
            setStyles();
            rafTicking = false;
          });
        }
      }
    
      const observer = new MutationObserver((mutations) => {
        let shouldUpdate = false;
    
        for (const mutation of mutations) {
          if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
            for (const node of mutation.addedNodes) {
              if (node.nodeType === 1) {
                if (node.dataset?.style_sp || node.dataset?.style_pc) {
                  shouldUpdate = true;
                  break;
                }
                if (node.querySelector?.('[data-style_sp], [data-style_pc]')) {
                  shouldUpdate = true;
                  break;
                }
              }
            }
            if (shouldUpdate) break;
          }
        }
    
        if (shouldUpdate) {
          cachedElements = null;
          throttledSetStyles();
        }
      });
    
      function initEventListeners() {
        if (document.readyState === 'loading') {
          document.addEventListener('DOMContentLoaded', setStyles, { once: true });
        }
    
        window.addEventListener('load', throttledSetStyles, { once: true, passive: true });
        window.addEventListener('resize', throttledSetStyles, { passive: true });
    
        observer.observe(document.body, {
          childList: true,
          subtree: true
        });
      }
    
      if (document.readyState === 'complete' || document.readyState === 'interactive') {
        setStyles();
      }
    
      initEventListeners();
      
      console.log('🟢 setStyles-breakpoint.js: スタイル適用完了!, 対象要素数 =', cachedElements.length);
    
      window.setStylesBreakpoint = {
        refresh: () => {
          cachedElements = null;
          lastWindowWidth = -1;
          setStyles();
        },
        destroy: () => {
          if (rafId) {
            cancelAnimationFrame(rafId);
          }
          observer.disconnect();
          window.removeEventListener('resize', throttledSetStyles);
        }
      };
    })();

    実運用:縦横比を自動計算・自動付与して手作業ゼロで維持する

    ここからが本題です。aspect-ratio の理屈は分かっても、現場ではデザイナーが画像とサイズを頻繁に差し替えるため、そのたびに手で比率を書き換えるのは時間コストが高すぎて続きません。古い比率が残ったまま新しい画像が入り、見切れ(クリッピング)が多発する——これが「結局使わない」に直結します。

    そこで、比率の付与を人手から外し、保存時に実画像を実測して自動で付け直す運用にします。

    WordPress なら content_save_pre フィルタで、保存されるコンテンツ中の <img> を走査し、対象画像の実寸を getimagesize() で測って aspect-ratio を組み立て、既存の値を上書きします。

    画像をドロップするだけで aspect-ratio を自動計算

    なお、テンプレートに直接書く <img> や、単発でHTMLを用意したいときは、画像をドロップするだけで width・height・aspect-ratio 付きの imgタグを一括生成できるツールなどを使うと便利です。40〜60枚程度の画像でも一括で即処理、そのまま貼り付けられるので、以下で解説する「保存時の自動付与」を、保存フローの外でも同じ発想で再現できます。

    画像→imgタグ変換ツール | 画像サイズ・アスペクト比自動計算サイト 画像をドロップするだけでimgタグを自動生成。width・height・aspect-ratioを正確に設定したHTMLコードを瞬時に作成。Web制作・コーディング作業を効率化するオンライン変換サイト。  続きを読む

    picture のSP/PC出し分けも自動化する

    前述の <picture> 問題も、保存時に実画像を実測して自動で付け直す運用で処理できます。<img>(PC基準)と、mediamax-width を含む <source>(SP)の両方を実測し、比率が異なるときだけブレークポイント別のデータ属性(例:data-style_sp / data-style_pc)を生成します。同じ比率、あるいはSP用ソースが無い場合は単一の aspect-ratio で済ませます。

    このとき、media="(prefers-reduced-motion: reduce)" のような max-width以外のソースはSP扱いしない、という判定を入れておきます。アニメーション抑制用の静止画差し替えなどをSPと誤認しないためです。

    そして、生成した data-style_sp / data-style_pc は、ビューポート幅に応じて要素の style を書き換える小さなスクリプトで適用します。ここで 2点だけ注意があります。

    (1) 初回描画の予約:データ属性はJSが走るまで style に反映されないため、初回描画にわずかなCLSの窓ができます。<img> のインライン style にもPC値を1つ入れておけば、JS実行前でも高さが予約されます。

    (2) 値の蓄積と語境界:ブレークポイントを跨ぐたびに style を組み直すスクリプトでは、古い width/height/aspect-ratio を除去してから新しい値を足します。この除去用の正規表現に語境界が無いと、min-widthmax-widthline-height の一部まで巻き込んで壊すことがあります。直前が英字・ハイフンでないことを条件にして、プロパティ名だけを対象にします。

    // 「先頭・セミコロン・空白の直後」に限定し、min-width や line-height を巻き込まない
    const cleaned = style.replace(
      /(^|[;s])s*(?:width|height|aspect-ratio)s*:[^;]+;?/g,
      '$1'
    );

    投稿保存時に <img> へ実画像の寸法から aspect-ratio を付与/更新する

    //(functions.php)
    
    /**
     * 投稿保存時に <img> へ実画像の寸法から aspect-ratio を付与/更新する
     *
     * - <picture> 内の <img> は、SP <source>(media に max-width を含むもの)と
     *   data-src0 の両方を実測し、比率が違えば data-style_sp / data-style_pc を生成、
     *   同比率 or SP source 無しなら単一の inline aspect-ratio を付与。
     * - 単独 <img> は data-src0(無ければ src)から単一の aspect-ratio を付与。
     * - media="(prefers-reduced-motion: reduce)" など max-width 以外の source は SP扱いしない。
     * - 既存の aspect-ratio は必ず上書き(stale 対策)。
     * - 解決できない画像(外部URL・SVG 等)は据え置き。
     */
    function iv_add_aspect_on_save($content) {
      if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return $content;
      if (empty($content)) return $content;
    
      $content = wp_unslash($content);
    
      // 1) <picture> ブロック(SP/PC 判定つき)
      $content = preg_replace_callback('/<pictureb[^>]*>.*?</picture>/is', function ($m) {
        return iv_process_picture($m[0]);
      }, $content);
    
      // 2) 残りの単独 <img>
      $content = preg_replace_callback('/<imgb[^>]*>/is', function ($m) {
        return iv_apply_aspect_to_img($m[0]);
      }, $content);
    
      return wp_slash($content);
    }
    add_filter('content_save_pre', 'iv_add_aspect_on_save', 10, 1);
    
    /**
     * <picture> ブロック内の <img> に SP/PC を判定して aspect-ratio を付与
     */
    function iv_process_picture($picture) {
      if (!preg_match('/<imgb[^>]*>/is', $picture, $im)) return $picture;
      $img = $im[0];
    
      // PC(基準)= data-src0(無ければ src)
      $pc_url  = iv_pick_image_url($img);
      $pc_dims = $pc_url !== '' ? iv_get_image_dimensions($pc_url) : false;
    
      // SP= media に max-width を含む最初の <source> の srcset 先頭URL
      $sp_url = '';
      if (preg_match_all('/<sourceb[^>]*>/is', $picture, $srcs)) {
        foreach ($srcs[0] as $s) {
          if (preg_match('/bmedias*=s*(["'])(.*?)1/is', $s, $mm)
              && stripos($mm[2], 'max-width') !== false
              && preg_match('/bsrcsets*=s*(["'])(.*?)1/is', $s, $ss)) {
            $sp_url = iv_first_srcset_url($ss[2]);
            break;
          }
        }
      }
      $sp_dims = $sp_url !== '' ? iv_get_image_dimensions($sp_url) : false;
    
      $new_img = $img;
    
      if ($pc_dims && $sp_dims && iv_ratio_differs($pc_dims, $sp_dims)) {
        // 比率が異なる → SP/PC 別指定。inline style にも PC を入れて初回描画の高さを予約
        $ar_pc = $pc_dims[0] . '/' . $pc_dims[1];
        $ar_sp = $sp_dims[0] . '/' . $sp_dims[1];
        $new_img = iv_set_prop_in_attr($new_img, 'style',         'aspect-ratio', $ar_pc);
        $new_img = iv_set_prop_in_attr($new_img, 'data-style_pc', 'aspect-ratio', $ar_pc);
        $new_img = iv_set_prop_in_attr($new_img, 'data-style_sp', 'aspect-ratio', $ar_sp);
      } elseif ($pc_dims) {
        // SP source 無し or 同比率 → 単一 aspect-ratio。古い data-style は掃除
        $ar_pc = $pc_dims[0] . '/' . $pc_dims[1];
        $new_img = iv_set_prop_in_attr($new_img, 'style', 'aspect-ratio', $ar_pc);
        $new_img = iv_remove_prop_in_attr($new_img, 'data-style_sp', 'aspect-ratio');
        $new_img = iv_remove_prop_in_attr($new_img, 'data-style_pc', 'aspect-ratio');
      }
      // どちらも測れなければ new_img は img のまま(据え置き)
    
      return str_replace($img, $new_img, $picture);
    }
    
    /**
     * 単独 <img>(picture 外)に単一 aspect-ratio を付与
     */
    function iv_apply_aspect_to_img($tag) {
      // picture 内で処理済み(data-style_* 付き)はスキップ
      if (preg_match('/bdata-style_(sp|pc)s*=/i', $tag)) return $tag;
    
      $url = iv_pick_image_url($tag);
      if ($url === '') return $tag;
    
      $dims = iv_get_image_dimensions($url);
      if (!$dims || $dims[0] < 1 || $dims[1] < 1) return $tag;
    
      return iv_set_prop_in_attr($tag, 'style', 'aspect-ratio', $dims[0] . '/' . $dims[1]);
    }
    
    /* ───────── helpers ───────── */
    
    /** 比率が異なるか(浮動小数を避けて外積で比較) */
    function iv_ratio_differs($a, $b) {
      return ($a[0] * $b[1]) !== ($a[1] * $b[0]);
    }
    
    /** 寸法の元にする URL(data: プレースホルダは除外、data-src0 優先) */
    function iv_pick_image_url($tag) {
      foreach (['data-src0', 'src'] as $attr) {
        $pat = '/b' . preg_quote($attr, '/') . 's*=s*(["'])(.*?)1/is';
        if (preg_match($pat, $tag, $mm)) {
          $u = trim($mm[2]);
          if ($u === '' || stripos($u, 'data:') === 0) continue;
          return $u;
        }
      }
      return '';
    }
    
    /** srcset 文字列の先頭URL(記述子 1x/744w 等を除去) */
    function iv_first_srcset_url($srcset) {
      $first = trim(explode(',', $srcset)[0]);
      if ($first === '') return '';
      $parts = preg_split('/s+/', $first, 2);
      return $parts[0];
    }
    
    /** URL → ローカルパス → getimagesize()。1リクエスト内はメモ化。 */
    function iv_get_image_dimensions($url) {
      static $memo = array();
    
      $url = preg_replace('/[?#].*$/', '', $url); // ?ver= 等を除去
      if ($url === '') return false;
      if (array_key_exists($url, $memo)) return $memo[$url];
    
      $result = false;
      $path   = iv_url_to_path($url);
      if ($path !== '' && is_file($path)) {
        $info = @getimagesize($path);
        if ($info && !empty($info[0]) && !empty($info[1])) {
          $result = array((int) $info[0], (int) $info[1]);
        }
      }
    
      $memo[$url] = $result;
      return $result;
    }
    
    /**
     * URL をサーバー上のファイルパスへ解決。
     *
     * ※images/... のようなテーマ相対パスの物理位置は環境依存です。
     *   下のフィルタで環境に合わせて解決してください(未定義なら null):
     *
     *   add_filter('iv_aspect_url_to_path', function ($path, $url) {
     *     if (strpos($url, 'images/') === 0) {
     *       return get_theme_file_path('/parts/inqiCkqJ/' . $url); // 実際の格納先へ
     *     }
     *     return $path;
     *   }, 10, 2);
     */
    
    function iv_url_to_path($url) {
      $override = apply_filters('iv_aspect_url_to_path', null, $url);
      if ($override !== null) return $override;
    
      $host = parse_url(home_url(), PHP_URL_HOST);
    
      // 絶対URL / プロトコル相対
      if (preg_match('#^https?://#i', $url) || strpos($url, '//') === 0) {
        $u_host = parse_url($url, PHP_URL_HOST);
        if ($u_host && $host && strcasecmp($u_host, $host) !== 0) return ''; // 外部はスキップ
        $p = parse_url($url, PHP_URL_PATH);
        return $p ? ABSPATH . ltrim($p, '/') : '';
      }
      // ルート相対
      if (strpos($url, '/') === 0) {
        return ABSPATH . ltrim($url, '/');
      }
      // その他の相対(images/... 等)→ まずテーマ直下を試す
      $try = get_theme_file_path('/' . ltrim($url, '/'));
      return is_file($try) ? $try : '';
    }
    
    /** 指定属性値へ 1プロパティをマージ(既存の同プロパティは除去して付け直す/無ければ属性を新設) */
    function iv_set_prop_in_attr($tag, $attr, $prop, $value) {
      $decl = $prop . ':' . $value . ';';
      $pat  = '/b' . preg_quote($attr, '/') . 's*=s*(["'])(.*?)1/is';
    
      if (preg_match($pat, $tag, $mm)) {
        $full = $mm[0]; $q = $mm[1]; $val = $mm[2];
        $val = preg_replace('/s*' . preg_quote($prop, '/') . 's*:[^;]*;?/i', '', $val);
        $val = trim($val);
        if ($val !== '' && substr($val, -1) !== ';') $val .= ';';
        $val = ($val === '') ? $decl : $val . ' ' . $decl;
        return str_replace($full, $attr . '=' . $q . $val . $q, $tag);
      }
      // 属性が無い → class 直後、無ければ <img 直後
      if (preg_match('/bclasss*=s*(["']).*?1/is', $tag, $cm)) {
        return str_replace($cm[0], $cm[0] . ' ' . $attr . '="' . $decl . '"', $tag);
      }
      return preg_replace('/^<imgb/i', '<img ' . $attr . '="' . $decl . '"', $tag, 1);
    }
    
    /** 指定属性値から 1プロパティを除去(空になれば属性ごと削除) */
    function iv_remove_prop_in_attr($tag, $attr, $prop) {
      $pat = '/b' . preg_quote($attr, '/') . 's*=s*(["'])(.*?)1/is';
      if (!preg_match($pat, $tag, $mm)) return $tag;
    
      $full = $mm[0]; $q = $mm[1]; $val = $mm[2];
      $val = preg_replace('/s*' . preg_quote($prop, '/') . 's*:[^;]*;?/i', '', $val);
      $val = trim($val);
    
      if ($val === '') {
        // 属性ごと削除(前の空白も1つ削る)
        return preg_replace('/s*' . preg_quote($attr, '/') . 's*=s*(["']).*?1/is', '', $tag, 1);
      }
      return str_replace($full, $attr . '=' . $q . $val . $q, $tag);
    }

    これで、「画像を差し替える → 再保存する → 実測から比率が付き直す」という、メンテナンス不要のループが回り始めます。

    なお getimagesize() はSVGの寸法を返さないことが多いため、SVGは自動付与の対象外(据え置き)になります。SVGだけは viewBox から比率を出すなど、別途対応が必要です。


    まとめ

    • aspect-ratio幅か高さの一方が auto のときだけ効く。両方固定・stretch・%・img属性欠如が主な「効かない」原因。
    • 遅延読み込み時のCLS対策は、width/height属性aspect-ratio + height:auto で高さを予約するのが基本。lazyと寸法未指定の併用は最悪手。
    • <picture> のインライン aspect-ratio<source> 選択に追従しない。SP/PCで比率が違うならメディアクエリブレークポイント別の切り替えで対応する(Firefoxの<source>挙動にも注意)。
    • 画像差し替えが多い現場では、保存時に実測して自動付与し、既存のキャッシュ更新フローに相乗りさせると、手作業ゼロで縦横比を維持できる。

    「面倒だから使わない」で後回しにしていた aspect-ratio も、付与を自動化してしまえば、CLS対策として無理なく常用できるレベルになります。