CSSアンカーポジショニングでプルダウンメニューを作る方法

今月、Firefox 147 のリリースにより、すべてのモダンブラウザで CSSアンカーポジショニング が使用できるようになりました。今回はリストと CSSアンカーポジショニング でプルダウンメニューを作る方法を紹介します。

CSSアンカーポジショニングとは?

クリックしたりポインターを乗せることで表示されるポップアップやツールチップを CSS で実現するためには、これまで positionプロパティ を駆使して、基準となる祖先要素からの絶対配置で行ってきました。

.ancestor {
  position: relative; /* 祖先要素を基準にする */

  .descendant {
    /* 子孫要素を祖先要素の下に表示する */
    position: absolute;
      inset-block-start: 100%;
      inset-inline: 0;
  }
}

この従来の方法では、基準となる祖先要素が画面端に配置された際、絶対配置した子孫要素が意図せず画面外にはみ出してしまうことがありました。

この問題を回避するために、メディアクエリ (@media) で画面サイズごとに最適化したり、JavaScript で要素の位置やサイズを監視して再配置させるなどの工夫が必要でした。

select要素 のプルダウンメニューは表示されている位置に応じて展開する方向を自動で切り替えますが、それと同様の機能を実現してくれるのが CSSアンカーポジショニング です。

CSSアンカーポジショニング の CSSプロパティ を抜粋すると次のようなコードになります。

/* 祖先要素と子孫要素 */
.ancestor {
  anchor-name: --ancestor; /* アンカー要素 (基準となる要素) のアンカー名 */
  anchor-scope: --ancestor; /* アンカー名のスコープ (アンカー名の重複対策) */

  .descendant {
    position: fixed;
    position-anchor: --ancestor; /* 基準とするアンカー要素 */
    position-area: block-end center; /* 初期位置 */
      /*
      □□□
      □●□
      □■□
      */
    position-try-fallbacks: /* 代替位置 */
      block-start center, /* 候補1 */
        /*
        □■□
        □●□
        □□□
        */
      center inline-end, /* 候補2 */
        /*
        □□□
        □●■
        □□□
        */
      center inline-start; /* 候補3 */
        /*
        □□□
        ■●□
        □□□
        */
  }
}

/* 兄弟要素 */
.prev {
  anchor-name: --prev; /* アンカー要素 (基準となる要素) のアンカー名 */
}
.next {
  position: fixed;
  position-anchor: --prev; /* 基準とするアンカー要素 */
  position-area: block-end span-all; /* 初期位置 */
    /*
    □□□
    □●□
    ■■■
    */
  position-try-fallbacks: /* 代替位置 */
    block-end span-inline-end, /* 候補1 */
      /*
      □□□
      □●□
      □■■
      */
    block-end span-inline-start, /* 候補2 */
      /*
      □□□
      □●□
      ■■□
      */
    block-start span-all, /* 候補3 */
      /*
      ■■■
      □●□
      □□□
      */
    block-start span-inline-end, /* 候補4 */
      /*
      □■■
      □●□
      □□□
      */
    block-start span-inline-start, /* 候補5 */
      /*
      ■■□
      □●□
      □□□
      */
    span-block-end inline-end, /* 候補6 */
      /*
      □□□
      □●■
      □□■
      */
    span-block-end inline-start, /* 候補7 */
      /*
      □□□
      ■●□
      ■□□
      */
    span-block-start inline-end, /* 候補8 */
      /*
      □□■
      □●■
      □□□
      */
    span-block-start inline-start; /* 候補9 */
      /*
      ■□□
      ■●□
      □□□
      */
}

まずは「アンカー要素 (基準となる要素)」を設定します。anchor-nameプロパティ でアンカー要素のアンカー名、必要に応じて anchor-scope要素 でアンカー名のスコープ (アンカー名の使用範囲) を設定します。

次に「アンカー位置指定要素」を設定します。position-anchorプロパティ で基準とするアンカー要素、position-areaプロパティ でアンカー要素からの相対位置、position-try-fallbacksプロパティ で position-areaプロパティ の位置に表示できなかった場合の代替位置を設定します。

