🎞️

Reactでのスライダー実装における選択肢:Swiperからの移行先を探る

に公開

はじめに:脱Swiperの背景

Next.js × Reactでスライダーを実装する際、定番とも言えるのが Swiper です。多機能かつ導入しやすい一方で、バンドルサイズ肥大化や不安定な挙動など、運用中に課題を感じた方も少なくないはずです。

私自身、ある案件でSwiperのバージョンを上げた際、画像枚数に応じてループ挙動が変わる(slidesPerViewの2倍以上必要)仕様に直面し、メンテナンスコストとバンドルサイズを考慮して「もっとシンプルで軽いライブラリ」の必要性を感じました。

そこで本記事では、React向けの有力な代替ライブラリ3種を比較検証します。

Keen Slider
https://keen-slider.io/

Splide
https://ja.splidejs.com/

Embla Carousel
https://www.embla-carousel.com/


バンドルサイズ比較

パッケージ Minified Gzipped
swiper@v11.2.10(比較対象) 67.3 KB 20.2 KB
@splidejs/react-splide 32.6 KB 13.6 KB
keen-slider 14.2 KB 5.8 KB
embla-carousel-react 18.7 KB 7.3 KB

(Bundlephobia参照)

補足:実プロジェクトでの差
localhostにてCreate React App + TypeScript + source-map-explorerで測定した場合:

ライブラリ 実測バンドルサイズ
Splide 32.7 KB
Embla Carousel 17.5 KB
Keen Slider 14.6 KB
  • Keen / Embla は圧倒的軽量。
  • Splide は必要十分な軽さと機能性のバランス。

ライブラリごとの設計思想と特徴

Keen Slider

特徴 内容
軽量・高性能 gzip 5.5 KB・超軽量、依存ゼロ
React対応 useKeenSlider フックで統合
ヘッドレス設計 UIなし・ロジックのみ提供
アクセシビリティ 自前実装推奨

Splide

補足:Splideは日本人開発者によるライブラリ。公式サイト・ドキュメントも日本語が充実しています。
ただし、2023年以降大きなアップデートは行われておらず、事実上メンテナンスフェーズに入っている可能性があります。今後の継続性を考慮した選定が必要です。

特徴 内容
バランス型 gzip 13 KB・多機能・安定運用
React対応 コンポーネントで即利用可
オプション豊富 perPage / breakpoints等細かく設定可能
アクセシビリティ ARIA / SR対応済み・テスト豊富

特徴 内容
柔軟・拡張性 プラグイン設計・モジュラー構造
React対応 useEmblaCarousel フック
ヘッドレス設計 UI自作前提、APIは柔軟
アクセシビリティ 必要に応じ自前実装

総比較

特徴 Keen Slider Splide Embla Carousel
軽量性 圧倒的に軽量(約5.5 KB gzip) 中程度(約12–13 KB gzip) 軽量かつプラグイン拡張可能
APIスタイル フック+ヘッドレス設計で自由自在 コンポーネント + オプション豊富な初期構成 コンポーネント + オプション豊富な初期構成
拡張性 プラグインもあり高い自由度 拡張ポイント多め、設定変更が簡単 プラグイン重視でカスタム挙動に強い
アクセシビリティ 必要に応じて自実装 ARIA対応済み・テスト豊富 API対応のみ、自作が必要な場合あり
設計思想 性能と柔軟さ重視のヘッドレス バランス型・設定で使いやすさ重視 シンプル&高拡張、ローレベル制御重視

実装コード比較(共通条件)

前提条件
本記事では、各ライブラリとも 「スライダー(3枚)」「ループ」「ナビゲーション(前後ボタン)」「ページネーション(インジケーター)」を備えた最小構成という共通条件で実装を揃えています。

Keen Slider実装

tsx
import React, { useState } from 'react';
import { useKeenSlider } from 'keen-slider/react';
import 'keen-slider/keen-slider.min.css';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import styles from './KeenSlider.module.css';

const slides = [
  'https://picsum.photos/id/1018/800/400',
  'https://picsum.photos/id/1015/800/400',
  'https://picsum.photos/id/1019/800/400',
];

const ArrowLeft = FaChevronLeft as React.FC<React.SVGProps<SVGSVGElement>>;
const ArrowRight = FaChevronRight as React.FC<React.SVGProps<SVGSVGElement>>;

