SMOUTリニューアルでカルーセルライブラリにEmbla Carouselを採用した話

この記事は【カヤック】面白法人グループ Advent Calendar 2024の21日目の記事です 🍊

みなさま初めまして!ウェブフロントエンジニアのbobです!

www.kayac.com

今回は、カヤックが提供している移住スカウトサービスSMOUTを先日一部リニューアルをした際に、カルーセル周りの刷新・実装をしましたのでそこで得た知見などを書かせていただきます。

この度移住スカウトサービスSMOUTを大リニューアルしました!

つい先日、カヤックが提供している移住スカウトサービス「SMOUT」を一部リニューアルしたものをリリースいたしました!
SMOUTは移住したい人と移住してきて欲しい人のマッチングをお手伝いするWebサービスで、多くの自治体様から個人の方まで幅広い方に使っていただいています!

SMOUTは2018年にリリースされて、2024年12月現在、リリースから約6年6ヶ月の歳月が経ちました。
サービスが成長するにつれて掲載されるプロジェクト数が増え徐々に情報が煩雑化してしまい、移住を考える方にとってどこから情報を取得していいのか分かりにくい状態が発生してしまいました。
こういった背景もあり、「検索する」から「偶然出会う」をテーマに興味のありそうなプロジェクトに気づいたら辿り着いているという体験を支援できるようなサイトに生まれ変わりました。

ぜひこちらからSMOUTを見ていただけますと嬉しいです!リリース記事はこちらから

smout.jp

フロントエンドもサーバーサイドも様々な新規追加や機能改修、リファクタリングまで行いました。
今回のアドベントカレンダーの記事では、いくつかのトピックについて書こうと考えていましたが内容が分散してしまうため、特にフロントエンドのカルーセル周りの刷新について書かせていただきます。

技術スタック

まずは前提となるSMOUTのフロントエンドで採用されている技術スタックについてです。
フレームワークについてはNuxt3.14、言語にはTypeScript5.7を使用しています。また、Vueの記述ではOptions APIではなくComposition APIを採用しています。
TypeScriptは特にウェブアプリケーションを作成する際にはほぼ必須な技術として定着してきましたね。

SMOUTのKVや一部ページにカルーセルやスワイパーが実装されています。リニューアル以前はSwiperというカルーセルの実装では非常に有名なライブラリを導入していました。
しかし、Swiperはメジャーアップデートの頻度が高く、アップデートの工数が大きいという課題がありました。そして、Swiperのアップデートが後手後手に回り続けた結果、最新のバージョンと現行のバージョンとで大きな乖離が発生してしまいました。

ここで考えられる選択肢は3つあり、1つ目はこのSwiperを一生懸命アップデートする、2つ目は脱Swiperを目指し別のライブラリを採択する、3つ目は自前で実装するという方針です。
以前、別の案件で使用したことがあるEmbla Carouselというライブラリが非常に使い勝手が良く、行いたい実装もすごくリッチなことをしたいわけではないので、SwiperをやめEmbla Carouselを使用するという方針に踏み切りました。
自前で実装するという方針も考えましたが、カルーセルの実装は無限ループさせたりスマホ対応させたり実装難易度も高く、複数人で開発する上で難しい点も多く見送りました。

www.embla-carousel.com

実際にEmbla Carouselが導入されている部分

Embla Carouselのいい点は以下の点かと感じました。

  • ReactやVueでも簡単に導入が可能。
  • Custom hooks的な使い方(Vueではcomposables)がシンプルで使いやすい。また、TypeScriptにも対応している。
  • CSSでカルーセルの見た目を整えられて、ライブラリのoptionに色々値を渡したりしなくて良い仕組み。レスポンシブの記述も行いやすい。
  • 読み込まれるjsがSwiperに比べて軽量。
  • autoplayなどはプラグインなどで対応可能。
  • 学習コストが低い

npm trendsで有名なカルーセル系のライブラリと比較してみると、Embla Carouselはswiperに次いで2位の位置にいます(2024/12/21現在)。
2024年に入ってからグッと人気が伸びていますね!いずれSwiperにも追いつきそうな勢いがあります。

embla-carousel vs keen-slider vs slick-carousel vs swiper | npm trends

Embla Carouselはこういった支持の厚さや開発の盛んさをみて、今後の保守性にも期待して導入することを決めました。

実装例・ハマりポイント

簡単に、Vue3での実装例とハマりポイントを共有いたします。
まずは、公式サイトにも掲載されているような簡単なカルーセルの実装をしてみようと思います。
環境はnpm, Nuxt3, Typescriptを使用します。

Nuxtの環境構築は完了している前提で、まずはEmbla Carouselのinstallをしていきます。

npm install embla-carousel-vue --save

見やすさを重視して、すべて1コンポーネントの中で実装をします。実際にはカードコンポーネントは分けて実装しています。

まずはミニマムなカルーセルの実装例になります。

<script setup lang="ts">
import emblaCarouselVue from 'embla-carousel-vue';

const [emblaRef] = emblaCarouselVue();
</script>

<template>
  <div>
    <div ref="emblaRef" class="slider">
      <div class="container">
        <div style="background-color: #f1c1c1" class="card">Card 1</div>
        <div style="background-color: #f1e0c1" class="card">Card 2</div>
        <div style="background-color: #d9f1c1" class="card">Card 3</div>
        <div style="background-color: #c1f1d9" class="card">Card 4</div>
        <div style="background-color: #c1def1" class="card">Card 5</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.slider {
  overflow: hidden;
  width: 400px;
}
.container {
  display: flex;
}
.card {
  flex: 0 0 auto;
  width: 400px;
  height: 200px;
  border-radius: 8px;
}
</style>