個人的に盲点だったのですが、アンカー位置指定要素には position: fixed; を設定します。position: absolute; を設定する例が MDN (2026年1月現在) などで見られますが、祖先要素に position: relative;contain: layout; などが設定してあると思ったように機能しませんでした。

アンカー要素と表示領域 (viewport) による計算よりも、祖先要素によるレイアウトの拘束が強く働くためだと思われます。この挙動は将来的には改善されるかもしれません。

従来の方法と同様、祖先要素と子孫要素の構造でも設定できますが、兄弟要素や離れている要素にも設定できます。

CSSアンカーポジショニングのプルダウンメニュー

今回作るプルダウンメニューは次のようなものです。通常は親ページのリンク下に中央揃えで配置されます。

親ページのリンク下に中央揃えでプルダウンメニューが表示される図

画面端で表示領域からはみ出しそうになると、中央揃えだったものが左揃えや右揃えになります。

親ページのリンク下に右揃えでプルダウンメニューが表示される図

表示領域の高さが不足すると、親ページのリンク横に上揃えで配置されます。

親ページのリンク右側に上揃えでプルダウンメニュー (プルサイドメニュー) が表示される図

HTMLコード はいたって普通の入れ子状になったリストです。

<nav class="_menu">
  <ul>
    <li><a href="#">Page 1</a>
      <ul>
        <li><a href="#">Child page 1-1</a>
          <ul>
            <li><a href="#">Grandchild page 1-1-1</a></li>
          </ul>
        </li>
      </ul>
    </li>
    <li><a href="#">Page 2</a>
      <ul>
        <li><a href="#">Child page 2-1</a>
          <ul>
            <li><a href="#">Grandchild page 2-1-1</a></li>
          </ul>
        </li>
        <li><a href="#">Child page 2-2</a>
          <ul>
            <li><a href="#">Grandchild page 2-2-1</a></li>
          </ul>
        </li>
      </ul>
    </li>
    <li><a href="#">Page 3</a>
      <ul>
        <li><a href="#">Child page 3-1</a>
          <ul>
            <li><a href="#">Grandchild page 3-1-1</a></li>
          </ul>
        </li>
        <li><a href="#">Child page 3-2</a>
          <ul>
            <li><a href="#">Grandchild page 3-2-1</a></li>
          </ul>
        </li>
        <li><a href="#">Child page 3-3</a>
          <ul>
            <li><a href="#">Grandchild page 3-3-1</a></li>
          </ul>
        </li>
      </ul>
    </li>
    <li><a href="#">Page 4</a>
      <ul>
        <li><a href="#">Child page 4-1</a>
          <ul>
            <li><a href="#">Grandchild page 4-1-1</a></li>
          </ul>
        </li>
        <li><a href="#">Child page 4-2</a>
          <ul>
            <li><a href="#">Grandchild page 4-2-1</a></li>
          </ul>
        </li>
        <li><a href="#">Child page 4-3</a>
          <ul>
            <li><a href="#">Grandchild page 4-3-1</a></li>
          </ul>
        </li>
        <li><a href="#">Child page 4-4</a>
          <ul>
            <li><a href="#">Grandchild page 4-4-1</a></li>
          </ul>
        </li>
      </ul>
    </li>
  </ul>
</nav>

CSSコード の方も CSSアンカーポジショニング の設定以外は割と普通です。従来の position: relative;position: absolute; を使用したプルダウンメニューに比べ、メディアクエリや JSコード が不要な分、コードの総量は少なくなるのかもしれません。