export const KeenCarousel: React.FC = () => {
  const [currentSlide, setCurrentSlide] = useState(0);
  const [sliderRef, instanceRef] = useKeenSlider<HTMLDivElement>({
    loop: true,
    slides: { perView: 1 },
    slideChanged(slider) {
      setCurrentSlide(slider.track.details.rel);
    },
  });

  return (
    <div className={styles.carouselContainer}>
      <div ref={sliderRef} className="keen-slider">
        {slides.map((src, index) => (
          <div className="keen-slider__slide" key={index}>
            <img
              src={src}
              alt={`Slide ${index + 1}`}
              style={{ width: '100%', display: 'block' }}
            />
          </div>
        ))}
      </div>

      {/* 左右ナビゲーション */}
      <button
        onClick={() => instanceRef.current?.prev()}
        className={`${styles.carouselNavBtn} ${styles.left}`}
      >
        <ArrowLeft />
      </button>
      <button
        onClick={() => instanceRef.current?.next()}
        className={`${styles.carouselNavBtn} ${styles.right}`}
      >
        <ArrowRight />
      </button>

      {/* ドットナビゲーション */}
      <div className={styles.carouselDotGroup}>
        {slides.map((_, index) => (
          <button
            key={index}
            onClick={() => instanceRef.current?.moveToIdx(index)}
            className={`${styles.carouselDot}${currentSlide === index ? ' ' + styles.active : ''}`}
          />
        ))}
      </div>
    </div>
  );
};
KeenSlider css
KeenSlider.module.css
/* カルーセル全体 */
.carouselContainer {
  position: relative;
  width: 600px;
  margin: 0 auto;
}

/* ナビゲーションボタン */
.carouselNavBtn {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 1;
  border-radius: 50%;
  background: #fff;
  width: 40px;
  height: 40px;
  opacity: 0.6;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
}
.carouselNavBtn.left {
  left: 10px;
}
.carouselNavBtn.right {
  right: 10px;
}

/* ドットナビゲーション */
.carouselDotGroup {
  display: flex;
  justify-content: center;
  gap: 0.5rem;
  margin-top: 1rem;
}
.carouselDot {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: none;
  background: #ccc;
  cursor: pointer;
}
.carouselDot.active {
  background: #333;
}

Splide実装

tsx
import React from 'react';
import { Splide, SplideSlide } from '@splidejs/react-splide';
import '@splidejs/splide/dist/css/splide.min.css';

const slides = [
  'https://picsum.photos/id/1018/800/400',
  'https://picsum.photos/id/1015/800/400',
  'https://picsum.photos/id/1019/800/400',
];

export const SplideCarousel: React.FC = () => {
  return (
    <Splide options={{
      type: 'loop',
      perPage: 1,
      gap: '1rem',
    }}>
      {slides.map((src, index) => (
        <SplideSlide key={index}>
          <img src={src} alt={`slide ${index}`} style={{ width: '100%' }} />
        </SplideSlide>
      ))}
    </Splide>
  );
};

Embla Carousel実装

tsx
import React, { useCallback, useEffect, useState } from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import styles from './EmblaCarousel.module.css';
import { FaChevronLeft, FaChevronRight } from "react-icons/fa";

const slides = [
  'https://picsum.photos/id/1018/800/400',
  'https://picsum.photos/id/1015/800/400',
  'https://picsum.photos/id/1019/800/400',
];

const ArrowLeft = FaChevronLeft as React.FC<React.SVGProps<SVGSVGElement>>;
const ArrowRight = FaChevronRight as React.FC<React.SVGProps<SVGSVGElement>>;

export const EmblaCarousel: React.FC = () => {
  const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, slidesToScroll: 1 }, []);
  const [selectedIndex, setSelectedIndex] = useState(0);

  useEffect(() => {
    if (!emblaApi) return;
    const onSelect = () => setSelectedIndex(emblaApi.selectedScrollSnap());
    emblaApi.on('select', onSelect);
    onSelect();
    return () => {
      emblaApi.off('select', onSelect);
    };
  }, [emblaApi]);

  const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
  const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
  const scrollTo = useCallback((idx: number) => emblaApi?.scrollTo(idx), [emblaApi]);

  return (
    <div className={styles.embla}>
      <div className={styles.embla__viewport} ref={emblaRef}>
        <div className={styles.embla__container}>
          {slides.map((src, index) => (
            <div className={styles.embla__slide} key={index}>
              <img src={src} className={styles.embla__slide__img} alt={`Slide ${index}`} />
            </div>
          ))}
        </div>
        {/* 左右ナビゲーション */}
        <div className={styles.embla__nav}>
          <button className={styles.embla__nav__btn} onClick={scrollPrev} aria-label="Prev"><ArrowLeft/></button>
          <button className={styles.embla__nav__btn} onClick={scrollNext} aria-label="Next"><ArrowRight/></button>
        </div>
        {/* ドットナビゲーション */}
        <div className={styles.embla__pagination}>
          {slides.map((_, i) => {
            const dotClass = [
              styles.embla__dot,
              i === selectedIndex ? styles['embla__dot--active'] : ''
            ].join(' ');
            return (
              <button
                key={i}
                className={dotClass}
                onClick={() => scrollTo(i)}
                aria-label={`Go to slide ${i + 1}`}
              />
            );
          })}
        </div>
      </div>
    </div>
  );
};
EmblaCarousel css
EmblaCarousel.module.css
.embla {
  position: relative;
  width: 100%;
  max-width: 800px;
  margin: 32px auto;
  overflow: hidden;
  border-radius: 16px;
  box-shadow: 0 2px 16px rgba(0,0,0,0.08);
}

