前回の記事では、アンカーの定義や位置の指定の種類、スコープの定義、アンカーサイズの使用、フォールバックの指定など、Anchor Positioning の基本的な仕組みを紹介しました。

今回は実践編として、いくつか実例を取り上げながら Anchor Positioning の活用方法や、CSS の新たな表現の可能性を考えていきます。

本記事で紹介する、Anchor Positioning の機能は、W3C の仕様ではおもに「CSS Anchor Positioning Module Level 1」に属しており、草案(Working Draft)段階のため、今後、実装方法や機能に変更が発生する可能性があります。

1. ドロップダウンメニュー

見出し「1. ドロップダウンメニュー」

まずは、ドロップダウンメニューの UI です。「File」などのメニュー項目を選択すると、子階層が表示されます。

Live Demo

ここでは、Popover API を使用して、暗黙的なアンカーを生成しています。説明のため簡略化していますが、以下に該当するコードを抜粋します。

以下のコードでは、メニューバーにおける適切な意味づけ(セマンティクス)や機能が十分に実装されていません。実際には、「Menu and Menubar Pattern | W3C」などを参考にした実装が望まれます。

ドロップダウンメニューの例
<!-- HTML -->
<ul class="menubar">
  <li>
    <button type="button" popovertarget="menu-file">File</button>
    <ul id="menu-file" class="menu" popover>
      <li><button type="button">New</button></li>
      <li>
        <button type="button" popovertarget="menu-open">Open</button>
        <ul id="menu-open" class="menu" popover>
          <li><button type="button">foo.html</button></li>
          <li><button type="button">bar.css</button></li>
          <li><button type="button">baz.js</button></li>
        </ul>
      </li>
      <li><button type="button">Save</button></li>
    </ul>
  </li>
  <!-- ... -->
</ul>

<!-- CSS -->
<style>
.menubar {
  display: grid;
  justify-items: center;
}
.menu {
  position-anchor: auto; /* for Firefox */
  inset-block-start: calc(anchor(outside) + 4px);
  inset-inline-start: anchor(inside);
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 4px;
  border: 1px solid currentColor;

  &:not(:popover-open) {
    display: none;
  }
  .menu & {
    inset-block-start: anchor(inside);
    inset-inline-start: anchor(outside);
  }
}
.menu button {
  &:hover {
    background-color: hsl(180 76% 11%);
    color: white;
  }
}

@layer reset {
  [popover] {
    overflow: visible;
    border: none;
  }
  ul {
    margin: 0;
    padding: 0;
    list-style-type: none;
  }
  button {
    border: none;
    background: none;
    font-size: 100%;
    cursor: pointer;
  }
}
</style>

popovertarget 属性を指定した要素がアンカー要素になり、popover 属性を指定した要素と接続することが可能です。上記のデモでは、以下のように anchor() 関数を使用して位置を指定しています。

ドロップダウンメニューの配置
.menu {
  inset-block-start: calc(anchor(outside) + 4px);
  inset-inline-start: anchor(inside);
  /* ... */

  .menu & {
    inset-block-start: anchor(inside);
    inset-inline-start: anchor(outside);
  }
}

Popover API の注意点として、popover 属性を指定した要素(ポップオーバー)は、トップレイヤーに配置されるため、基準がビューポートになるということです。

たとえば、ポップオーバーの祖先要素に、overflow: clip などを指定してもクリッピングできません。また、position-try-fallbacks プロパティの判定基準もビューポートになります。

ちなみに、Firefox において、暗黙的なアンカーに anchor() 関数を使用しただけでは要素が正しい位置に配置されませんでしたが、position-anchor: auto を指定することで解消できます。この現象は position-area プロパティを使用したときには発生しません。

続いて、ツールチップの UI です。HTML における title 属性のように、対象要素にホバーもしくはフォーカスすると補足的な情報が表示されます。

Live Demo
Alignment

以下に、HTML と CSS のコードを抜粋します。

ツールチップの例
<!-- HTML -->
<div class="toolbar">
  <custom-tooltip class="toolbar-item">
    <button type="button" class="toolbar-btn" data-toolbar="btn" aria-labelledby="align-left">
      <svg width="40" height="40" viewBox="0 0 40 40">
        <rect x="9" y="8" width="2" height="24"/>
        <rect x="13" y="14" width="20" height="4"/>
        <rect x="13" y="22" width="12" height="4"/>
      </svg>
    </button>
    <p id="align-left" class="toolbar-tip" aria-hidden="true">Align left</p>
  </custom-tooltip>
  <!-- ... -->
</div>