これでとりあえずカルーセルが動くようになりました!簡単!
このように、flex-boxを用いることができたり、独自で書いたcssを使いやすいというのが非常に魅力的です。
上の例では、固定された幅(400px)の中でのカルーセルですが、実際には以下の条件を満たして欲しかったりすると思います。

  1. 無限ループしてほしい
  2. 固定幅ではなく画面幅いっぱいにカルーセル要素を並べてほしい
  3. ボタンでスライドできるようになってほしい
  4. スライドのドットインジケーターを出してほしい

こう言った希望を叶えるための実装は以下のようになります。

<script setup lang="ts">
import emblaCarouselVue from 'embla-carousel-vue';

const [emblaRef, emblaApi] = emblaCarouselVue({ loop: true }); // 1. loopオプションをtrueにするとloopになります。

const scrollNext = () => {
  if (emblaApi.value) {
    emblaApi.value.scrollNext(); // 3. emblaCarouselVueから取り出せる配列の2番目の要素のemblaApi経由で諸々の操作が出来ます。次のスライドに進んだりスキップすることも可能です。
  }
};

const scrollPrev = () => {
  if (emblaApi.value) {
    emblaApi.value.scrollPrev();
  }
};

const skip = (index: number) => {
  if (emblaApi.value) {
    emblaApi.value.scrollTo(index);
  }
};

// 4. 今どの箇所にいるかを判定をするために、emblaApiのselectイベントが発火したタイミングで、currentIndexに何枚目のスライドにいるかを計算して値を指定します。
// この処理はonMountedのタイミングで行う必要があります。

const currentIndex = ref(0);

onMounted(() => {
  if (emblaApi.value) {
    emblaApi.value.on('select', () => {
      currentIndex.value = emblaApi.value!.selectedScrollSnap() % cardList.length;
    })
  }
})

const cardList = [
  { color: '#f1c1c1', text: 'Card 1' },
  { color: '#f1e0c1', text: 'Card 2' },
  { color: '#d9f1c1', text: 'Card 3' },
  { color: '#c1f1d9', text: 'Card 4' },
  { color: '#c1def1', text: 'Card 5' },
];
</script>

<template>
  <div class="slider-wrapper">
    <div ref="emblaRef" class="slider">
      <div class="container">
        <div v-for="(card, index) in new Array(6).fill(cardList).flat()" :key="`card-${index}`" :style="{ backgroundColor: card.color }" class="card"> // 2. 画面幅に到達しないとカルーセルが有効にならないため無理やり表示したいカードを増やしてます。
          {{ card.text }}
        </div>
      </div>
    </div>
    <div class="indicator-wrapper">
      <button v-for="index in cardList.length" :key="`button-${index}`" class="indicator" @click="skip(index - 1)" :data-active="index - 1 === currentIndex"></button>
    </div>
    <button class="prev" @click="scrollPrev">Prev</button>
    <button class="next" @click="scrollNext">Next</button>
  </div>
</template>

<style scoped>
.slider {
  overflow: hidden;
}
.slider-wrapper {
  position: relative;
}
.container {
  display: flex;
}
.card {
  flex: 0 0 auto;
  width: 400px;
  height: 200px;
  border-radius: 8px;
  margin: 0 10px;
}
.prev, .next {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  padding: 10px;
  border: none;
  background-color: #333;
  color: white;
  cursor: pointer;
}
.prev {
  left: 0;
}
.next {
  right: 0;
}
.indicator-wrapper {
  display: flex;
  justify-content: center;
  margin-top: 10px;
  gap: 10px;
}
.indicator {
  border: none;
  padding: 0;
  background-color: #aaa;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  cursor: pointer;
}
.indicator[data-active="true"] {
  background-color: #333;
}
</style>

すみません、ちょっと長くなってしまいました汗
cssの部分は基本的に装飾をしているだけになります。
コードの中にコメントで注意書き等記述しています。

特に注意点が必要なのは、2番目の画面幅一杯でカルーセルを有効にする時です。
Embla Carouselの動きを試してみると、カルーセルの領域にスライドの要素(今回で言えばカード)が届いていないとカルーセルが有効になりません!!なんてこった、、、

カード枚数が足りずにカルーセルができない状態

回避する方法としては、

  1. デザイン的にスライドの大きさをview-port依存にして、画面幅が広くなったら等倍にスライドも大きくなる
  2. スライドの数を複製して、ある程度大きい画面で見てもカルーセルの領域全体にスライドが埋まるようにする
  3. カルーセルの幅を固定にして、それ以降は見切れるようなデザインにする

という方法が考えられます。今回のSMOUTではスライドの大きさは一定なため、2番目のスライドの数を複製するという方法をとっています。
かなり力づくな方法な気がしますが、この辺りはデザイナーさんとのすり合わせが必要になるかと思います。

また、一見するとflexに対してgapを指定すれば良さそうに見えますが、公式サイトにも以下のように記述されています。

Note! If you don't have loop enabled, Embla Carousel will ignore any gaps at the start and end edge of the carousel.

gapを使ってしまうと、loopの際には最初と最後のgapが無視されてしまい、一枚目と最後のスライドがくっついてしまいます!そのため、loopをさせるためには要素に対してmarginを指定させるのがいいようです。

と、言ったように実際に導入しようとするときにはちょっとした落とし穴などがありますが、動作が重くなったり致命的なバグがあるといったことはなく、非常に使い勝手が良いカルーセルライブラリだなと感じました!

今回説明用に実装したものは、こちらのgithubにアップロードしていますので、手元でご確認していただきながら遊んでみていただければと思います。

github.com

他にもSMOUTのリニューアルに際して、様々なアップデートや試みがされていますので、もし興味がある方がいらっしゃれば気軽にコメントいただければ嬉しいです!

それではハッピークリスマス!!