はじめに
みなさんアクセシビリティを意識して開発できていますか?
必要なところにrole属性
を記述したり、tabキー
でフォーカスができるようにしたりなど、意識しないといけないことも多いです。
そのため、アクセシビリティを完璧にやろうとするのは一苦労です。
ただ、コンポーネントごとに区切って、アクセシビリティを理解しておけば、実装するタイミングに思い出しやすく、アクセシビリティも意識しやすいと思います。
そのため、この記事では「カルーセル」に焦点を当てて、アクセシビリティを意識したカルーセルの実装方法とカルーセルで意識した方がいいアクセシビリティを解説しようと思います。
アクセシビリティを意識したカルーセルの仕様
⚪︎ カルーセルとは?
カルーセルは、複数のスライドを順番に表示することができる要素です。
通常一度に1つのスライドが表示され、ユーザーが現在のスライドを非表示にして、次のスライドを表示する機能があります。
また、カルーセルのすべての要素にユーザーがアクセスできるようにするのも重要です。
カルーセルでは、以下のような機能が必要です。
- 前と次のスライドを表示させるボタン
- 特定のスライドを表示させるコントロールグループ (任意)
-
カルーセルが自動スライドである場合は以下も必要
- 回転を停止・再開させるボタンをつける
- キーボードフォーカスがカルーセルに入ると回転を停止させる
- マウスがカルーセル上にある時は、回転を停止させる
また、カルーセルは以下の5つに部分に分かれています。
-
Slide
- カルーセルで表示させる要素のコンテナー
-
Rotation Control
- スライドが自動で動くのを停止・再開させる要素
-
Next Slide Control
- 次のスライドへ切り替える要素
-
Previous Slide Control
- 前のスライドへ切り替える要素
-
Slide Picker Controls
- 特定のスライドへ切り替える要素
⚪︎ キーボードインタラクション
-
キーボードフォーカス
- カルーセルに自動スライドである場合
- → 自動スライドを停止する
- カルーセルに自動スライドである場合
-
Tabキー
・Shift + Tabキー
- ページタブシーケンスによって指定されたカルーセルのインタラクティブ要素を通してフォーカスを移動させる
-
ボタン要素
- ボタンパターンで定義されたキーボード操作
-
Next Slide Control
とPrevious Slide Control
はアクティブにしても次のフォーカスに移動させない
-
Rotation Control
- Rotation Controlがある場合
- → Rotation Controlが最初にタブフォーカスが当たる
- Rotation Controlがある場合
-
Tab要素
- Slide Picker ControlsにTab要素が使われている場合
- → Tab要素で定義されたキーボード操作
- Slide Picker ControlsにTab要素が使われている場合
⚪︎ WAI-ARIA の役割、状態、プロパティ
以下の3つのカルーセルの WAI-ARIA の役割、状態、プロパティについて紹介します。
-
基本カルーセル
- Slide、Next Slide Control、Previous Slide Controlの要素を持っているカルーセル
-
基本カルーセル + Slide Picker Controls
- 基本カルーセルと、タブパターンを使用して実装された Slide Picker Controlsがあるカルーセル
-
グループ カルーセル
- 各コントロールがボタンパターンで実装され、基本カルーセル と Slide Picker Controlsが連動するカルーセル
⚪︎ 基本カルーセル
- カルーセル コンテナ要素には、
role="region"
orrole="group"
のどちらかを指定する - カルーセル コンテナ要素には、
aria-roledescription="carousel"
を指定する - カルーセルが可視ラベルを持つ場合、可視ラベルのIDをカルーセル コンテナ要素の
aria-labelledby
に指定する - カルーセルに可視ラベルがない場合、アクセス可能なラベルは、カルーセル コンテナ要素に
aria-label
で指定する- カルーセル コンテナ要素に
aria-roledescription="carousel"
を指定しているので、aria-label
でcarousel
を使わない
- カルーセル コンテナ要素に
- Rotation Control、Next Slide Control、Previous Slide Controlは、Button要素かButtonパターンで実装する
- Rotation Controlは、
aria-label
でラベルを指定する-
aria-label="Stop slide rotation"
oraria-label="Start slide rotation"
- ボタンがアクティブになった時に変化するラベルは、スライドの内容が自動的に変化することと、それがいつ行われるかを伝える
- ラベルが変わるので、
aria-pressed
のような状態は持たない
-
- スライド コンテナ要素には、
aria-roledescription="slide"
を設定する - 各スライドには、アクセス可能な名前をつける
- 可視ラベルを持つ場合、可視ラベルのIDをスライド コンテナ要素
aria-labelledby
に設定する- そうでなければ、
aria-label
でラベルを設定する
- そうでなければ、
- スライドの内容を特定する一意の名前が利用できない場合、
3 of 10
などを提供する -
aria-roledescription="slide"
が設定されているので、ラベルにslide
が含まないことに注意
- 可視ラベルを持つ場合、可視ラベルのIDをスライド コンテナ要素
- 【任意】スライド コンテナには、
aria-atomic="false"
とaria-live
を設定する-
aria-live="off"
:カルーセルが自動で動く場合 -
aria-live="polite"
:カルーセルが自動動かない場合
-
⚪︎ 基本カルーセル + Slide Picker Controls
- スライド コンテナは
role="group"
ではなくrole="tabpanel"
を持つ- そのため、
aria-roledescription
は設定しない
- そのため、
- Slide Picker Controlsはタブパターンを使用して実装する
- 各コントロールは、タブ要素であり、タブがアクティブになると、関連するスライドが表示される
- 各タブのlabelは、
slide 3
のように番号を含めることで、どのスライドを表示するかを示す - 各コントロールのコンテナは、tablist要素にする
- tab, tablist tabpanelは、タブパターンで指定したプロパティで実装する
⚪︎ グループ カルーセル
- Slide Picker Controlsは、
role="group"
を設定する - Slide Picker Controlsを持つ要素は、
aria-label
を設定する-
Choose slide to display
のようにコントロールの目的を明確にする
-
- 各ピッカーは、Button要素かButtonパターンで実装する
- 各ピッカーには、表示するスライドと一致するラベルを設定するために、スライド要素に
aria-labelledby
を設定する - 現在表示されているピッカーは、
aria-disable="true"
を設定する
アクセシビリティを意識したカルーセルの完成形
See the Pen Carousel Accessibility by でぐぅー | Qiita (@sp_degu) on CodePen.
アクセシビリティを意識したカルーセルの作り方
1. HTMLを実装する
<section id="myCarousel" class="carousel-tablist" aria-roledescription="carousel" aria-label="Highlighted television shows">
<div class="carousel-inner">
<div id="myCarousel-items" class="carousel-items playing" aria-live="off">
<div class="carousel-item active" id="carousel-item-1" role="tabpanel" aria-roledescription="slide" aria-label="1 of 4">
<a href="#" id="carousel-image-1" class="carousel-image">
<img src="https://drive.google.com/uc?export=view&id=1mwp4HQzhbwYI2ofa4bQQw0a8CuiYa77k" alt="" width="500px" height="300px">
</a>
</div>
<div class="carousel-item" id="carousel-item-2" role="tabpanel" aria-roledescription="slide" aria-label="2 of 4">
<a href="#" id="carousel-image-2" class="carousel-image">
<img src="https://drive.google.com/uc?export=view&id=1n0Jyf55rh72bB9ElPQLcXhKBBlVvFaFB" alt="" width="500px" height="300px">
</a>
</div>
<div class="carousel-item" id="carousel-item-3" role="tabpanel" aria-roledescription="slide" aria-label="3 of 4">
<a href="#!" id="carousel-image-3" class="carousel-image">
<img src="https://drive.google.com/uc?export=view&id=1n1jfBoNBl0L3e2qP5HOvxqQjBiHPyDx8" alt="" width="500px" height="300px">
</a>
</div>
<div class="carousel-item" id="carousel-item-4" role="tabpanel" aria-roledescription="slide" aria-label="4 of 4">
<a href="#" id="carousel-image-4" class="carousel-image">
<img src="https://drive.google.com/uc?export=view&id=1n8lNUmS1oxKcMRLJjmu_ae6ONjVeFAVJ" alt="" width="500px" height="300px">
</a>
</div>
</div>
<div class="controls">
<div class="tab-wrapper" role="tablist" aria-label="Slides">
<button id="carousel-tab-1" type="button" role="tab" aria-label="Slide 1" aria-selected="true" aria-controls="carousel-item-1"></button>
<button id="carousel-tab-2" type="button" role="tab" tabindex="-1" aria-label="Slide 2" aria-selected="false" aria-controls="carousel-item-2"></button>
<button id="carousel-tab-3" type="button" role="tab" tabindex="-1" aria-label="Slide 3" aria-selected="false" aria-controls="carousel-item-3"></button>
<button id="carousel-tab-4" type="button" role="tab" tabindex="-1" aria-label="Slide 4" aria-selected="false" aria-controls="carousel-item-4"></button>
</div>
<button class="rotation" type="button" aria-label="Stop automatic slide show"></button>
</div>
</div>
</section>
2. CSSを実装する
/* 以下スタイル調整 */
body {
background-color: #212529;
color: #fff;
display: grid;
height: calc(100vh - 40px);
margin: 0;
padding: 20px 0;
place-items: center;
width: 100vw;
}
.carousel-tablist {
background-color: rgb(128 128 128 / .3);
border-radius: 24px;
padding: 16px;
width: 300px;
background-blend-mode: luminosity;
backdrop-filter: blur(15px);
&::before {
background: linear-gradient(135deg, rgb(255 255 255 / .4) 0, rgb(255 255 255 / 0) 40%, rgb(255 255 255 / 0) 60%, rgb(255 255 255 / .1) 100%);
border: 1.4px solid transparent;
border-radius: 24px;
content: "";
inset: 0;
position: absolute;
-webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
-webkit-mask-composite: destination-out;
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
mask-composite: exclude;
z-index: -1;
}
}
.carousel-tablist .carousel-inner {
display: grid;
gap: 8px;
}
.carousel-tablist .carousel-item {
display: none;
&.active {
display: block;
}
& a img {
display: block;
border-radius: 8px;
height: 100%;
width: 100%;
}
}
.carousel-tablist .controls {
display: flex;
justify-content: center;
& > button {
align-items: center;
background: none;
border: none;
cursor: pointer;
color: #ffffff;
display: flex;
justify-content: center;
padding: 2px;
}
}
.carousel-tablist .rotation {
padding: 2px;
&.pause::before {
align-items: center;
justify-content: center;
display: flex;
font-family: "Material Symbols Outlined";
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 24;
content: "pause";
width: 24px;
height: 24px;
}
&.play::before {
align-items: center;
justify-content: center;
display: flex;
font-family: "Material Symbols Outlined";
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 24;
content: "play_arrow";
width: 24px;
height: 24px;
}
}
.carousel-tablist [role="tablist"] {
padding: 0 8px;
display: flex;
align-items: center;
gap: 8px;
}
.carousel-tablist [role="tab"] {
border: none;
border-radius: 50%;
padding: 0;
margin: 0;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.08) 100%), rgba(214, 214, 214, 0.45);
background-blend-mode: luminosity, color-burn;
width: 12px;
height: 12px;
&:focus, &:hover {
background: #ffffff;
}
}
.carousel-tablist [role="tab"][aria-selected="true"] {
background: #ffffff;
}
3. JavaScriptを実装する
var CarouselTablist = function (node, options) {
// merge passed options with defaults
options = Object.assign(
{ moreaccessible: false, paused: false, norotate: false },
options || {}
);
// a prefers-reduced-motion user setting must always override autoplay
var hasReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
if (hasReducedMotion.matches) {
options.paused = true;
}
/* DOM properties */
this.domNode = node;
this.tablistNode = node.querySelector('[role=tablist]');
this.containerNode = node.querySelector('.carousel-items');
this.tabNodes = [];
this.tabpanelNodes = [];
this.liveRegionNode = node.querySelector('.carousel-items');
this.pausePlayButtonNode = document.querySelector(
'.carousel-tablist .controls button.rotation'
);
this.playLabel = 'Start automatic slide show';
this.pauseLabel = 'Stop automatic slide show';
/* State properties */
this.hasUserActivatedPlay = false; // set when the user activates the play/pause button
this.isAutoRotationDisabled = options.norotate; // This property for disabling auto rotation
this.isPlayingEnabled = !options.paused; // This property is also set in updatePlaying method
this.timeInterval = 5000; // length of slide rotation in ms
this.currentIndex = 0; // index of current slide
this.slideTimeout = null; // save reference to setTimeout
// initialize tabs
this.tablistNode.addEventListener('focusin', this.handleTabFocus.bind(this));
this.tablistNode.addEventListener('focusout', this.handleTabBlur.bind(this));
var nodes = node.querySelectorAll('[role="tab"]');
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
this.tabNodes.push(n);
n.addEventListener('keydown', this.handleTabKeydown.bind(this));
n.addEventListener('click', this.handleTabClick.bind(this));
// initialize tabpanels
var tabpanelNode = document.getElementById(n.getAttribute('aria-controls'));
if (tabpanelNode) {
this.tabpanelNodes.push(tabpanelNode);
// support stopping rotation when any element receives focus in the tabpanel
tabpanelNode.addEventListener(
'focusin',
this.handleTabpanelFocusIn.bind(this)
);
tabpanelNode.addEventListener(
'focusout',
this.handleTabpanelFocusOut.bind(this)
);
var imageLink = tabpanelNode.querySelector('.carousel-image a');
if (imageLink) {
imageLink.addEventListener(
'focus',
this.handleImageLinkFocus.bind(this)
);
imageLink.addEventListener('blur', this.handleImageLinkBlur.bind(this));
}
} else {
this.tabpanelNodes.push(null);
}
}
// Pause Button
if (this.pausePlayButtonNode) {
this.pausePlayButtonNode.addEventListener(
'click',
this.handlePausePlayButtonClick.bind(this)
);
}
// Handle hover events
this.domNode.addEventListener('mouseover', this.handleMouseOver.bind(this));
this.domNode.addEventListener('mouseout', this.handleMouseOut.bind(this));
// initialize behavior based on options
this.enableOrDisableAutoRotation(options.norotate);
this.updatePlaying(!options.paused && !options.norotate);
this.setAccessibleStyling(options.moreaccessible);
this.rotateSlides();
};
/* Public function to disable/enable rotation and if false, hide pause/play button*/
CarouselTablist.prototype.enableOrDisableAutoRotation = function (disable) {
this.isAutoRotationDisabled = disable;
this.pausePlayButtonNode.hidden = disable;
};
/* Public function to update controls/caption styling */
CarouselTablist.prototype.setAccessibleStyling = function (accessible) {
if (accessible) {
this.domNode.classList.add('carousel-tablist-moreaccessible');
} else {
this.domNode.classList.remove('carousel-tablist-moreaccessible');
}
};
CarouselTablist.prototype.hideTabpanel = function (index) {
var tabNode = this.tabNodes[index];
var panelNode = this.tabpanelNodes[index];
tabNode.setAttribute('aria-selected', 'false');
tabNode.setAttribute('tabindex', '-1');
if (panelNode) {
panelNode.classList.remove('active');
}
};
CarouselTablist.prototype.showTabpanel = function (index, moveFocus) {
var tabNode = this.tabNodes[index];
var panelNode = this.tabpanelNodes[index];
tabNode.setAttribute('aria-selected', 'true');
tabNode.removeAttribute('tabindex');
if (panelNode) {
panelNode.classList.add('active');
}
if (moveFocus) {
tabNode.focus();
}
};
CarouselTablist.prototype.setSelectedTab = function (index, moveFocus) {
if (index === this.currentIndex) {
return;
}
this.currentIndex = index;
for (var i = 0; i < this.tabNodes.length; i++) {
this.hideTabpanel(i);
}
this.showTabpanel(index, moveFocus);
};
CarouselTablist.prototype.setSelectedToPreviousTab = function (moveFocus) {
var nextIndex = this.currentIndex - 1;
if (nextIndex < 0) {
nextIndex = this.tabNodes.length - 1;
}
this.setSelectedTab(nextIndex, moveFocus);
};
CarouselTablist.prototype.setSelectedToNextTab = function (moveFocus) {
var nextIndex = this.currentIndex + 1;
if (nextIndex >= this.tabNodes.length) {
nextIndex = 0;
}
this.setSelectedTab(nextIndex, moveFocus);
};
CarouselTablist.prototype.rotateSlides = function () {
if (!this.isAutoRotationDisabled) {
if (
(!this.hasFocus && !this.hasHover && this.isPlayingEnabled) ||
this.hasUserActivatedPlay
) {
this.setSelectedToNextTab(false);
}
}
this.slideTimeout = setTimeout(
this.rotateSlides.bind(this),
this.timeInterval
);
};
CarouselTablist.prototype.updatePlaying = function (play) {
this.isPlayingEnabled = play;
if (play) {
this.pausePlayButtonNode.setAttribute('aria-label', this.pauseLabel);
this.pausePlayButtonNode.classList.remove('play');
this.pausePlayButtonNode.classList.add('pause');
this.liveRegionNode.setAttribute('aria-live', 'off');
} else {
this.pausePlayButtonNode.setAttribute('aria-label', this.playLabel);
this.pausePlayButtonNode.classList.remove('pause');
this.pausePlayButtonNode.classList.add('play');
this.liveRegionNode.setAttribute('aria-live', 'polite');
}
};
/* Event Handlers */
CarouselTablist.prototype.handleImageLinkFocus = function () {
this.liveRegionNode.classList.add('focus');
};
CarouselTablist.prototype.handleImageLinkBlur = function () {
this.liveRegionNode.classList.remove('focus');
};
CarouselTablist.prototype.handleMouseOver = function (event) {
if (!this.pausePlayButtonNode.contains(event.target)) {
this.hasHover = true;
}
};
CarouselTablist.prototype.handleMouseOut = function () {
this.hasHover = false;
};
/* EVENT HANDLERS */
CarouselTablist.prototype.handlePausePlayButtonClick = function () {
this.hasUserActivatedPlay = !this.isPlayingEnabled;
this.updatePlaying(!this.isPlayingEnabled);
};
/* Event Handlers for Tabs*/
CarouselTablist.prototype.handleTabKeydown = function (event) {
var flag = false;
switch (event.key) {
case 'ArrowRight':
this.setSelectedToNextTab(true);
flag = true;
break;
case 'ArrowLeft':
this.setSelectedToPreviousTab(true);
flag = true;
break;
case 'Home':
this.setSelectedTab(0, true);
flag = true;
break;
case 'End':
this.setSelectedTab(this.tabNodes.length - 1, true);
flag = true;
break;
default:
break;
}
if (flag) {
event.stopPropagation();
event.preventDefault();
}
};
CarouselTablist.prototype.handleTabClick = function (event) {
var index = this.tabNodes.indexOf(event.currentTarget);
this.setSelectedTab(index, true);
};
CarouselTablist.prototype.handleTabFocus = function () {
this.tablistNode.classList.add('focus');
this.liveRegionNode.setAttribute('aria-live', 'polite');
this.hasFocus = true;
};
CarouselTablist.prototype.handleTabBlur = function () {
this.tablistNode.classList.remove('focus');
if (this.playState) {
this.liveRegionNode.setAttribute('aria-live', 'off');
}
this.hasFocus = false;
};
/* Event Handlers for Tabpanels*/
CarouselTablist.prototype.handleTabpanelFocusIn = function () {
this.hasFocus = true;
};
CarouselTablist.prototype.handleTabpanelFocusOut = function () {
this.hasFocus = false;
};
/* Initialize Carousel Tablists and options */
window.addEventListener(
'load',
function () {
var carouselEls = document.querySelectorAll('.carousel-tablist');
var carousels = [];
// set example behavior based on
// default setting of the checkboxes and the parameters in the URL
// update checkboxes based on any corresponding URL parameters
var checkboxes = document.querySelectorAll(
'.carousel-options input[type=checkbox]'
);
var urlParams = new URLSearchParams(location.search);
var carouselOptions = {};
// initialize example features based on
// default setting of the checkboxes and the parameters in the URL
// update checkboxes based on any corresponding URL parameters
checkboxes.forEach(function (checkbox) {
var checked = checkbox.checked;
if (urlParams.has(checkbox.value)) {
var urlParam = urlParams.get(checkbox.value);
if (typeof urlParam === 'string') {
checked = urlParam === 'true';
checkbox.checked = checked;
}
}
carouselOptions[checkbox.value] = checkbox.checked;
});
carouselEls.forEach(function (node) {
carousels.push(new CarouselTablist(node, carouselOptions));
});
// add change event to checkboxes
checkboxes.forEach(function (checkbox) {
var updateEvent;
switch (checkbox.value) {
case 'moreaccessible':
updateEvent = 'setAccessibleStyling';
break;
case 'norotate':
updateEvent = 'enableOrDisableAutoRotation';
break;
}
// update the carousel behavior and URL when a checkbox state changes
checkbox.addEventListener('change', function (event) {
urlParams.set(event.target.value, event.target.checked + '');
window.history.replaceState(
null,
'',
window.location.pathname + '?' + urlParams
);
if (updateEvent) {
carousels.forEach(function (carousel) {
carousel[updateEvent](event.target.checked);
});
}
});
});
},
false
);
まとめ
この記事では、「カルーセル」に焦点を当てて、アクセシビリティを意識したカルーセルの実装方法とカルーセルで意識した方がいいアクセシビリティを解説しました。
ぜひこの記事をストックして、カルーセルを実装する時にアクセシビリティについて思い出してもらえると嬉しいです。
Advent Calendar 2023では、他のコンポーネントにも焦点を当てて、アクセシビリティについても解説しているので、ぜひ購読していてください。
最後まで読んでくださってありがとうございます!
普段はデザインやフロントエンドを中心にQiitaに記事を投稿しているので、ぜひQiitaのフォローとX(Twitter)のフォローをお願いします。