<!-- CSS -->
<style>
.toolbar {
  display: flex;
  gap: 4px;
  inline-size: fit-content;
  margin-inline: auto;
}
.toolbar-item {
  anchor-scope: all;
}
.toolbar-btn {
  anchor-name: --toolbar-btn;
  display: block;
}
.toolbar-tip {
  position: absolute;
  position-anchor: --toolbar-btn;
  position-try-fallbacks: flip-block;
  inset-block-start: calc(anchor(outside) + 8px);
  justify-self: anchor-center;
  margin: 0;
  padding-block: 4px;
  padding-inline: 8px;
  border-radius: 4px;
  background-color: black;
  color: white;

  &[aria-hidden="true"] {
    display: none;
  }
}
</style>

まず、.toolbar-btn に anchor-name: --toolbar-btn を指定して、アンカー要素として定義しています。そして、.toolbar-tip に anchor() を指定して、アンカーと結合しています。また、アンカー要素は複数あるため、anchor-scope プロパティでスコープを定義しています。

アンカーの指定
.toolbar-item {
  anchor-scope: all;
}
.toolbar-btn {
  anchor-name: --toolbar-btn;
  /* ... */
}
.toolbar-tip {
  position: absolute;
  position-anchor: --toolbar-btn;
  position-try-fallbacks: flip-block;
  inset-block-start: calc(anchor(outside) + 8px);
  justify-self: anchor-center;
  /* ... */
}

ここから、ボタンにホバーもしくはフォーカスしたときにツールチップが表示されるように、JS で Web Components(Light DOM)を定義して完成です。

ツールチップのカスタム要素を JS で定義
<!-- JS -->
<script>
class Tooltip extends HTMLElement {
  #controller = new AbortController();

  constructor() {
    super();
  }

  connectedCallback() {
    const { signal } = this.#controller;

    const btn = this.querySelector('[data-toolbar="btn"]');
    const tooltip = this.querySelector(`#${btn?.getAttribute('aria-labelledby')}`);

    if (btn && tooltip) {
      btn.addEventListener('mouseover', () => {
        this.toggleTooltip(tooltip, true);
      }, { signal });

      btn.addEventListener('mouseout', () => {
        this.toggleTooltip(tooltip, false);
      }, { signal });

      btn.addEventListener('focus', () => {
        this.toggleTooltip(tooltip, true);
      }, { signal });

      btn.addEventListener('blur', () => {
        this.toggleTooltip(tooltip, false);
      }, { signal });
    }
  }

  disconnectedCallback() {
    this.#controller.abort();
  }

  // ツールチップを表示・非表示
  toggleTooltip = (tooltip, isShow) => {
    if (!tooltip) return;
    tooltip.setAttribute('aria-hidden', (isShow ? 'false' : 'true'));
  };
}

if (!customElements.get('custom-tooltip')) {
  customElements.define('custom-tooltip', Tooltip);
}
</script>

以前に、:has() 擬似クラスに関する記事で紹介した、ナビゲーションのリンクにホバーしたときに下線のスタイルが追従スタイルの改良版です。

上記の記事では、ナビ項目の幅が均等であることが前提条件でしたが、Anchor Positioning を使用することで任意の幅でも対応可能になります。

以下のデモでは、grid-template-columns のセレクトメニューを切り替えると、グリッドのスタイルが変更されます。各レイアウトで、カーソルに追従する要素の変化を確認してみてください。

Live Demo

このように、グリッドのレイアウトが変わっても、ナビ項目の横幅や位置にあわせて柔軟に変化させることが可能になります。

追従するホバー効果の例
<!-- HTML -->
<nav class="nav">
  <ul class="nav-list">
    <li><a href="/" aria-current="page">Home</a></li>
    <li><a href="/services/">Services</a></li>
    <li><a href="/projects/">Projects</a></li>
    <li><a href="/blog/">Blog</a></li>
    <li><a href="/contact/">Contact</a></li>
  </ul>
  <span class="nav-chaser"></span>
</nav>

<!-- CSS -->
<style>
.nav {
  position: relative;
  overflow-x: auto;
}
.nav-list {
  display: inline-grid;
  grid-template-columns: repeat(5, auto);
  list-style-type: none;
}
.nav-list a {
  display: block;
  padding: 20px;
  color: currentColor;
  text-align: center;
  text-transform: uppercase;
  text-decoration: none;
  cursor: pointer;
}

/* 追従する要素 */
.nav {
  --nav-target: --nav-current;
}
.nav-list a[aria-current] {
  anchor-name: --nav-current;
}
.nav-chaser {
  position: absolute;
  position-anchor: var(--nav-target);
  inset-inline: anchor(inside);
  inset-block-end: anchor(inside);
  display: block;
  block-size: 4px;
  background-color: teal;
  transition: inset 0.4s ease;
  pointer-events: none;
}
.nav:has(.nav-list a:is(:hover, :focus-visible)) {
  --nav-target: --nav-item;
}
.nav-list a:is(:hover, :focus-visible) {
  anchor-name: --nav-item;
}
</style>

