Reactでのスライダー実装における選択肢:Swiperからの移行先を探る
はじめに:脱Swiperの背景
Next.js × Reactでスライダーを実装する際、定番とも言えるのが Swiper です。多機能かつ導入しやすい一方で、バンドルサイズ肥大化や不安定な挙動など、運用中に課題を感じた方も少なくないはずです。
私自身、ある案件でSwiperのバージョンを上げた際、画像枚数に応じてループ挙動が変わる(slidesPerViewの2倍以上必要)仕様に直面し、メンテナンスコストとバンドルサイズを考慮して「もっとシンプルで軽いライブラリ」の必要性を感じました。
そこで本記事では、React向けの有力な代替ライブラリ3種を比較検証します。
Keen Slider
Splide
Embla Carousel
バンドルサイズ比較
| パッケージ | 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対応済み・テスト豊富 |
Embla Carousel
| 特徴 | 内容 |
|---|---|
| 柔軟・拡張性 | プラグイン設計・モジュラー構造 |
| React対応 |
useEmblaCarousel フック |
| ヘッドレス設計 | UI自作前提、APIは柔軟 |
| アクセシビリティ | 必要に応じ自前実装 |
総比較
| 特徴 | Keen Slider | Splide | Embla Carousel |
|---|---|---|---|
| 軽量性 | 圧倒的に軽量(約5.5 KB gzip) | 中程度(約12–13 KB gzip) | 軽量かつプラグイン拡張可能 |
| APIスタイル | フック+ヘッドレス設計で自由自在 | コンポーネント + オプション豊富な初期構成 | コンポーネント + オプション豊富な初期構成 |
| 拡張性 | プラグインもあり高い自由度 | 拡張ポイント多め、設定変更が簡単 | プラグイン重視でカスタム挙動に強い |
| アクセシビリティ | 必要に応じて自実装 | ARIA対応済み・テスト豊富 | API対応のみ、自作が必要な場合あり |
| 設計思想 | 性能と柔軟さ重視のヘッドレス | バランス型・設定で使いやすさ重視 | シンプル&高拡張、ローレベル制御重視 |
実装コード比較(共通条件)
前提条件
本記事では、各ライブラリとも 「スライダー(3枚)」「ループ」「ナビゲーション(前後ボタン)」「ページネーション(インジケーター)」を備えた最小構成という共通条件で実装を揃えています。
Keen Slider実装
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
/* カルーセル全体 */
.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実装
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実装
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
.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を絶賛募集中です!
少しでも興味を持っていただけた方は、ぜひ以下のリンクからエントリーしてみてください。
健康が大好きな方、コーヒー大好きな方、音楽が大好きな方、大歓迎です。
Discussion