._menu {
  background: white;
  border-radius: 4px;
  box-shadow: 0 1px 4px -1px black;
  inline-size: fit-content;
  margin-inline: auto;

  ul {
    margin: 0;
    padding: 0;
  }
  li {
    display: block;
    margin: 0;
  }
  a {
    color: inherit;
    display: inline-block;
    padding-block: calc(0.5lh - 0.5em); /* 行間半分 (ハーフレディング) の余白 */
    text-decoration: none;
  }
  & > ul {
    display: flex;
    padding-block: calc(1lh - 1em); /* 行間分 (レディング) の余白 */
    padding-inline: 1em;

    & > li {
      anchor-name: --item;
      anchor-scope: --item; /* li要素 に同一のアンカー名を使用しているのでスコープは必須。 */
        /* 仮に anchor-scopeプロパティ を使用しない場合、anchor-nameプロパティ値 を重複させない必要がある。
        &:nth-child(1) { anchor-name: --item-1; & > ul { position-anchor: --item-1; } }
        &:nth-child(2) { anchor-name: --item-2; & > ul { position-anchor: --item-2; } }
        …
        */

      & > a {
        font-weight: bolder;
        padding-inline: 1em;
      }
      & > ul {
        --transition-time: calc(1s / 4);
        background: white;
        box-shadow: 0 2px 8px -2px black;
        border-radius: 4px;
        inline-size: fit-content;
          max-inline-size: min(320px, 100dvi * 3 / 4); /* 表示領域外にはみ出さないようサイズを設定。 */
        max-block-size: calc(100dvb * 3 / 4); /* 表示領域外にはみ出さないようサイズを設定。 */
        overflow-block: auto; /* max-block-size を超えたらスクロールバーを表示。 */
        overflow-inline: hidden;
        overscroll-behavior: contain; /* スクロールの連鎖を抑制。 */
        padding-block: calc(1lh - 1em);
        padding-inline: 2em;
        position: fixed;
        position-anchor: --item;
        position-area: block-end span-all; /* リンク下に中央揃え */
          /*
          □□□
          □●□
          ■■■
          */
        position-try-fallbacks:
            block-end span-inline-end, /* リンク下に左揃え */
              /*
              □□□
              □●□
              □■■
              */
            block-end span-inline-start, /* リンク下に右揃え */
              /*
              □□□
              □●□
              ■■□
              */
            span-block-end inline-end, /* リンク右側に上揃え */
              /*
              □□□
              □●■
              □□■
              */
            span-block-end inline-start; /* リンク左側に上揃え */
              /*
              □□□
              ■●□
              ■□□
              */
        transition: opacity var(--transition-time);

        a {
          position: relative;

          /* クリックできるエリアを広げる。 */
          &::after {
            content: "";
            position: absolute;
              inset-block: 0;
              inset-inline: -100dvmax;
          }
        }
        ul {
          margin-inline: 1em 0;
        }
      }
    }
    /* ホバー・フォーカスされていなければプルダウンメニューを非表示。フォーカスされていればホバーを無効化。 */
    &:not(:focus-within) > li:not(:hover) > ul,
    &:focus-within > li:not(:focus-within) > ul {
      opacity: 0;
      transition:
          opacity var(--transition-time), /* 徐々に消える。 */
          visibility 0s var(--transition-time); /* 消え終わるまで遅らせる。 */
      visibility: hidden;
    }
    /* ホバー・フォーカスされている li要素 とその祖先の a要素 以外は半透明。 */
    &:has(> :hover, > :focus-within) li:not(:hover, :focus-within) a {
      color: color-mix(in srgb, transparent, currentcolor 50%);
    }
  }
}

アンカー位置指定要素に position: fixed; を使用しているため、表示領域 (viewport) からはみ出しても、スクロールバーが表示されません。そのため、max-block-sizeプロパティ, max-inline-sizeプロパティ と overflow-block: auto; で表示領域をはみ出さないようにする必要があります。

また、ブラウザ毎に挙動が異なる部分が多々あります。今後、どのように挙動が統一されていくのか気になりますね。

CodePen

See the Pen CSS anchor positioning dropdown menu by nov (@numerofive) on CodePen.

この投稿のアイキャッチ画像に、CSSアンカーポジショニング のテストのために作成した、ドラゴンクエスト3のコマンド風プルダウンメニューのスクリーンショットを載せました。

アンカー位置指定要素の計算が高負荷なのか、普段使いしている Firefox が何度もクラッシュしました。CSSアンカーポジショニング を使用したプルダウンメニューを何重にも入れ子にするのは避けた方が良さそうです。

関連記事