まず、初期状態で、カスタムプロパティ --nav-target に、aria-current 属性を持った要素のアンカー名である --nav-current を指定します。

追従する要素には、position-anchor: var(--nav-target) を指定します。これによって、初期状態に接続されるアンカー要素は、aria-current 属性を持った要素に指定した --nav-current になります。

初期状態では anchor-name: --nav-current
.nav {
  --nav-target: --nav-current;
}
.nav-list a[aria-current] {
  anchor-name: --nav-current;
}
.nav-chaser {
  position: absolute;
  position-anchor: var(--nav-target);
  inset-inline: anchor(inside);
  inset-block-end: anchor(inside);
  /* ... */
}

そして、リンクにホバーやフォーカスしたときには、アンカー名 --nav-item を指定し、カスタムプロパティ --nav-target の値をそのアンカー名に変更します。ここでは、祖先要素 .nav のスタイルを変更するため :has() 擬似クラスを使用しています。

ホバー、フォーカス時に anchor-name を変更する
.nav-list a:is(:hover, :focus-visible) {
  anchor-name: --nav-item;
}
.nav:has(a:is(:hover, :focus-visible)) {
  --nav-target: --nav-item;
}

このように、カスタムプロパティとアンカー名で、2 つのハイフン -- で始まる識別子(<dashed-ident>)が混在しているためまぎらわしいことが難点ですが、この方法で追従する要素の実装が可能になります。

ただ、2026 年 8 月現在では、Firefox ではアニメーションが有効にならないので、アニメーションが欠かせない場合、結局のところ JS による実装に頼らざるを得ないのかもしれません。

スクロールコンテナを包含ブロックにする

見出し「スクロールコンテナを包含ブロックにする」

さきほどの例では、.nav に対して position: relative を指定していました。その理由として、この要素に対して overflow-x: auto を指定しており、領域に収まらないときに水平方向のスクロールを許可しているためです。

ナビゲーション要素のスタイル
.nav {
  /* ... */
  position: relative;
  overflow-x: auto;
}

このように、Anchor Positioning で絶対配置する要素であったとしても、祖先要素がスクロールコンテナの場合には、包含ブロック(Containing Block)として定義する必要があります。

以下は、スクロールコンテナが包含ブロックではない場合(position: static)のデモです。ホバー時にアンカー要素に追従しますが、スクロールすると位置が変わってしまったり、追従要素がスクロールコンテナを突き抜けてしまいます。

Live Demo

なお、position: relative 以外にも、contain: content や scale: 1 といった形でも包含ブロックを定義できます。

裏を返すと、祖先要素に contain: content や scale: 1.5 などを指定していると、その要素が包含ブロックとなります。

カード UI では、リンクをコンポーネント全体に拡張する手法がよく使われます。

一般的には、カードを起点とするため、コンテナ要素に position: relative を指定して包含ブロックとして定義し、その内側に配置されたリンク要素の擬似要素に対して position: absolute を指定して、リンクの領域を広げます。

この手法はコンテナ要素とリンク要素の擬似要素の間に、別の包含ブロックを置けないという制約が存在しますが、Anchor Positioning を使用することで、この制約を回避できます。

以下のデモでは、リンク要素(「More」1)の上に「Widely」や「Newly」といったアイコンが絶対配置されていますが、リンク要素には position: relative を指定していません。つまり、リンクは包含ブロックではありません。

Live Demo

contain-intrinsic-size

MoreWidely

Popover

MoreNewly

Web app manifest

MoreLimited

このように、拡張リンクの途中に、別の絶対配置の要素が存在する場合でも、HTML の構造を維持したまま実装することが可能です。以下にコードを抜粋します。

拡張リンクのコードの例
<!-- HTML -->
<div class="card-item">
  <figure class="card-image">
    <!-- ... -->
  </figure>
  <p class="card-desc">
    contain-intrinsic-size
  </p>
  <a href="#" class="card-link">
    More
    <span class="card-icon">Widely</span>
  </a>
</div>

<!-- CSS -->
<style>
/* アンカー要素(カード) */
.card-item {
  anchor-name: --card-item;
  anchor-scope: all;
  padding: 16px;
  border: 1px solid black;
  border-radius: 8px;
  max-inline-size: 200px;
}

/* アンカー要素(リンク) */
.card-link {
  anchor-name: --card-link;
  display: block;
  padding-block: 4px;
  border-radius: 100vmax;
  background-color: hsl(180 100% 12%);
  color: white;
  text-align: center;
  text-decoration: none;

  &:hover {
    background-color: hsl(180 100% 20%);
  }
}

