Patterns

アクセシビリティに配慮したタブメニューの実装例

アクセシビリティに配慮したタブメニューの実装メモです。

2026年11月に、矢印キーの操作をkeyupイベントではなくkeydownイベントで受けるようにコードを直しました。keyupイベントではpreventDefault()が間に合わず、矢印キーやHomeキー、Endキーでページがスクロールしてしまうためです。あわせて、ブラウザの対応状況と初期化の書き方を直しています。

タブメニューのHTML

前回投稿した記事「タブやアコーディオンの非表示コンテンツにはhidden=“until-found”を使うべし」で触れたように、タブパネルの非表示の状態管理はhidden="until-found"、タブはa要素で実装するようにします。

タブが横並びの場合

横並びのタブメニュー実装例
<div id="js-tabs" class="tab-contents">
  <ul class="tablist" role="tablist">
    <li role="presentation">
      <a id="tab1" class="tab" href="#tabpanel1" role="tab" aria-controls="tabpanel1">Tab1</a>
    </li>
    <li role="presentation">
      <a id="tab2" class="tab" href="#tabpanel2" role="tab" aria-controls="tabpanel2">Tab2</a>
    </li>
    <li role="presentation">
      <a id="tab3" class="tab" href="#tabpanel3" role="tab" tabindex="0" aria-controls="tabpanel3">Tab3</a>
    </li>
  </ul>
  <div class="tabpanel-wrapper">
    <div id="tabpanel1" class="tabpanel" role="tabpanel" aria-labelledby="tab1" tabindex="0">
      <p>タブコンテンツで検索</p>
      <div class="images">
        <img src="https://picsum.photos/800/450?random=1" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=2" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=3" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=4" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=5" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=6" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=7" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=8" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
      </div>
    </div>
    <div id="tabpanel2" class="tabpanel" role="tabpanel" hidden="until-found" aria-labelledby="tab2" tabindex="0">
      <p>タブコンテンツで検索</p>
      <div class="images">
        <img src="https://picsum.photos/800/450?random=9" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=10" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=11" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=12" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=13" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=14" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=15" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=16" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
      </div>
    </div>
    <div id="tabpanel3" class="tabpanel" role="tabpanel" hidden="until-found" aria-labelledby="tab3" tabindex="0">
      <p>タブコンテンツで検索</p>
      <div class="images">
        <img src="https://picsum.photos/800/450?random=17" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=18" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=19" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=20" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=21" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=22" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=23" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=24" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
      </div>
    </div>
  </div>
</div>
.tab-contents {
  --font-size: clamp(0.875rem, 0.821rem + 0.24vi, 1rem);
  --spacing: clamp(1.25rem, 0.714rem + 2.38vw, 2.5rem);

  max-inline-size: 1200px;
  padding: var(--spacing);
  margin-inline: auto;
  font-size: var(--font-size);
  background-color: var(--color-white);
}

.tablist {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 4px;
  padding: 0;
  list-style: none;
}

.tab {
  display: grid;
  place-items: center;
  padding: 1em;
  color: var(--color-gray);
  text-decoration: none;
  letter-spacing: 0.02em;
  border: 2px solid var(--color-smoke);
  text-decoration: unset;
  transition-duration: 0.3s;
  transition-property: background-color, border-color;

  &[aria-selected="true"] {
    color: var(--color-white);
    background-color: var(--color-gray);
    border-color: var(--color-gray);
  }

  &:focus-visible {
    background-color: var(--color-smoke);
  }

  @media (any-hover: hover) {
    &:not([aria-selected="true"]):hover {
      background-color: var(--color-smoke);
    }
  }
}

.tabpanel-wrapper {
  margin-block-start: var(--spacing);
}

.tabpanel:target {
  display: revert;
}

.tabpanel > * + * {
  margin-block-start: var(--spacing);
}

.images {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: calc(var(--spacing) / 2);
}

