カルーセル
stimeo--carousel
止められる自動再生つきのスライドショー。スライドを選ぶボタンはキーボードで渡り歩けます。
WAI-ARIA の Carousel パターンに沿ったスライドショーです。クリックもキーも全体でまとめて受けるので、スライドごとに設定を書く必要はありません。あとから足したスライドも、現れた瞬間から動きます。表示中でないスライドは Tab の順番からも外れるので、見えていないリンクにフォーカスが移ることはありません。スライドを選ぶボタンを置けば、キーボードで渡り歩けます。置かなければ前後のボタンだけで送る形になります。自動再生では、利用者の意思と一時的な停止を分けて扱います。完全に止められるのは再生ボタンだけで、ホバーやフォーカス、別のタブへ移ったときは、その間だけ止まって離れれば再開します。動きを減らす設定の利用者には、自動再生そのものを切ります。押せないボタンは押せないと伝わり、自動で動いている間は読み上げが騒がしくならないよう配慮します。
アクセシビリティ配慮(WCAG 2.2.2)として、自動再生を確実に止める手段は再生ボタンです。カルーセルにマウスを乗せる、フォーカスを入れる、別のタブへ移る、といった操作は動きを一時的に止めるだけなので、操作の邪魔にならず、離れれば再び動き始めます。そのため、ポインタをカルーセルに乗せたまま再生ボタンを押しても、離れるまでは動き出しません。
キーボード操作
| キー | 動作 |
|---|---|
| Enter / Space | ボタンを実行(前へ/次へ/再生停止/スライド選択)。 |
| → / ← | 次/前のスライドピッカーへフォーカス移動(ロービング)。RTL では左右が入れ替わる。 |
| Home / End | 先頭/末尾のスライドピッカーへフォーカス移動する。選択が変わるのは Enter か Space を押したときで、キー自体では変わらない。修飾キー付きの Home/End(Control+Home など)はブラウザへ渡す。 |
<%# Markup for the carousel demo.
The library handles slide advance, autoplay, syncing the current slide's
data-state / hidden / inert, the pickers' (tabs) aria-selected and roving, the
aria-disabled of controls that cannot be reached, and the viewport's aria-live.
There is no data-action anywhere: the controller delegates clicks, picker keys,
hover, and focus from its own element, so the markup only declares targets.
Autoplay deliberately demonstrates WCAG 2.2.2: the play toggle is what stops it for
good, while hovering, focusing, or backgrounding the tab only suspends it until that
condition ends. This is spelled out in the on-screen note below the carousel.
Transitions and layout are the consumer's CSS. %>
<section
class="carousel"
data-controller="stimeo--carousel"
aria-roledescription="carousel"
aria-label="<%= t("components.carousel.demo.label") %>"
data-stimeo--carousel-autoplay-value="false"
data-stimeo--carousel-interval-value="2500"
data-stimeo--carousel-loop-value="true">
<div class="carousel__bar">
<button
type="button"
class="carousel__play"
aria-label="<%= t("components.carousel.demo.autoplay") %>"
data-stimeo--carousel-target="playToggle">
<%# The library owns aria-pressed (the autoplay Value is the intent it mirrors);
demo.css swaps the glyph off it so the control visibly reflects play (❚❚) vs.
paused (▶). The icons are decorative; the accessible name comes from aria-label
above. %>
<span class="carousel__play-icon carousel__play-icon--play" aria-hidden="true">▶</span>
<span class="carousel__play-icon carousel__play-icon--pause" aria-hidden="true">❚❚</span>
</button>
<%# A visual read-out only. The library makes the viewport the live region, so a
second one here would make a screen reader announce the change twice. %>
<p
class="carousel__status"
data-carousel-status
data-template="<%= t("components.carousel.demo.status_template") %>"
data-playing-suffix="<%= t("components.carousel.demo.status_playing") %>"></p>
</div>
<div class="carousel__viewport" data-stimeo--carousel-target="viewport">
<% t("components.carousel.demo.slides").each_with_index do |slide, i| %>
<div
id="carousel-slide-<%= i + 1 %>"
class="carousel__slide"
role="tabpanel"
aria-roledescription="slide"
aria-label="<%= "#{i + 1} of #{t('components.carousel.demo.slides').size}" %>"
aria-labelledby="carousel-dot-<%= i + 1 %>"
data-stimeo--carousel-target="slide"
<%= "hidden inert" if i.positive? %>>
<h3 class="carousel__title"><%= slide[:title] %></h3>
<p><%= slide[:body] %></p>
</div>
<% end %>
</div>
<div class="carousel__nav">
<button type="button" class="carousel__arrow"
aria-label="<%= t("components.carousel.demo.prev") %>"
data-stimeo--carousel-target="prev">‹</button>
<button type="button" class="carousel__arrow"
aria-label="<%= t("components.carousel.demo.next") %>"
data-stimeo--carousel-target="next">›</button>
</div>
<div class="carousel__dots" role="tablist"
aria-label="<%= t("components.carousel.demo.tablist") %>">
<% t("components.carousel.demo.slides").each_with_index do |slide, i| %>
<button
id="carousel-dot-<%= i + 1 %>"
class="carousel__dot"
role="tab"
aria-selected="<%= i.zero? %>"
aria-controls="carousel-slide-<%= i + 1 %>"
aria-label="<%= slide[:title] %>"
tabindex="<%= i.zero? ? 0 : -1 %>"
data-stimeo--carousel-target="picker"></button>
<% end %>
</div>
</section>
<%# On-screen explanation of the deliberate autoplay accessibility behavior, so the
pause-on-hover / hard-stop-on-focus is understood as intentional (WCAG 2.2.2), not a bug.
Kept outside the <section> so reading it doesn't itself pause the carousel. %>
<p class="carousel__hint"><%= t("components.carousel.demo.hint") %></p>
/*
* Presentation-only styles for the carousel demo.
* The library toggles the current slide's data-state / hidden, the pickers'
* aria-selected / tabindex, and the play toggle's aria-pressed. The visual
* switching and styling are built here.
*/
.carousel {
max-width: 28rem;
padding: 0.75rem;
border: 1px solid var(--border-strong);
border-radius: 0.75rem;
background: var(--surface, var(--surface-card));
}
.carousel__bar {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.6rem;
}
.carousel__play {
padding: 0.3rem 0.55rem;
border: 1px solid var(--border-strong);
border-radius: 0.4rem;
background: none;
font: inherit;
cursor: pointer;
}
/* A fixed dark accent, not the theme-aware `--accent-700`: this text sits on
`--vital-100`, a raw ramp value that stays light in **both** themes, so a token that
lightens for dark would fail there (measured 2.5:1). */
.carousel__play[aria-pressed="true"] {
border-color: var(--accent, var(--color-primary));
background: var(--vital-100);
color: var(--vital-700);
}
/* Swap the glyph off aria-pressed: ▶ when paused, ❚❚ while autoplay runs. */
.carousel__play-icon--pause {
display: none;
}
.carousel__play[aria-pressed="true"] .carousel__play-icon--play {
display: none;
}
.carousel__play[aria-pressed="true"] .carousel__play-icon--pause {
display: inline;
}
.carousel__status {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
/* The library writes aria-disabled on a control that has nowhere left to go: the
play toggle of a carousel that cannot rotate, and the arrow a non-looping
carousel has run out of slides for. The control keeps its focus (that is why it
is not natively disabled), so only its appearance changes. */
.carousel__play[aria-disabled="true"],
.carousel__arrow[aria-disabled="true"] {
opacity: 0.45;
cursor: default;
}
/* Explains the deliberate WCAG 2.2.2 autoplay behavior (pause on hover / stop on focus). */
.carousel__hint {
max-width: 28rem;
margin: 0.6rem 0 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-muted);
}
.carousel__viewport {
min-height: 6rem;
padding: 1rem;
border-radius: 0.5rem;
background: var(--surface-subtle);
}
/* Inactive slides carry both hidden and inert, so only the visible one needs styling. */
.carousel__slide[data-state="active"] {
animation: carousel-fade 0.25s ease;
}
@keyframes carousel-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.carousel__slide[data-state="active"] {
animation: none;
}
}
.carousel__title {
margin: 0 0 0.4rem;
font-size: 1rem;
}
.carousel__nav {
display: flex;
justify-content: space-between;
margin-top: 0.6rem;
}
.carousel__arrow {
width: 2rem;
height: 2rem;
border: 1px solid var(--border-strong);
border-radius: 50%;
background: none;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.carousel__dots {
display: flex;
justify-content: center;
gap: 0.4rem;
margin-top: 0.7rem;
}
.carousel__dot {
width: 0.7rem;
height: 0.7rem;
padding: 0;
border: 1px solid var(--border-interactive);
border-radius: 50%;
background: none;
cursor: pointer;
}
.carousel__dot[aria-selected="true"] {
border-color: var(--accent, var(--color-primary));
background: var(--accent, var(--color-primary));
}
.carousel__dot:focus-visible,
.carousel__arrow:focus-visible,
.carousel__play:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: 2px;
}
/* The dots are tiny, so make the focused one pop — this makes arrow-key roving between
pickers (focus moves; Enter/Space or click then switches the slide) clearly visible. */
.carousel__dot:focus-visible {
outline-offset: 3px;
transform: scale(1.4);
border-color: var(--accent, var(--color-primary));
}
// Demo that subscribes to carousel events (consumer-side JS).
//
// The core controller (stimeo--carousel) handles slide advance, autoplay, and state
// sync, firing stimeo--carousel:change on slide change, :reconcile when the slide set
// itself moves the position or the total, and :play / :pause when autoplay starts and
// stops. Here we subscribe to those and show the current position and play state.
//
// The events are edges, so the starting values come from the state hooks instead:
// data-state on the root says whether it is rotating right now, and data-state on
// the slides says which one is showing. That is what makes the read-out correct
// straight after a Turbo restore, where the controller reconnects — and fires its
// event — before this module runs again.
//
// The read-out is visual only: the controller makes the viewport the live region,
// so a second one here would announce every change twice. Layout is CSS.
//
// For the bilingual catalog the copy isn't hardcoded: it uses the localized template
// the ERB passes (the "{position}" token in data-template and data-playing-suffix),
// and JS only fills in the position (number) and play state.
document.querySelectorAll('[data-controller~="stimeo--carousel"]').forEach((carousel) => {
const status = carousel.querySelector('[data-carousel-status]');
if (!status) return;
const template = status.dataset.template || '{position}';
const playingSuffix = status.dataset.playingSuffix || '';
let position = '';
// Read the current run state rather than waiting for the next play/pause edge:
// after a Turbo restore the controller has already reconnected (and already
// fired the event) by the time this module subscribes.
let playing = carousel.dataset.state === 'playing';
// Read the starting position out of the state hooks rather than assuming the first
// slide: a Turbo Drive restore hands back the slide the reader was actually on.
const readPosition = () => {
const slides = [...carousel.querySelectorAll('[data-stimeo--carousel-target="slide"]')];
const index = slides.findIndex((slide) => slide.dataset.state === 'active');
position = `${(index === -1 ? 0 : index) + 1} / ${slides.length}`;
};
const render = () => {
status.textContent = template.replace('{position}', position) + (playing ? playingSuffix : '');
};
const showDetail = (event) => {
position = `${event.detail.index + 1} / ${event.detail.total}`;
render();
};
carousel.addEventListener('stimeo--carousel:change', showDetail);
carousel.addEventListener('stimeo--carousel:reconcile', showDetail);
carousel.addEventListener('stimeo--carousel:play', () => {
playing = true;
render();
});
carousel.addEventListener('stimeo--carousel:pause', () => {
playing = false;
render();
});
readPosition();
render();
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--carousel"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
slide
必須
|
個々のスライドパネル。アクティブなものだけが表示・フォーカス対象になる。必須。 | data-stimeo--carousel-target="slide" |
viewport
|
スライドを内包するビューポート要素。コントローラがここをライブリージョンにする。 | data-stimeo--carousel-target="viewport" |
prev
|
前のスライドへ移動するボタン。next と対で置く。 | data-stimeo--carousel-target="prev" |
next
|
次のスライドへ移動するボタン。prev と対で置く。 | data-stimeo--carousel-target="next" |
picker
|
対応スライドを選択するタブ。aria-selectedとロービングtabindexを持つ。任意で、置かない場合スライドは tabpanel ではなく group になる。 |
data-stimeo--carousel-target="picker" |
playToggle
|
再生/一時停止ボタン。aria-pressedが自動再生の意思と連動する。autoplay を使う構成では必須。 |
data-stimeo--carousel-target="playToggle" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
autoplay
|
自動再生の意思であり、その単一の真実源。togglePlay はここへ書き戻す(既定false)。 | data-stimeo--carousel-autoplay-value |
interval
|
自動再生の間隔(ミリ秒)。有限かつ0より大きい値であること。それ以外は既定値へ落ちる(既定5000)。 | data-stimeo--carousel-interval-value |
loop
|
前後移動が端で循環するか(既定true)。 | data-stimeo--carousel-loop-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
goto
|
操作されたpickerに対応するスライドへ移動する。 | stimeo--carousel#goto |
next
|
次のスライドへ進む。 | stimeo--carousel#next |
onPickerKeydown
|
pickerのキー操作。矢印・Home・Endはいずれもフォーカス移動のみ。 | stimeo--carousel#onPickerKeydown |
pause
|
回転を一時停止する。フォーカス系イベントならフォーカス由来として、それ以外はポインタ由来として記録する。再生の意思は変えない。 | stimeo--carousel#pause |
prev
|
前のスライドへ戻る。 | stimeo--carousel#prev |
resume
|
対応する一時停止を解除する。カルーセル内のコントロール間のフォーカス移動では解除しない。 | stimeo--carousel#resume |
togglePlay
|
利用者の明示操作で自動再生の意思を反転し、Value へ書き戻す。 | stimeo--carousel#togglePlay |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
利用者の操作でアクティブスライドが変わると発火する(利用者が始めた自動再生の1コマ送りを含む)。detail に index と total を含む。 | stimeo--carousel:change |
pause
|
自動再生タイマーが停止すると発火する。 | stimeo--carousel:pause |
play
|
自動再生タイマーが開始すると発火する。 | stimeo--carousel:play |
reconcile
|
コントローラ自身が表示位置を決め直したときに発火する(slide / picker の増減、または要素を保持したままの状態属性の書き替えで、表示位置または総数が変わったとき)。detail は change と同じ形。 | stimeo--carousel:reconcile |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-state |
ルート要素 | "playing" / "paused"。いま実際に回っているか。play / pause イベントはエッジなので、connect より後に購読を始めた利用側(Turbo 復帰では必ずそうなる)はここから現在値を読む。 |
data-state |
スライド | "active" / "inactive"。 |
hidden + inert |
非アクティブスライド | 非表示にしフォーカス順から除外する。inert があるので、利用側 CSS が display を上書きしてスライドをトラックとして並べても除外は保たれる。 |
aria-selected |
ピッカー(タブ) | 表示中のスライドのピッカーに "true"、ほかには "false"。最初に見せたいスライドはサーバー側で "true" を書いておける(複数書いた場合は先頭が勝つ)。 |
tabindex |
ピッカー(タブ) | 選択中のピッカーが 0、他は -1(ロービング)。 |
aria-pressed |
再生トグル | 自動再生の意思がオンなら true。ホバー・フォーカス・タブの非表示は回転を止めるだけで、この値は変えない。 |
aria-disabled |
再生トグル・前へ・次へ | 送り先の無いコントロールに true を書く(回せないカルーセルのトグル、ループしない構成で端に達した方向のボタン)。到達できるようになれば属性ごと外す。 |
aria-live |
ビューポート | 回転中は "off"、停止中は "polite"。あわせて aria-atomic="false" を書き、変わったスライドだけが読まれるようにする。 |