/* 拡張リンク */
.card-link::after {
  content: '';
  position: absolute;
  position-anchor: --card-item;
  position-area: center;
  place-self: stretch;
  display: block;
}

/* 画像 */
.card-image {
  inline-size: 100%;
  aspect-ratio: 1;
  margin: 0;
  object-fit: cover;
  background-color: hsl(138 69% 38%);
}

/* アイコン */
.card-icon {
  position: absolute;
  position-anchor: --card-link;
  inset-block-start: calc(anchor(inside) - 1em);
  inset-inline-end: calc(anchor(inside) - 8px);
  padding-inline: 8px;
  border: 2px solid hsl(138 69% 38%);
  border-radius: 100vmax;
  background-color: white;
  color: black;
}
</style>

ここでは、カードとリンクの 2 つをアンカー要素として定義しています。拡張リンクはカード要素に接続し、アイコンはリンク要素に接続しています。

拡張リンクとアイコンで別のアンカー要素に接続させる
/* アンカー要素(カード) */
.card-item {
  anchor-name: --card-item;
  anchor-scope: all;
  /* ... */
}

/* アンカー要素(リンク) */
.card-link {
  anchor-name: --card-link;
  /* ... */
}

/* 拡張リンク */
.card-link::after {
  content: '';
  position: absolute;
  position-anchor: --card-item;
  position-area: center;
  place-self: stretch;
  /* ... */
}

/* アイコン */
.card-icon {
  position: absolute;
  position-anchor: --card-link;
  inset-block-start: calc(anchor(inside) - 1em);
  inset-inline-end: calc(anchor(inside) - 8px);
  /* ... */
}

留意点として、Anchor Positioning をサポートしていない環境では、拡張リンクによる表示崩れの影響が大きいので、@supports 規則で調整しておくのが無難です。

拡張リンクの @supports 規則の例
@supports not (anchor-name: --card-item) {
  .card-link {
    position: relative;
  }
  .card-link::after {
    display: none;
  }
  .card-icon {
    inset-block-start: -1em;
    inset-inline-end: -8px;
  }
}

前回の記事で作成したタイムテーブルのデモを改良します。

前回は、時間軸を 30 分ごとに区切って、要素を配置していました。今回は、時間軸を 60 分ごとに区切って構造をシンプルにし、その一方で、分単位で細かく指定できるようにします。

Live Demo

Research Stage

Design Stage

Dev Stage

10:00

11:00

12:00

13:00

14:00

15:00

[10:00–11:10]AEIOU

[11:10–12:40]Contextual Inquiry

[11:50–13:20]Horror Vacui

[13:20–14:50]Ockhams Razor

[13:50–16:00]Progressive Enhancement

以下に該当するコードを抜粋します。

タイムテーブルのコードの抜粋
<!-- HTML -->
<div class="timetable">

  <!-- アンカー要素(水平方向) -->
  <div class="timetable-guide"></div>
  <p id="stage-research" class="timetable-guide --stage" style="anchor-name: --stage-research;">Research Stage</p>
  <p id="stage-design" class="timetable-guide --stage" style="anchor-name: --stage-design;">Design Stage</p>
  <p id="stage-dev" class="timetable-guide --stage" style="anchor-name: --stage-dev;">Dev Stage</p>

  <!-- アンカー要素(垂直方向) -->
  <p class="timetable-guide --time" style="anchor-name: --time-10;">10:00</p>
  <p class="timetable-guide --time" style="anchor-name: --time-11;">11:00</p>
  <p class="timetable-guide --time" style="anchor-name: --time-12;">12:00</p>
  <p class="timetable-guide --time" style="anchor-name: --time-13;">13:00</p>
  <p class="timetable-guide --time" style="anchor-name: --time-14;">14:00</p>
  <p class="timetable-guide --time" style="anchor-name: --time-15;">15:00</p>
  
  <!-- 配置要素 -->
  <p
    class="timetable-item"
    aria-describedby="stage-design"
    style="
      inset-inline: anchor(--stage-design inside);
      inset-block-start: calc(anchor(--time-13 start) + anchor-size(--time-13 block) * calc(20 / 60));
      inset-block-end: calc(anchor(--time-14 start) - anchor-size(--time-14 block) * calc(50 / 60) + 2px);
    "
  >
    <span class="timetable-time">[13:20–14:50]</span>
    <span class="timetable-name">Ockhams Razor</span>
  </p>
  <!-- ... -->
</div>