.images img {
  inline-size: 100%;
  block-size: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

@layer defaults {
  :root {
    --color-white: #fefefe;
    --color-neutral: #f7f8f8;
    --color-smoke: #e0e0e0;
    --color-gray: #434a56;
  }

  *,
  *::before,
  *::after {
    box-sizing: border-box;
    margin: 0;
  }

  body {
    font-family: "Helvetica Neue", "Segoe UI", "Hiragino Sans",
      "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
    line-height: 1.5;
    color: var(--color-gray);
    background-color: var(--color-neutral);
  }

  :focus:not(:focus-visible) {
    outline: 0;
  }

  p {
    text-align: center;
  }
}
const initializeTabs = (root, firstView = 1) => {
  if (!root) return;

  const tablist = root.querySelector('[role="tablist"]');
  const tabs = root.querySelectorAll('[role="tab"]');
  const tabpanels = root.querySelectorAll('[role="tabpanel"]');

  if (!tablist || tabs.length === 0 || tabpanels.length === 0) return;

  const currentIndex = Math.max(0, firstView - 1);

  setTabAttributes(tabs, tabpanels);
  activateTab(tabs, tabpanels, currentIndex);

  tabs.forEach((tab, index) => {
    tab.addEventListener(
      "click",
      (event) => handleClick(event, tabs, tabpanels, index),
      false
    );
    tab.addEventListener(
      "keydown",
      (event) => handleKeyNavigation(event, tabs, tabpanels, index, tablist),
      false
    );
  });

  tabpanels.forEach((panel) => {
    panel.addEventListener(
      "beforematch",
      (event) => handleBeforeMatch(event, tabs, tabpanels),
      true
    );
  });
};

// タブとタブパネルに必要な属性を設定する関数
const setTabAttributes = (tabs, tabpanels) => {
  tabs.forEach((tab, index) => {
    tab.setAttribute("aria-selected", "false");
    tab.setAttribute("aria-controls", tabpanels[index].id);
    tab.setAttribute("tabindex", "-1");
  });
};

// アクティブなタブを切り替える関数
const activateTab = (tabs, tabpanels, index) => {
  tabs.forEach((tab, i) => {
    const isSelected = i === index;
    tab.setAttribute("aria-selected", String(isSelected));
    tab.setAttribute("tabindex", isSelected ? "0" : "-1");
  });

  tabpanels.forEach((tabpanel, i) => {
    if (i !== index) {
      tabpanel.setAttribute("hidden", "until-found");
      tabpanel.removeAttribute("tabindex");
    } else {
      tabpanel.removeAttribute("hidden");
      tabpanel.setAttribute("tabindex", "0");
    }
  });
};

// クリックイベントを扱う関数
const handleClick = (event, tabs, tabpanels, index) => {
  event.preventDefault();
  activateTab(tabs, tabpanels, index);
};

// キーボードナビゲーションを扱う関数
const handleKeyNavigation = (event, tabs, tabpanels, currentIndex, tablist) => {
  const orientation = tablist.getAttribute("aria-orientation") || "horizontal";

  const keyActions = {
    [orientation === "vertical" ? "ArrowUp" : "ArrowLeft"]: () =>
      currentIndex - 1 >= 0 ? currentIndex - 1 : tabs.length - 1,
    [orientation === "vertical" ? "ArrowDown" : "ArrowRight"]: () =>
      (currentIndex + 1) % tabs.length,
    Home: () => 0,
    End: () => tabs.length - 1
  };

  const action = keyActions[event.key];

  if (action) {
    event.preventDefault();
    const newIndex = action();
    tabs[newIndex].focus();
    activateTab(tabs, tabpanels, newIndex);
  }
};

// beforematchイベントを扱う関数
const handleBeforeMatch = (event, tabs, tabpanels) => {
  const panel = event.currentTarget;
  const tabIndex = [...tabpanels].indexOf(panel);

  if (tabIndex !== -1) {
    activateTab(tabs, tabpanels, tabIndex);
  }
};

const target = document.getElementById("js-tabs");

initializeTabs(target);

マウスかキーボードでコードを選ぶと編集でき、表示結果に反映されます。編集中は Tab キーで字下げし、Esc キーでエディターから抜けます。

role="tablist"とrole="tab"の実装例
<div role="tablist">
	<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	<a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a>
	<a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a>
</div>
<div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div>
<div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div>
<div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>
role="tablist"にul要素を利用した実装例
<ul role="tablist">
	<li role="presentation">
		<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	</li>
	<li role="presentation">
		<a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a>
	</li>
	<li role="presentation">
		<a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a>
	</li>
</ul>
<div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div>
<div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div>
<div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>

タブが縦並びの場合

縦並びのタブメニュー実装例
<div id="js-tabs" class="tab-contents">
  <ul class="tablist" role="tablist" aria-orientation="vertical">
    <li role="presentation">
      <a id="tab1" class="tab" href="#tabpanel1" role="tab">Tab1</a>
    </li>
    <li role="presentation">
      <a id="tab2" class="tab" href="#tabpanel2" role="tab">Tab2</a>
    </li>
    <li role="presentation">
      <a id="tab3" class="tab" href="#tabpanel3" role="tab">Tab3</a>
    </li>
  </ul>
  <div class="tabpanel-wrapper">
    <div id="tabpanel1" class="tabpanel" role="tabpanel" aria-labelledby="tab1">
      <div class="images">
        <img src="https://picsum.photos/800/450?random=1" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=2" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=3" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=4" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=5" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=6" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=7" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
        <img src="https://picsum.photos/800/450?random=8" alt="" width="800" height="450" decoding="async" fetchpriority="high" />
      </div>
    </div>
    <div id="tabpanel2" class="tabpanel" role="tabpanel" hidden="until-found" aria-labelledby="tab2">
      <div class="images">
        <img src="https://picsum.photos/800/450?random=9" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=10" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=11" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=12" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=13" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=14" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=15" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=16" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
      </div>
    </div>
    <div id="tabpanel3" class="tabpanel" role="tabpanel" hidden="until-found" aria-labelledby="tab3">
      <div class="images">
        <img src="https://picsum.photos/800/450?random=17" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=18" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=19" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=20" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=21" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=22" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=23" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
        <img src="https://picsum.photos/800/450?random=24" alt="" width="800" height="450" decoding="async" loading="lazy" fetchpriority="low" />
      </div>
    </div>
  </div>
</div>
@import "https://cdn.jsdelivr.net/npm/kiso.css@latest/kiso.css" layer(reset);

.tab-contents {
  --_breakpoint: 640px;
  --_static-column: 260px;
  --_fluid-column: calc(var(--_breakpoint) - var(--_static-column));
  --_spacing: clamp(1.25rem, 0.714rem + 2.38dvi, 2.5rem);

  display: block flex;
  flex-wrap: wrap;
  gap: var(--_spacing);
  max-inline-size: 1280px;
  padding: var(--_spacing);
  margin-inline: auto;
  font-size: clamp(0.875rem, 0.821rem + 0.24dvi, 1rem);
  background-color: var(--color-white);

  & > :first-child {
    flex: 1 1 var(--_static-column);
  }

  & > :last-child {
    flex: 9999 1 var(--_fluid-column);
  }
}

.tablist {
  display: block grid;
  row-gap: 4px;
  align-content: start;
}

.tab {
  --_foreground-default: var(--color-gray);
  --_foreground-active: var(--color-white);
  --_background-active: var(--color-gray);
  --_background-hover: var(--color-smoke);

  display: block grid;
  place-items: center;
  padding: 1em;
  color: var(--_foreground-default);
  letter-spacing: 0.02em;
  border: 2px solid var(--color-smoke);
  transition-duration: 0.3s;
  transition-property: background-color, border-color;

  &[aria-selected="true"] {
    color: var(--_foreground-active);
    background-color: var(--_background-active);
    border-color: var(--_background-active);
  }

  &:focus-visible {
    background-color: var(--_background-hover);
  }

  &:not([aria-selected="true"]) {
    @media (any-hover: hover) {
      &:hover {
        background-color: var(--_background-hover);
      }
    }
  }
}

.tabpanel {
  &:target {
    display: revert;
  }
}

.images {
  display: block grid;
  grid-template-columns: repeat(2, 1fr);
  gap: calc(var(--_spacing) / 2);

  & img {
    inline-size: 100%;
    block-size: auto;
    aspect-ratio: 16 / 9;
    object-fit: cover;
  }
}

:root {
  --color-white: #fefefe;
  --color-neutral: #f7f8f8;
  --color-smoke: #e0e0e0;
  --color-gray: #434a56;

  color: var(--color-gray);
  background-color: var(--color-neutral);
}
const initializeTabs = (root, firstView = 1) => {
  if (!root) return;

  const tablist = root.querySelector('[role="tablist"]');
  const tabs = root.querySelectorAll('[role="tab"]');
  const tabpanels = root.querySelectorAll('[role="tabpanel"]');

  if (!tablist || tabs.length === 0 || tabpanels.length === 0) return;

  const currentIndex = Math.max(0, firstView - 1);

  setTabAttributes(tabs, tabpanels);
  activateTab(tabs, tabpanels, currentIndex);

  tabs.forEach((tab, index) => {
    tab.addEventListener(
      "click",
      (event) => handleClick(event, tabs, tabpanels, index),
      false
    );
    tab.addEventListener(
      "keydown",
      (event) => handleKeyNavigation(event, tabs, tabpanels, index, tablist),
      false
    );
  });

  tabpanels.forEach((panel) => {
    panel.addEventListener(
      "beforematch",
      (event) => handleBeforeMatch(event, tabs, tabpanels),
      true
    );
  });
};

// タブとタブパネルに必要な属性を設定する関数
const setTabAttributes = (tabs, tabpanels) => {
  tabs.forEach((tab, index) => {
    tab.setAttribute("aria-selected", "false");
    tab.setAttribute("aria-controls", tabpanels[index].id);
    tab.setAttribute("tabindex", "-1");
  });
};

// アクティブなタブを切り替える関数
const activateTab = (tabs, tabpanels, index) => {
  tabs.forEach((tab, i) => {
    const isSelected = i === index;
    tab.setAttribute("aria-selected", String(isSelected));
    tab.setAttribute("tabindex", isSelected ? "0" : "-1");
  });

  tabpanels.forEach((tabpanel, i) => {
    if (i !== index) {
      tabpanel.setAttribute("hidden", "until-found");
      tabpanel.removeAttribute("tabindex");
    } else {
      tabpanel.removeAttribute("hidden");
      tabpanel.setAttribute("tabindex", "0");
    }
  });
};

// クリックイベントを扱う関数
const handleClick = (event, tabs, tabpanels, index) => {
  event.preventDefault();
  activateTab(tabs, tabpanels, index);
};

// キーボードナビゲーションを扱う関数
const handleKeyNavigation = (event, tabs, tabpanels, currentIndex, tablist) => {
  const orientation = tablist.getAttribute("aria-orientation") || "horizontal";

  const keyActions = {
    [orientation === "vertical" ? "ArrowUp" : "ArrowLeft"]: () =>
      currentIndex - 1 >= 0 ? currentIndex - 1 : tabs.length - 1,
    [orientation === "vertical" ? "ArrowDown" : "ArrowRight"]: () =>
      (currentIndex + 1) % tabs.length,
    Home: () => 0,
    End: () => tabs.length - 1
  };

  const action = keyActions[event.key];

  if (action) {
    event.preventDefault();
    const newIndex = action();
    tabs[newIndex].focus();
    activateTab(tabs, tabpanels, newIndex);
  }
};

// beforematchイベントを扱う関数
const handleBeforeMatch = (event, tabs, tabpanels) => {
  const panel = event.currentTarget;
  const tabIndex = [...tabpanels].indexOf(panel);

  if (tabIndex !== -1) {
    activateTab(tabs, tabpanels, tabIndex);
  }
};

const target = document.getElementById("js-tabs");

initializeTabs(target);

マウスかキーボードでコードを選ぶと編集でき、表示結果に反映されます。編集中は Tab キーで字下げし、Esc キーでエディターから抜けます。

role="tablist"とrole="tab"の実装例
<div role="tablist" aria-orientation="vertical">
	<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	<a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a>
	<a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a>
</div>
<div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div>
<div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div>
<div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>
role="tablist"にul要素を利用した実装例
<ul role="tablist" aria-orientation="vertical">
	<li role="presentation">
		<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	</li>
	<li role="presentation">
		<a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a>
	</li>
	<li role="presentation">
		<a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a>
	</li>
</ul>
<div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div>
<div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div>
<div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>

解説

タブのリストである要素にはrole="tablist"、タブとなる要素にはrole="tab"を付与します。

<div role="tablist" aria-orientation="vertical">
	<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	<a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a>
	<a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a>
</div>

role="tablist"とrole="tab"は親子関係である必要があります。ul要素で実装する場合は前述の制約により、li要素を「無いもの」として扱う必要があるのでrole="presentation"を付与します。

これを怠るとVoiceOverではタブの個数の読み上げが行われなくなるので必ず指定するようにしましょう。

<ul role="tablist" aria-orientation="vertical">
	<li role="presentation">
		<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	</li>
	<li role="presentation">
		<a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a>
	</li>
	<li role="presentation">
		<a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a>
	</li>
</ul>

タブが横並びの場合は意識しなくても良いですが、縦並びの場合はrole="tablist"にはaria-orientation="vertical"を付与します。

<div role="tablist" aria-orientation="vertical">
	<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	<a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a>
	<a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a>
</div>

タブパネルとなる要素にはrole="tabpanel"を付与してタブパネルであることを明示します。

どの項目のパネルなのかを判別しやすくするために制御関係にあるタブのidと紐づけたaria-labelledby属性でラベリングも行います。

<div role="tablist">
	<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	...
</div>
<div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div>

タブと制御関係にあるタブパネルをタブパネルに指定したidとタブのaria-controls属性およびhref属性で紐づけます。aria-controls属性で制御関係にある要素を識別し、JSが無効化されている場合にもユーザーがタブパネルにアクセスできるリンクを提供するhref属性も指定します。

<div role="tablist">
	<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	...
</div>
<div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div>

タブには現在選択されていることを示すためにaria-selected属性を付与します。この場合だと最初のタブがtrueに設定され、他のタブがfalseに設定されています。aria-selected属性の値はJSで切り替えます。

<div role="tablist">
	<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	<a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a>
	<a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a>
</div>

タブとタブパネルにtabindex属性を付与します。タブ側は選択中のtabindexの値を0に設定し、他のタブのそれは-1にします。非アクティブのタブにtabindex="-1"を付与することでスムーズにタブパネルへフォーカスを移すことができます。

MDNのサンプル(新しいタブで開く)では全てのタブパネルにtabindex="0"が付与されていますが、hidden="until-found"は従来のhidden属性とは違い非表示のrole="tabpanel"要素にもフォーカスが当たるため、JSでアクティブなタブパネルのみにtabindex="0"を付与するようにします。

<div role="tablist">
	<a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a>
	<a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a>
	<a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a>
</div>
<div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div>
<div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div>
<div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>

マークアップでrole="tab"にtabindex="-1"を付与してしまうとJS無効環境かつキーボード操作の時に非アクティブのタブを選択できなくなります。tabindex属性はJS側で初期化時に付与するのが望ましいでしょう。

非表示のタブパネルにはhidden="until-found"を付与します。display:noneする従来の方法とは違い、ページ内検索で検出することが可能になり、JS無効環境のフォールバックも容易になります。また、Chrome for Developersの文書(新しいタブで開く)によれば、hidden="until-found"が指定された要素内のコンテンツには検索エンジンのロボットもアクセスできるようになるとのことですので、SEOに良い影響を与える可能性もあります。

<div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div>
<div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div>
<div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>

タブとタブパネルにそれぞれユニークなid属性を指定する必要があります。Crypto: randomUUID() メソッドなどを利用し、ユニークIDを生成することをオススメします。

タブメニューのCSS

選択されているタブのスタイルの状態管理は[aria-selected='true']セレクタで行うと良いでしょう。

[role='tab'][aria-selected='true'] {
	/* 選択中のタブのスタイル */
}

@media (any-hover: hover) {
	[role='tab']:not([aria-selected='true']):hover {
		/* 非選択のタブのホバースタイル */
	}
}

また、hidden="until-found"が非対応の古いブラウザ(Safari 26.2未満やFirefox 139未満)のJS無効環境のフォールバックのために次のようなスタイルも指定しておきます。このスタイルの意味は前回投稿した記事「タブやアコーディオンの非表示コンテンツにはhidden=“until-found”を使うべし」で詳細に解説しているのでそちらを参照してください。

タブやアコーディオンの非表示コンテンツにはhidden="until-found"を使うべし – TAKLOG

タブやアコーディオンの非表示コンテンツにはdisplay:noneがよく用いられますが、hidden="until-found"属性を使ったほうがメリットがあります。この記事ではその具体例を説明します。

www.tak-dcxi.com

[role='tabpanel']:target {
	display: revert;
}

非表示のスタイリングはhidden="until-found"のUAスタイルシートを利用し、Authorオリジンでdisplay:noneを指定しないように気をつけてください。

タブメニューのスタイリングはデザインによって異なるため、他に特筆することはありません。

タブメニューのJS

タブ機能を追加するためのJavaScriptの実装例です。コードにはTypeScriptを使用していますので、TypeScriptを利用していないWeb制作現場で使用する場合はChatGPTなどに依頼してJSファイルに変換してください。

initializeTabs

initializeTabs. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

マークアップ側はこのように指定しております。

tabmenu.astro
<div id="tabmenu">
	<div role="tablist">
		<a role="tab" id="tab1" href="#tabpanel1">Tab1</a>
		<a role="tab" id="tab2" href="#tabpanel2">Tab2</a>
		<a role="tab" id="tab3" href="#tabpanel3">Tab3</a>
	</div>
	<div role="tabpanel" id="tabpanel1" aria-labelledby="tab1">コンテンツ1</div>
	<div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div>
	<div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>
</div>

<script>
	import initializeTabs from '@/scripts/initializeTabs.ts'

	const target = document.getElementById('tabmenu')

	if (target) {
		initializeTabs(target)
	}
</script>

ページの遷移にAstroのClientRouterを使っている場合は、astro:page-loadイベントの中でinitializeTabs関数を呼び出してください。

JS無効環境を意識してtabindex属性とJSでの操作が前提のaria-selected属性、それに付随するaria-controls属性はJSで初期化時に付与するようにします。

initializeTabs関数

initializeTabs
const initializeTabs = (root: HTMLElement | null, firstView = 1): void => {
  if (!root) {
    console.error('initializeTabs: Root element is not found.')
    return
  }

  const tablist = root.querySelector('[role="tablist"]') as HTMLElement
  const tabs = root.querySelectorAll('[role="tab"]') as NodeListOf<HTMLAnchorElement>
  const tabpanels = root.querySelectorAll('[role="tabpanel"]') as NodeListOf<HTMLElement>

  if (!tablist || tabs.length === 0 || tabpanels.length === 0) {
    console.error('initializeTabs: Required elements for tabs are missing or invalid.')
    return
  }

  const initialIndex = Math.max(0, firstView - 1)

  setTabAttributes(tabs, tabpanels)
  activateTab(tabs, tabpanels, initialIndex)

  tabs.forEach((tab, index) => {
    tab.addEventListener('click', (event) => handleClick(event, tabs, tabpanels, index), false)
    tab.addEventListener('keydown', (event) => handleKeyNavigation(event, tablist, tabs, tabpanels, index), false)
  })

  tabpanels.forEach((panel) => {
    panel.addEventListener('beforematch', (event) => handleBeforeMatch(event, tabs, tabpanels), true)
  })
}

initializeTabs関数は、タブ機能を初期化するための関数です。引数として、タブ機能を適用するルート要素(root)と、初期表示するタブのインデックス(firstView)を受け取るようにします。

関数内では、まずルート要素内からタブリスト(role="tablist")、タブ(role="tab")、タブパネル(role="tabpanel")を取得します。これらの要素が存在しない場合は、初期化処理を中断します。

次に、setTabAttributes関数を呼び出して、タブに必要な属性を設定し、activateTab関数を呼び出して、初期表示するタブをアクティブにします。

その後、各タブに対してクリックイベントとキーボードイベントのリスナーを登録します。クリックイベントはhandleClick関数で、キーボードイベントはhandleKeyNavigation関数で処理されます。

さらに、各タブパネルに対してbeforematchイベントのリスナーを登録します。これは、ページ内検索時にタブパネルが表示される(hidden="until-found"が取り除かれる)直前に発生するイベントで、handleBeforeMatch関数で処理されます。

setTabAttributes関数

setTabAttributes
const setTabAttributes = (tabs: NodeListOf<HTMLAnchorElement>, tabpanels: NodeListOf<HTMLElement>): void => {
  tabs.forEach((tab, index) => {
    tab.setAttribute('aria-selected', 'false')
    tab.setAttribute('aria-controls', tabpanels[index].id)
    tab.setAttribute('tabindex', '-1')
  })
}

setTabAttributes関数は、タブに必要な属性を設定するための関数です。

それぞれのタブにtabindex="-1"、aria-selected="false"、対応するタブパネルのidを指定したaria-controlsを付与します。

初期状態ではすべてのタブが非選択状態になります。

activateTab関数

activateTab
const activateTab = (tabs: NodeListOf<HTMLAnchorElement>, tabpanels: NodeListOf<HTMLElement>, index: number): void => {
  tabs.forEach((tab, i) => {
    const isSelected = i === index
    tab.setAttribute('aria-selected', String(isSelected))
    tab.setAttribute('tabindex', isSelected ? '0' : '-1')
  })

  tabpanels.forEach((tabpanel, i) => {
    if (i !== index) {
      tabpanel.setAttribute('hidden', 'until-found')
      tabpanel.removeAttribute('tabindex')
    } else {
      tabpanel.removeAttribute('hidden')
      tabpanel.setAttribute('tabindex', '0')
    }
  })
}

activateTab関数は、アクティブなタブを切り替えるための関数です。引数として、タブのNodeList(tabs)、タブパネルのNodeList(tabpanels)、アクティブにするタブのインデックス(index)を受け取ります。

関数内では、まず各タブに対して、aria-selected属性とtabindex属性を更新します。アクティブなタブはaria-selected属性がtrueに、tabindex属性が0になります。非アクティブなタブはaria-selected属性がfalseに、tabindex属性が-1になります。

次に、各タブパネルに対して、hidden属性とtabindex属性を更新します。非アクティブなタブパネルはhidden属性がuntil-foundに設定され、tabindex属性が削除されます。アクティブなタブパネルはhidden属性が削除され、tabindex属性が0に設定されます。

handleClick関数

handleClick
const handleClick = (event: MouseEvent, tabs: NodeListOf<HTMLAnchorElement>, tabpanels: NodeListOf<HTMLElement>, index: number): void => {
  event.preventDefault()
  activateTab(tabs, tabpanels, index)
}

handleClick関数は、タブのクリックイベントを処理するための関数です。

event.preventDefault()でイベントのデフォルトの動作をキャンセルし、activateTab関数を呼び出してクリックされたタブをアクティブにします。

handleKeyNavigation関数

handleKeyNavigation
const handleKeyNavigation = (
  event: KeyboardEvent,
  tablist: HTMLElement,
  tabs: NodeListOf<HTMLAnchorElement>,
  tabpanels: NodeListOf<HTMLElement>,
  currentIndex: number,
): void => {
  const orientation = tablist.getAttribute('aria-orientation') || 'horizontal'

  const keyActions: Record<string, () => number> = {
    [orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft']: () =>
      currentIndex - 1 >= 0 ? currentIndex - 1 : tabs.length - 1,
    [orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight']: () => (currentIndex + 1) % tabs.length,
    Home: () => 0,
    End: () => tabs.length - 1,
  }

  const action = keyActions[event.key]

  if (action) {
    event.preventDefault()
    const newIndex = action()
    tabs[newIndex].focus()
    activateTab(tabs, tabpanels, newIndex)
  }
}

handleKeyNavigation関数は、タブのキーボードナビゲーションを処理するための関数です。タブリスト要素のaria-orientation属性を取得し、キーボードの矢印キーやHomeキー、Endキーに応じてアクティブなタブを切り替えます。

キー操作
左矢印キー【aria-orientationが未設定もしくはhorizontalに設定されている場合】
フォーカスを前のタブに移動します。最初のタブにフォーカスがある場合は、最後のタブに移動します。
右矢印キー【aria-orientationが未設定もしくはhorizontalに設定されている場合】
フォーカスを次のタブに移動します。最後のタブにフォーカスがある場合は、最初のタブに移動します。
上矢印キー【aria-orientationがverticalに設定されている場合】
フォーカスを前のタブに移動します。最初のタブにフォーカスがある場合は、最後のタブに移動します。
下矢印キー【aria-orientationがverticalに設定されている場合】
フォーカスを次のタブに移動します。最後のタブにフォーカスがある場合は、最初のタブに移動します。
Homeキーフォーカスを最初のタブに移動します。
Endキーフォーカスを最後のタブに移動します。

関数内では、まずkeyActionsオブジェクトを定義します。このオブジェクトは、キーと対応するアクションを定義しています。アクションは、次のタブまたは前のタブのインデックスを計算する関数です。

イベントのキーがkeyActionsオブジェクトに定義されている場合、イベントのデフォルトの動作をキャンセルし、対応するアクションを実行します。アクションにより計算された新しいタブのインデックスを使って、activateTab関数を呼び出し、タブを切り替えます。

handleBeforeMatch関数

handleBeforeMatch
const handleBeforeMatch = (event: Event, tabs: NodeListOf<HTMLAnchorElement>, tabpanels: NodeListOf<HTMLElement>): void => {
  const panel = event.currentTarget as HTMLElement
  const tabIndex = [...tabpanels].indexOf(panel)

  if (tabIndex !== -1) {
    activateTab(tabs, tabpanels, tabIndex)
  }
}

handleBeforeMatch関数は、タブパネルが表示される直前に発生するbeforematchイベントを処理するための関数です。

ページ内検索でhidden="until-found"内のコンテンツがヒットした際にタブを切り替えます。

イベントの発生元のタブパネルを取得し、そのインデックスを計算します。インデックスが有効な範囲内にある場合、activateTab関数を呼び出して対応するタブをアクティブにします。

beforematchイベントの詳細についてはMDNのドキュメントを参考にしてください。

Element: beforematch イベント - Web API | MDN

要素が beforematch イベントを受け取るのは、それが hidden until found の状態にあり、ユーザーが「ページ内検索」機能やフラグメントナビゲーションによってコンテンツを見つけたため、ブラウザーがそのコンテンツを表示しようとしているときです。

developer.mozilla.org

関連リンク