タブをクリックするとカルーセルが該当のカードへ移動し、逆にカルーセルをスワイプすると、タブや進捗バー・カウンターが連動して切り替わる。そんな「タブとカルーセルを融合させた」UIの作成について解説しています。
見た目はタブUIですが、中身はSwiperのカルーセルが1つあるだけです。タブは「特定のスライドへ移動するボタン」として、進捗バーとカウンターは「今どのスライドを見ているかの表示」として機能させています。
タブの数だけカードがある、というシンプルな構成にしています。実際のプロジェクトでは、1つのタブに複数のカードが属していても、考え方は同じです。
SwiperのslideTo()とslideChangeイベント
Swiperのインスタンスは、プログラム側から操作するためのメソッドと、状態の変化を検知するためのイベントの両方を持っています。今回のポイントは、この2つを組み合わせることです。
基本的な使い方は、以下のようになります:
const swiper = new Swiper('.mySwiper', {
slidesPerView: 3,
on: {
slideChange: function () {
console.log('現在のスライド番号:', this.activeIndex); // ユーザーがスワイプした時にも呼ばれる
},
},
});
// 外部のボタンなどから、特定のスライドへ移動させる
swiper.slideTo(2);
swiper.slideTo(番号)を呼ぶと、その番号のスライドまでプログラム側から移動させられます。逆に、ユーザーがスワイプ操作でスライドを変えたときは、onに渡したslideChangeが呼ばれます。この2つを組み合わせることで、「外部のボタン(タブ)→カルーセル」「カルーセル→外部の表示(タブ・進捗バー)」という、双方向の連動が作れます。
タブをクリックしたときにslideTo()を呼ぶと、その移動によってslideChangeイベントも発火します。なので、タブ・進捗バー・カウンターの更新処理は、slideChange側に1箇所だけ書いておけば十分です。
タブ×カルーセルのサンプル
それでは、早速サンプルです。
タブをクリックすると、対応するカードまでカルーセルが移動します。逆に、カードを左右にスワイプすると、選択されているタブや、上部の進捗バー・カウンターも連動して切り替わります。
カードを左右にスライドすると、他の知識もご覧いただけます。
進捗バーの幅は「(現在のスライド番号 + 1) ÷ 全体の枚数」で計算しています。地味な処理ですが、こういう数値の見せ方ひとつで、UIの完成度がぐっと上がる気がしています。
実装の手順と方法
コードの詳細の前に、実装の手順と方法について解説していきます。
タブ(.ttc-tab)、進捗バー(.ttc-progress)、Swiperのカルーセル本体を、それぞれ用意します。タブにはdata-index属性で、対応するスライド番号を持たせています。
<div class="jsSample-tabCarousel2">
<div class="ttc-head">
<h3 class="ttc-title">知識をひらく</h3>
<p class="ttc-desc">実践の中で得た知見を、テーマごとにまとめています。</p>
</div>
<div class="ttc-tabsRow">
<div class="ttc-tabs">
<button class="ttc-tab is-active" data-index="0">基本設計</button>
<button class="ttc-tab" data-index="1">運用の工夫</button>
<button class="ttc-tab" data-index="2">チーム共有</button>
<button class="ttc-tab" data-index="3">改善の記録</button>
<button class="ttc-tab" data-index="4">ツール活用</button>
<button class="ttc-tab" data-index="5">プロジェクト管理</button>
</div>
<div class="ttc-nav">
<button class="ttc-prev" aria-label="前へ">←</button>
<span class="ttc-counter">01 / 06</span>
<button class="ttc-next" aria-label="次へ">→</button>
</div>
</div>
<div class="ttc-progress">
<div class="ttc-progress-fill"></div>
</div>
<div class="swiper ttcSwiper">
<div class="swiper-wrapper">
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">基本設計</span>
<h4 class="ttc-card-title">迷わず進めるための設計</h4>
<p class="ttc-card-desc">目的から逆算して、必要な情報と流れを整理しておきます。判断基準を先に言葉にしておくことで、手戻りが減り、チーム全体が同じ前提で進めやすくなります。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">運用の工夫</span>
<h4 class="ttc-card-title">小さく試して、早く学ぶ</h4>
<p class="ttc-card-desc">最初から完璧を目指すより、小さな仮説を立ててすぐに試す方が、学びを次の一手につなげやすくなります。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">チーム共有</span>
<h4 class="ttc-card-title">伝わる共有のしかた</h4>
<p class="ttc-card-desc">結論から先に伝え、背景・補足・次のアクションの順で共有すると、相手の理解がぐっと早くなります。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">改善の記録</span>
<h4 class="ttc-card-title">気づきを、資産に変える</h4>
<p class="ttc-card-desc">その場で感じた小さな違和感をメモしておくと、あとから振り返ったときに次の改善のヒントになります。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">ツール活用</span>
<h4 class="ttc-card-title">ツールは目的のあとで選ぶ</h4>
<p class="ttc-card-desc">「このツールを使いたい」から入るより、解決したいことを先に明確にしてから、合うツールを選ぶ方が失敗しにくいです。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">プロジェクト管理</span>
<h4 class="ttc-card-title">全体像を、常に見える化する</h4>
<p class="ttc-card-desc">進捗や課題をいつでも見える状態にしておくことで、チーム全員が同じ地図を見ながら進められます。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
</div>
</div>
<p class="ttc-caption">カードを左右にスライドすると、他の知識もご覧いただけます。</p>
</div>
タブの選択状態・進捗バーの塗り・カードの見た目を整えます。Swiper自体のレイアウト崩れを防ぐため、CDNのCSSも読み込んでおきます。
.jsSample-tabCarousel2 {
margin-top: 20px;
padding: 32px;
background: #fafafa;
border-radius: 12px;
}
.ttc-title { margin: 0 0 4px; font-size: 1.4rem; }
.ttc-desc { margin: 0 0 24px; color: #666; font-size: 0.9rem; }
.ttc-tabsRow {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.ttc-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
.ttc-tab {
padding: 8px 16px;
border: none;
border-radius: 999px;
background: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: 0.2s;
}
.ttc-tab.is-active { background: #111; color: #fff; }
.ttc-nav { display: flex; align-items: center; gap: 12px; font-size: 0.85rem; }
.ttc-prev, .ttc-next {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid #ddd;
background: #fff;
cursor: pointer;
}
.ttc-next { background: #111; color: #fff; border-color: #111; }
.ttc-progress {
height: 2px;
background: #e5e5e5;
margin-bottom: 24px;
border-radius: 999px;
overflow: hidden;
}
.ttc-progress-fill {
height: 100%;
width: 0%;
background: #111;
transition: width 0.3s ease;
}
.ttc-card {
height: auto;
padding: 24px;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
}
.ttc-card-tag {
align-self: flex-start;
padding: 4px 10px;
margin-bottom: 12px;
border-radius: 999px;
background: #f0f0f0;
font-size: 0.75rem;
font-weight: 600;
}
.ttc-card-title { margin: 0 0 10px; font-size: 1.05rem; }
.ttc-card-desc { margin: 0 0 16px; font-size: 0.85rem; color: #555; line-height: 1.7; flex-grow: 1; }
.ttc-card-link { font-size: 0.85rem; font-weight: 600; border-top: 1px solid #eee; padding-top: 12px; }
.ttc-caption { margin-top: 16px; font-size: 0.8rem; color: #999; text-align: center; }
SwiperのslideChangeイベントでタブ・カウンター・進捗バーを更新し、逆にタブをクリックしたときはslideTo()でカルーセル側を動かします。
document.addEventListener('DOMContentLoaded', function () {
const container = document.querySelector('.jsSample-tabCarousel2');
const tabs = container.querySelectorAll('.ttc-tab');
const counter = container.querySelector('.ttc-counter');
const progressFill = container.querySelector('.ttc-progress-fill');
const total = tabs.length;
const swiper = new Swiper(container.querySelector('.ttcSwiper'), {
slidesPerView: 1.15,
spaceBetween: 16,
breakpoints: {
768: { slidesPerView: 2.3, spaceBetween: 20 },
1024: { slidesPerView: 3.2, spaceBetween: 24 },
},
on: {
slideChange: syncUI,
},
});
// カルーセル側の状態を、タブ・カウンター・進捗バーに反映する
function syncUI() {
const index = swiper.activeIndex;
tabs.forEach((tab, i) => {
tab.classList.toggle('is-active', i === index);
});
counter.textContent =
String(index + 1).padStart(2, '0') + ' / ' + String(total).padStart(2, '0');
progressFill.style.width = ((index + 1) / total) * 100 + '%';
}
// タブをクリックしたら、該当スライドへ移動する
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
swiper.slideTo(Number(tab.dataset.index));
});
});
container.querySelector('.ttc-prev').addEventListener('click', () => swiper.slidePrev());
container.querySelector('.ttc-next').addEventListener('click', () => swiper.slideNext());
syncUI(); // 初期状態を反映しておく
});
ざっくりとしたコードの解説
コードは、HTML・CSS・JavaScriptの3種類です。ざっくりですが、順に解説していきます。
HTML
タブのボタンにはdata-index属性で対応するスライド番号を、カルーセル本体はSwiperが求める.swiper → .swiper-wrapper → .swiper-slideという構造にしています。
<div class="jsSample-tabCarousel2">
<div class="ttc-head">
<h3 class="ttc-title">知識をひらく</h3>
<p class="ttc-desc">実践の中で得た知見を、テーマごとにまとめています。</p>
</div>
<div class="ttc-tabsRow">
<div class="ttc-tabs">
<button class="ttc-tab is-active" data-index="0">基本設計</button>
<button class="ttc-tab" data-index="1">運用の工夫</button>
<button class="ttc-tab" data-index="2">チーム共有</button>
<button class="ttc-tab" data-index="3">改善の記録</button>
<button class="ttc-tab" data-index="4">ツール活用</button>
<button class="ttc-tab" data-index="5">プロジェクト管理</button>
</div>
<div class="ttc-nav">
<button class="ttc-prev" aria-label="前へ">←</button>
<span class="ttc-counter">01 / 06</span>
<button class="ttc-next" aria-label="次へ">→</button>
</div>
</div>
<div class="ttc-progress">
<div class="ttc-progress-fill"></div>
</div>
<div class="swiper ttcSwiper">
<div class="swiper-wrapper">
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">基本設計</span>
<h4 class="ttc-card-title">迷わず進めるための設計</h4>
<p class="ttc-card-desc">目的から逆算して、必要な情報と流れを整理しておきます。判断基準を先に言葉にしておくことで、手戻りが減り、チーム全体が同じ前提で進めやすくなります。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">運用の工夫</span>
<h4 class="ttc-card-title">小さく試して、早く学ぶ</h4>
<p class="ttc-card-desc">最初から完璧を目指すより、小さな仮説を立ててすぐに試す方が、学びを次の一手につなげやすくなります。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">チーム共有</span>
<h4 class="ttc-card-title">伝わる共有のしかた</h4>
<p class="ttc-card-desc">結論から先に伝え、背景・補足・次のアクションの順で共有すると、相手の理解がぐっと早くなります。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">改善の記録</span>
<h4 class="ttc-card-title">気づきを、資産に変える</h4>
<p class="ttc-card-desc">その場で感じた小さな違和感をメモしておくと、あとから振り返ったときに次の改善のヒントになります。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">ツール活用</span>
<h4 class="ttc-card-title">ツールは目的のあとで選ぶ</h4>
<p class="ttc-card-desc">「このツールを使いたい」から入るより、解決したいことを先に明確にしてから、合うツールを選ぶ方が失敗しにくいです。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
<div class="swiper-slide ttc-card">
<span class="ttc-card-tag">プロジェクト管理</span>
<h4 class="ttc-card-title">全体像を、常に見える化する</h4>
<p class="ttc-card-desc">進捗や課題をいつでも見える状態にしておくことで、チーム全員が同じ地図を見ながら進められます。</p>
<span class="ttc-card-link">詳しく読む →</span>
</div>
</div>
</div>
<p class="ttc-caption">カードを左右にスライドすると、他の知識もご覧いただけます。</p>
</div>
CSS
タブの選択状態は.is-activeクラスの有無で、進捗バーは.ttc-progress-fillのwidthで表現しています。どちらも、実際の見た目の更新はJavaScript側から行います。
.jsSample-tabCarousel2 {
margin-top: 20px;
padding: 32px;
background: #fafafa;
border-radius: 12px;
}
.ttc-title { margin: 0 0 4px; font-size: 1.4rem; }
.ttc-desc { margin: 0 0 24px; color: #666; font-size: 0.9rem; }
.ttc-tabsRow {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.ttc-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
.ttc-tab {
padding: 8px 16px;
border: none;
border-radius: 999px;
background: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: 0.2s;
}
.ttc-tab.is-active { background: #111; color: #fff; }
.ttc-nav { display: flex; align-items: center; gap: 12px; font-size: 0.85rem; }
.ttc-prev, .ttc-next {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid #ddd;
background: #fff;
cursor: pointer;
}
.ttc-next { background: #111; color: #fff; border-color: #111; }
.ttc-progress {
height: 2px;
background: #e5e5e5;
margin-bottom: 24px;
border-radius: 999px;
overflow: hidden;
}
.ttc-progress-fill {
height: 100%;
width: 0%;
background: #111;
transition: width 0.3s ease;
}
.ttc-card {
height: auto;
padding: 24px;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
}
.ttc-card-tag {
align-self: flex-start;
padding: 4px 10px;
margin-bottom: 12px;
border-radius: 999px;
background: #f0f0f0;
font-size: 0.75rem;
font-weight: 600;
}
.ttc-card-title { margin: 0 0 10px; font-size: 1.05rem; }
.ttc-card-desc { margin: 0 0 16px; font-size: 0.85rem; color: #555; line-height: 1.7; flex-grow: 1; }
.ttc-card-link { font-size: 0.85rem; font-weight: 600; border-top: 1px solid #eee; padding-top: 12px; }
.ttc-caption { margin-top: 16px; font-size: 0.8rem; color: #999; text-align: center; }
JavaScript
Swiperのon: { slideChange }で、現在のスライド番号(swiper.activeIndex)を取得し、それをもとにタブのクラス・カウンターの文字・進捗バーの幅を、まとめて更新するsyncUI()という関数を用意しています。
タブがクリックされたときは、swiper.slideTo(番号)を呼ぶだけです。あとはslideChangeが自動的に発火してsyncUI()が呼ばれるので、タブ側で個別に見た目を更新する処理を書く必要がありません。
document.addEventListener('DOMContentLoaded', function () {
const container = document.querySelector('.jsSample-tabCarousel2');
const tabs = container.querySelectorAll('.ttc-tab');
const counter = container.querySelector('.ttc-counter');
const progressFill = container.querySelector('.ttc-progress-fill');
const total = tabs.length;
const swiper = new Swiper(container.querySelector('.ttcSwiper'), {
slidesPerView: 1.15,
spaceBetween: 16,
breakpoints: {
768: { slidesPerView: 2.3, spaceBetween: 20 },
1024: { slidesPerView: 3.2, spaceBetween: 24 },
},
on: {
slideChange: syncUI,
},
});
// カルーセル側の状態を、タブ・カウンター・進捗バーに反映する
function syncUI() {
const index = swiper.activeIndex;
tabs.forEach((tab, i) => {
tab.classList.toggle('is-active', i === index);
});
counter.textContent =
String(index + 1).padStart(2, '0') + ' / ' + String(total).padStart(2, '0');
progressFill.style.width = ((index + 1) / total) * 100 + '%';
}
// タブをクリックしたら、該当スライドへ移動する
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
swiper.slideTo(Number(tab.dataset.index));
});
});
container.querySelector('.ttc-prev').addEventListener('click', () => swiper.slidePrev());
container.querySelector('.ttc-next').addEventListener('click', () => swiper.slideNext());
syncUI(); // 初期状態を反映しておく
});
各コードの詳細はコメントアウトにも記載しているので、チェックしてみてください。
さいごに
今回は、タブとSwiperのカルーセルを組み合わせた、進捗表示付きのUIの作り方についての解説でした。
今回は1タブ=1カードのシンプルな構成にしましたが、1つのタブに複数枚のカードを対応させたい場合は、data-indexの代わりに「そのタブに属する最初のスライド番号」を持たせておき、slideChange側では「現在のスライドがどのタブの範囲に含まれるか」を判定するロジックに変えると対応できます。
是非あわせてチェックしてみてください。