<!-- CSS -->
<style>
.timetable {
  position: relative;
  overflow-x: auto;
  display: grid;
  grid-template-columns: auto repeat(3, minmax(8em, 1fr));
  gap: 2px;
  max-inline-size: 600px;
  padding: 2px;
  color: white;
  background-color: hsl(180 14% 56%);
  text-align: center;
}
.timetable-guide {
  padding: 8px;
  background: hsl(180 20% 7%);
}
.timetable-guide.--time {
  grid-column: 1;
  min-block-size: 5em;
}
.timetable-item {
  position: absolute;
  display: flex;
  flex-direction: column;
  gap: 4px;
  justify-content: center;
  padding: 4px;
  background-color: hsl(180 100% 12%);
}

@layer reset {
  p {
    margin: 0;
  }
}
</style>

ここでのポイントは、配置要素の style 属性で指定している、inset-block-start と inset-block-end の 2 つのプロパティです。calc(20 / 60) は 20 分、calc(50 / 60) は 50 分を意味します。

タイムテーブルに要素を配置する style 指定の例
<p
  class="timetable-item"
  aria-describedby="stage-design"
  style="
    inset-inline: anchor(--stage-design inside);
    inset-block-start: calc(anchor(--time-13 start) + anchor-size(--time-13 block) * calc(20 / 60));
    inset-block-end: calc(anchor(--time-14 start) - anchor-size(--time-14 block) * calc(50 / 60) + 2px);
  "
>
  <span class="timetable-time">[13:20–14:50]</span>
  <span class="timetable-name">Ockhams Razor</span>
</p>

なお、ここではわかりやすく説明するために calc() 関数を使用していますが、実際のデモではテンプレート側(Astro)で計算済みの数値を指定しています。

以上で、グリッドの構成はシンプルになり、分単位での指定が可能になりました。

6. カルーセル(スライダー)

見出し「6. カルーセル(スライダー)」

続いて、シンプルな機能のカルーセル(スライダー)のデモです。左右に配置されたボタンを選択すると、前後のスライドに移動します。

Live Demo
Array findLast() and findLastIndex()
ToggleEvent source
moveBefore()

このデザインの特徴として、スライドの下にキャプションが配置されています。そのため、テキストの長さによって行数が変わり、スライドの高さが常に一定ではありません。

この状態で、左右のボタンを画像に対して上下センターに置きたい場合、従来の position: relative などを使用した方法では直感的に配置することができず、何らかの工夫が必要でしたが、Anchor Positioning を使用することで、よりシンプルにボタンを配置できます。

スライダーの機能(JS)は割愛しますが、以下に HTML と CSS のコードを抜粋します。

スライドの上下センターにボタンを配置する例
<!-- HTML -->
<image-slider class="slider">
  <!-- スライダー -->
  <div class="slider-track">
    <figure class="slider-item">
      <div class="slider-item-image"></div>
      <figcaption class="slider-caption">Array findLast() and findLastIndex()</figcaption>
    </figure>
    <!-- ... -->
  </div>

  <!-- 前後ボタン -->
  <button type="button" class="slider-btn --prev" aria-label="前のスライド">
    <svg width="8" height="10" viewBox="0 0 8 10" class="slider-btn-svg">
      <path d="M5.5,2 L1.5,5 L5.5,8" />
    </svg>
  </button>
  <button type="button" class="slider-btn --next" aria-label="次のスライド">
    <svg width="8" height="10" viewBox="0 0 8 10" class="slider-btn-svg">
      <path d="M2.5,2 L6.5,5 L2.5,8" />
    </svg>
  </button>
</image-slider>

<!-- CSS -->
<style>
/* スライダー */
.slider {
  anchor-scope: all;
  display: block;
  max-inline-size: 200px;
  margin-inline: auto;
}
.slider-track {
  anchor-name: --slider-track;
  display: flex;
  overflow-x: auto;
  scrollbar-width: none;
  scroll-snap-type: inline mandatory;
  scroll-behavior: smooth;
}
.slider-item {
  flex: 0 0 100%;
  margin: 0;
  margin-inline-start: 4px;
  scroll-snap-align: start;

  &:first-child {
    margin-inline-start: 0;
  }
}
.slider-item-image {
  anchor-name: --slider-image;
  inline-size: 100%;
  block-size: auto;
  aspect-ratio: 1;
  object-fit: cover;
  background-color: #1ea446;
}
.slider-caption {
  margin-block-start: 8px;
}

/* ボタン */
.slider-btn {
  position: absolute;
  inset-block: anchor(--slider-image inside);
  align-self: anchor-center;
  display: grid;
  place-items: center;
  inline-size: 50px;
  aspect-ratio: 1;
  padding: 4px;
  border: 2px solid currentColor;
  border-radius: 50%;
  background: none;
  cursor: pointer;

  &.--prev {
    inset-inline-end: calc(anchor(--slider-track outside) + 20px);
  }
  &.--next {
    inset-inline-start: calc(anchor(--slider-track outside) + 20px);
  }
}
.slider-btn-svg {
  inline-size: 18px;
  block-size: auto;
}
.slider-btn-svg path {
  fill: none;
  stroke: currentColor;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 1px;
}
</style>