.embla__viewport {
  overflow: hidden;
  width: 100%;
}

.embla__container {
  display: flex;
  gap: 1rem;
  padding: 24px 0;
}

.embla__slide {
  flex: 0 0 100%;
  box-sizing: border-box;
  display: flex;
  justify-content: center;
  align-items: center;
}

.embla__slide__img {
  width: 100%;
  max-width: 760px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.10);
}

.embla__nav {
  position: absolute;
  top: 50%;
  left: 32px;
  right: 32px;
  width: auto;
  display: flex;
  justify-content: space-between;
  align-items: center;
  transform: translateY(-50%);
  pointer-events: none;
  z-index: 2;
}

.embla__nav__btn {
  background: rgba(255,255,255,0.85);
  border: 1px solid #ddd;
  border-radius: 50%;
  width: 44px;
  height: 44px;
  font-size: 1.7rem;
  cursor: pointer;
  box-shadow: 0 1px 8px rgba(0,0,0,0.10);
  pointer-events: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 0.2s, box-shadow 0.2s;
  z-index: 2;
}

.embla__nav__btn:hover {
  background: #e9ecef;
}

.embla__pagination {
  position: absolute;
  left: 50%;
  bottom: 48px;
  transform: translateX(-50%);
  display: flex;
  gap: 10px;
  z-index: 2;
}

.embla__dot {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: #ccc;
  border: none;
  cursor: pointer;
  transition: background 0.2s;
}

.embla__dot--active {
  background: #333;
}

各ライブラリのコード比較

ライブラリ 実装スタイル ナビの扱い 備考
Keen Slider フックベース instanceRefで制御 CSSはkeen-slider__slide等必須
Splide コンポーネント形式 デフォルトで前後ボタンが出る @splidejs/react-splide/css含む
Embla Carousel フック+API呼び出し emblaApi?.scrollNext() ヘッドレスUI

結論:どれを選ぶべきか?

要件 最適候補
とにかく軽く・最低限のスライダーだけで良い Keen Slider / Embla Carousel
UIを完全に自作したい・高い自由度が欲しい Embla Carousel
楽に導入したい・機能バランスが欲しい Splide
既存でSwiper運用 → 無理に移行不要 Swiper継続でも可

個人的なおすすめ

シーン おすすめ
企業・案件用(安定 / 汎用性重視) Splide
個人開発・超軽量SPA Keen Slider / Embla
細かくカスタムUIにこだわる場合 Embla 一択

本記事の執筆時点で、私は結局 Splide を採用しました。理由はとてもシンプルで、導入が圧倒的に楽で、すぐに動く安心感があったからです。案件やプロダクトの状況によっては、スライダーにそこまで時間をかけたくないケースも多く、Splideの手軽さは大きな魅力でした。

ただし、個人的な好みで言えば Embla Carousel に惹かれています。
理由は、ヘッドレスかつシンプルなAPIによって 必要なUIを自由に作り込める柔軟性があり、設計思想としても非常に好感が持てるからです。もし完全に自分だけの趣味開発であれば、私は Embla を選んでいたと思います。

採用情報

メディロムグループでは以下のようなサービスを展開しています。

  • 全国300店舗以上のリラクゼーションスタジオ「Re.Ra.Ku」
  • 世界初!充電不要の活動量計「MOTHER bracelet」
  • ヘルスケアコーチングアプリ「Lav」

ヘルスケア領域に興味があるエンジニア、PMを絶賛募集中です!
少しでも興味を持っていただけた方は、ぜひ以下のリンクからエントリーしてみてください。
健康が大好きな方、コーヒー大好きな方、音楽が大好きな方、大歓迎です。
https://medirom.co.jp/recruit

メディロムグループ Tech Blog

Discussion