ここでのポイントは、水平方向の --slider-track と、垂直方向の --slider-image という、ボタンの配置の基準となるアンカーを 2 つ指定していることです。

2 つのアンカー要素
.slider-track {
  anchor-name: --slider-track;
}
.slider-item-image {
  anchor-name: --slider-image;
}

このとき、画像(.slider-item-image)が複数存在するため、アンカー名 --slider-image が重複しますが、最後に出現する画像がアンカー要素の対象となります。

そして、この 2 つのアンカー要素に対して前後ボタンを配置します。

前後ボタンの配置
.slider-btn {
  position: absolute;
  inset-block: anchor(--slider-image inside);
  align-self: anchor-center;
  /* ... */

  &.--prev {
    inset-inline-end: calc(anchor(--slider-track outside) + 20px);
  }
  &.--next {
    inset-inline-start: calc(anchor(--slider-track outside) + 20px);
  }
}

なお、ページ内にスライダーが複数存在すると、アンカー名が競合してしまうので、コンポーネントのルートとなる要素に anchor-scope を指定しています。

アンカースコープの定義
.slider {
  anchor-scope: all;
}

ガイド付きツアーのデモです。「START」ボタンを選択するとツアーが開始されます。

Live Demo

Lorem ipsum, dolor sit amet consectetur adipisicing elit. Sequi, facilis accusamus itaque fugit quos officia minima eaque exercitationem sit nostrum 🫥 vero dolorum quaerat accusantium. Qui perferendis doloremque sequi reiciendis quisquam.

ここでは、対象となる要素を囲うスポットライトの要素と、説明テキストを表示するポップオーバーを配置するために、Anchor Positioning を使用しています。

簡略化したコードを以下に抜粋します。JS はガイドのステップを進めるための最小限の機能だけを掲載しています。

ガイド付きツアーの例
<!-- HTML -->
<div class="container" data-tour>
  <!-- コンテンツ -->
  <div class="logo" style="anchor-name: --tour-1;"></div>
  <p style="anchor-name: --tour-2;">
    Lorem ipsum, dolor sit amet consectetur adipisicing elit.
    Sequi, facilis accusamus itaque fugit quos officia minima eaque exercitationem sit nostrum
    <span style="anchor-name: --tour-3;">🫥</span> vero dolorum quaerat accusantium.
    Qui perferendis doloremque sequi reiciendis quisquam.
  </p>

  <!-- スポットライト -->
  <div class="tour-guide"></div>

  <!-- 説明テキスト -->
  <div class="tour-guide-text">
    <p>ガイドテキスト</p>
    <button type="button" class="tour-guide-btn" data-tour-btn>Next</button>
  </div>
</div>

<!-- CSS -->
<style>
/* コンテンツ */
.container {
  --tour-item: --tour-1;

  max-inline-size: 600px;
  padding: 40px;
}
.logo {
  block-size: 20px;
  aspect-ratio: 3 / 1;
  margin-block-end: 20px;
  background-color: hsl(138 69% 38%);
}

/* スポットライト */
.tour-guide {
  anchor-name: --tour-guide;
  position: absolute;
  position-anchor: var(--tour-item);
  inset: calc(anchor(inside) - 4px);
  border-radius: 8px;
  box-shadow: 0 0 0 200vmax hsl(0 0% 0% / 80%);
}

/* 説明テキスト */
.tour-guide-text {
  position: absolute;
  position-anchor: --tour-guide;
  position-area: end span-end;
  position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, end span-all;
  inline-size: 12em;
  margin-block: 8px;
  padding: 8px;
  border-radius: 8px;
  background-color: white;
}
.tour-guide-btn {
  display: block;
  margin-inline-start: auto;
}

@layer reset {
  p {
    margin: 0;
  }
}
</style>

<!-- JS -->
<script>
const tour = document.querySelector('[data-tour]');
const btn = document.querySelector('[data-tour-btn]');

if (tour && btn) {
  const max = 3;
  let count = 1;

  btn.addEventListener('click', () => {
    count = (count >= max) ? 1 : count + 1;
    tour.style.setProperty('--tour-item', `--tour-${count}`);
  });
}
</script>

まず、ガイドを表示したいコンテンツに anchor-name を指定しておきます。

コンテンツに anchor-name を指定する
<!-- コンテンツ -->
<div class="logo" style="anchor-name: --tour-1;"></div>
<p style="anchor-name: --tour-2;">
  <!-- ... -->
  <span style="anchor-name: --tour-3;">🫥</span>
  <!-- ... -->
</p>

続いて、スポットライトの要素を配置していきます。この要素は、先ほど指定した anchor-name を基準としますが、ステップごとに切り替える必要があるため、カスタムプロパティ --tour-item を経由します。まずは、初期値として --tour-1 を指定しています。

スポットライトを配置する
/* コンテンツ */
.container {
  --tour-item: --tour-1;
  /* ... */
}

/* スポットライト */
.tour-guide {
  /* ... */
  position: absolute;
  position-anchor: var(--tour-item);
  inset: calc(anchor(inside) - 4px);
  border-radius: 8px;
  box-shadow: 0 0 0 200vmax hsl(0 0% 0% / 80%);
}

これで、anchor-name: --tour-1 に接続されるようになりました。なお、スポットライトの外側の黒背景(モーダル状態)は box-shadow プロパティで表現しています。

続いて、スポットライトをアンカー要素として指定しておきます。そして、その位置を基準に説明テキストを配置します。さらに、説明テキストには position-try-fallbacks プロパティを使用して、領域に収まらないときのフォールバックを指定します2。

説明テキストを配置する
/* スポットライト */
.tour-guide {
  anchor-name: --tour-guide;
  /* ... */
}

/* 説明テキスト */
.tour-guide-text {
  position: absolute;
  position-anchor: --tour-guide;
  position-area: end span-end;
  position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, end span-all;
  /* ... */
}

あとは JS 側で、「Next」ボタンを選択するごとに、カスタムプロパティ --tour-item の値を切り替えていきます。

カスタムプロパティ --tour-item の値を切り替える
const tour = document.querySelector('[data-tour]');
const btn = document.querySelector('[data-tour-btn]');

if (tour && btn) {
  const max = 3;
  let count = 1;

  btn.addEventListener('click', () => {
    count = (count >= max) ? 1 : count + 1;
    tour.style.setProperty('--tour-item', `--tour-${count}`);
  });
}

ギターのコード(メジャーコードのみ)を表示するデモです。各コードを選択すると、押さえる弦(押弦、おうげん)の位置が切り替わります。

Live Demo

詳細な(HTML や CSS の)コードは割愛しますが、考え方はタイムテーブルのデモと同じです。

まず、CSS でグリッドを生成してレイアウトを整え、水平方向にフレットの要素、垂直方向に弦の要素を配置し、固有の anchor-name を指定します。

フレットと弦に anchor-name を指定する
<!-- フレット(水平方向) -->
<div style="anchor-name: --fret-1;"></div>
<div style="anchor-name: --fret-2;"></div>
<!-- ... -->
<div style="anchor-name: --fret-8;"></div>

<!-- 弦(垂直方向) -->
<div style="anchor-name: --string-1;"></div>
<div style="anchor-name: --string-2;"></div>
<!-- ... -->
<div style="anchor-name: --string-6;"></div>

そして、6 つの弦それぞれに配置する要素を用意し、ギターのコードに応じて押弦のマークの位置を変えています。フレットの外に置かれる開放弦とミュート弦のマークは、Container Style Queries(@container style())で切り替えます。

押弦・開放弦・ミュート弦のマークを配置する
<!-- HTML -->
<div data-chords="c">
  <!-- 押弦 -->
  <div class="chords-press --press-1"><div class="chords-press-item"></div></div>
  <div class="chords-press --press-2"><div class="chords-press-item"></div></div>
  <div class="chords-press --press-3"><div class="chords-press-item"></div></div>
  <div class="chords-press --press-4"><div class="chords-press-item"></div></div>
  <div class="chords-press --press-5"><div class="chords-press-item"></div></div>
  <div class="chords-press --press-6"><div class="chords-press-item"></div></div>
</div>

<!-- CSS -->
<style>
/* 押弦の基本スタイル */
.chords-press {
  --size: 12px;
  --press: open;

  &.--press-1 { --string: --string-1; }
  &.--press-2 { --string: --string-2; }
  &.--press-3 { --string: --string-3; }
  &.--press-4 { --string: --string-4; }
  &.--press-5 { --string: --string-5; }
  &.--press-6 { --string: --string-6; }
}
.chords-press-item {
  position: absolute;
  inset-block-start: calc(anchor(var(--string) inside) - var(--size) / 2);
  inset-inline-start: calc(anchor(var(--press) center) - var(--size) / 2);
  inline-size: var(--size);
  aspect-ratio: 1;
  border: 1px solid white;
  border-radius: 50%;
  background-color: white;
}

/* 開放弦 */
@container style(--press: open) {
  .chords-press-item {
    background-color: transparent;
  }
}

/* ミュート弦 */
@container style(--press: mute) {
  .chords-press-item {
    background-image: url('icon-mute.svg');
    background-size: contain;
    background-color: transparent;
    border: none;
  }
}
</style>

あとは、ギターのコードごとのスタイルを用意して完成です。祖先要素に指定している data-chords 属性の値によって、スタイルを切り替えています。

コードごとのスタイルの例
/* C コード */
[data-chords="c"] .chords-press {
  &.--press-1 { --press: open; }
  &.--press-2 { --press: --fret-1; }
  &.--press-3 { --press: open; }
  &.--press-4 { --press: --fret-2; }
  &.--press-5 { --press: --fret-3; }
  &.--press-6 { --press: mute; }
}

/* D コード */
[data-chords="d"] .chords-press {
  &.--press-1 { --press: --fret-2; }
  &.--press-2 { --press: --fret-3; }
  &.--press-3 { --press: --fret-2; }
  &.--press-4 { --press: open; }
  &.--press-5 { --press: mute; }
  &.--press-6 { --press: mute; }
}
/* ... */

Container Style Queries と組み合わせることで、より宣言的な指定が可能になりました。

ゴールにちょうどの目で止まることを目指す、簡易的なすごろくゲームです。

「ROLL」ボタンを選択すると、サイコロを振ることができます。「PLAYER SELECT」の項目では、プレイヤーの見た目を変更できます。ゴールにちょうど止まるとクリアですが、ゴールを通り過ぎた回数によってクリア条件が変わります。

Live Demo
1
2
3
4
5
6
7
GOAL!
😀

Perfect!

😀🥳😹🥷🐲🐙⛵️🪂🪇🥁🍙🍔🍩🃏🛸👾🎃👻😈🤖

Player Select

ゲームの制御は JS によるものですが、コマが移動するときの位置の指定および、プレイヤー選択のスタイルに Anchor Positioning を使用しています。

ボード上のコマの配置については、ここまでのデモの応用ですが、すごろくのマスを CSS のグリッドで生成し、マスごとに anchor-name を指定しています。

すごろくのマスに anchor-name を指定
<div class="sugoroku-step" style="anchor-name: --step-1;">1</div>
<div class="sugoroku-step" style="anchor-name: --step-2;">2</div>
<div class="sugoroku-step" style="anchor-name: --step-3;">3</div>
<div class="sugoroku-step" style="anchor-name: --step-4;">4</div>
<div class="sugoroku-step" style="anchor-name: --step-5;">5</div>
<div class="sugoroku-step" style="anchor-name: --step-6;">6</div>
<div class="sugoroku-step" style="anchor-name: --step-7;">7</div>
<div class="sugoroku-step" style="anchor-name: --step-8;">GOAL!</div>

あとは、出た目に応じてコマに position-anchor を指定して、該当のマスに移動させます。

出た目に応じてコマに position-anchor を指定
<div class="sugoroku-player" style="position-anchor: --step-3;">😀</div>

プレイヤー選択時のスタイルも、追従する要素のデモの応用ですが、ホバー時には変化させないので、カスタムプロパティを経由する必要がなく、よりシンプルなコードになっています。ここでは、選択されている要素(input:checked)にアンカー名を指定しています。

プレイヤー選択のスタイルの抜粋
.sugoroku-player-radio-label:has(input:checked) {
  anchor-name: --checked-player;
}
.sugoroku-player-radio-chaser {
  position: absolute;
  position-anchor: --checked-player;
  inset-block: anchor(inside);
  inset-inline: anchor(inside);
  /* ... */
}

ちなみに、アニメーションには View Transition API を多用しています。そのため、環境によって描画上の不具合が発生することがあるかもしれません。

この記事では、Anchor Positioning の典型例である、ドロップダウンメニューやツールチップから、少し特殊なレイアウトまで、デモを作りながらその可能性を考えました。

やはり、実際に手を動かすと一気に理解が深まります。たとえば anchor() 関数の inside、outside のキーワードは思いのほか使い勝手がよかったり、水平軸と垂直軸で異なるアンカー要素と接続させることで、宣言的な指定方法を思いついたりと、新たな気づきが得られました。

一方で、ブラウザ間における表示の違いが気になることも多く、現時点では Firefox が顕著でした。この点については、今後 Interop などの取り組みをとおして、相互運用性が向上していくことが期待されます。

本記事の作成にあたり、以下のウェブページを参考にしました。

脚注

  1. ここでは、説明しやすくする目的でラベルを「More」としてますが、ページ内でリンクのラベルが重複すると、スクリーンリーダーでリンク先が識別しづらくなる問題があります。詳しくは「アクセシビリティ通信 #1 テキストリンクの指定」参照。 ↩

  2. position-try-fallbacks は、ブラウザの種類やステップの進む方向によって挙動が異なることがあり、指定方法が難しいと感じました。あくまでもフォールバックなので、完全に制御したいという考えを捨て、ある程度ブラウザに委ねたほうがよいのかもしれません。 ↩