円周上に配置されたカード
クリックスクロールJavaScriptLenisカード
Card
Back
Card
Back
Card
Back
Card
Back
Card
Back
Card
Back
Card
Back
Card
Back
Card
Back
Card
Back
Card
Back
Card
Back
カードが円周上に配置されて、スクロールでくるくる回せるレイアウトです。
実装のポイント
円周上の配置自体はCSSのtransform-style: preserve-3d
で3次元上に並べています。円周の回転角度はCSS変数で管理しているので、スクロール量をこのCSS変数へ代入すればくるくると回転します。そのためJavaScriptはスクロール量をCSSへ代入するのが主な役目になります。
スクロールイベントはスクロール用ライブラリのLenisで取得してスクロール量を角度に変換しています。ライブラリがタッチデバイスやAppleのMagic Mouse、普通のホイールマウスのスクロール挙動をいい感じに平準化してくれました。
最後にクリックイベントを設置してカードがフリップするようにします。この機能はホバーで回転するカードのものを流用しています。
コード例
HTML
<div class="scrollable">
<!-- --card-countにカード枚数を記述してください -->
<ul class="cardList" style="--card-count: 12">
<li class="sampleCard" style="--index: 0">
<div class="face front">
<p>Card</p>
</div>
<div class="face back">
<p>Back</p>
</div>
</li>
<li class="sampleCard" style="--index: 1">
<div class="face front">
<p>Card</p>
</div>
<div class="face back">
<p>Back</p>
</div>
</li>
<!-- 中略 -->
<li class="sampleCard" style="--index: 11">
<div class="face front">
<p>Card</p>
</div>
<div class="face back">
<p>Back</p>
</div>
</li>
</ul>
</div>
CSS
.scrollable {
width: 100%;
height: 300px;
}
.cardList {
--base-deg: 0; /* 円周の回転角度(度) */
position: relative;
width: 100%;
height: 100%;
pointer-events: none; /* カード要素へのインタラクションを有効化するため、ラッパー要素はインタラクションが透過するよう設定 */
transform-style: preserve-3d;
perspective: 500px;
}
.sampleCard {
/* 実際に配置する大きさなどに応じて適宜変更してください */
--r: 450px; /* 円周の半径 */
--z-offset: 600px; /* 円周の奥行き方向の距離 */
--rotate-deg: calc(
360 / var(--card-count)
); /* 個別のカードの回転角度(度) */
position: fixed;
top: 0;
left: calc(50% - 100px); /* 50%の位置からカードの横幅の半分で中央寄せにする */
list-style: none;
transform-style: preserve-3d;
.face {
position: absolute;
backface-visibility: hidden;
pointer-events: auto; /* カード要素へのインタラクションを有効化する */
}
.front {
rotate: 0 1 0
calc((var(--base-deg) + (var(--index) * var(--rotate-deg))) * 1deg);
translate: calc(
sin(calc((var(--base-deg) + (var(--index) * var(--rotate-deg))) * 1deg)) *
var(--r)
)
0
calc(
cos(calc((var(--base-deg) + (var(--index) * var(--rotate-deg))) * 1deg)) *
var(--r) - var(--z-offset)
);
}
.back {
rotate: 0 1 0
calc(
((var(--base-deg) + (var(--index) * var(--rotate-deg)) + 180)) * 1deg
);
translate: calc(
sin(calc((var(--base-deg) + (var(--index) * var(--rotate-deg))) * 1deg)) *
var(--r)
)
0
calc(
cos(calc((var(--base-deg) + (var(--index) * var(--rotate-deg))) * 1deg)) *
var(--r) - var(--z-offset)
);
}
/* フリップ時 */
&.isActive {
.front {
rotate: 0 1 0
calc(
(var(--base-deg) + (var(--index) * var(--rotate-deg)) + 179.9) * 1deg
);
}
.back {
rotate: 0 1 0
calc(
(var(--base-deg) + (var(--index) * var(--rotate-deg)) + 359.9) * 1deg
);
}
}
}
/* フリップ時(スクロールしていない時)のみトランジションを有効化 */
.lenis:not(.lenis-scrolling) .face {
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
transition: rotate 0.8s var(--ease-out-quart);
}
JavaScript
import Lenis from "lenis";
const wrapperElement = document.querySelector(".scrollable");
const cardListElement = document.querySelector(".cardList");
const cardItemElement = document.querySelectorAll(".sampleCard");
// Lenisの設定
const lenis = new Lenis({
autoRaf: true,
wrapper: wrapperElement,
wheelMultiplier: 0.3,
touchMultiplier: 0.5,
syncTouchLerp: 0.01,
syncTouch: true,
gestureOrientation: "both",
infinite: true,
});
// 初期回転角度を設定
const baseDeg = 0;
cardListElement.style.setProperty("--base-deg", `${baseDeg}`);
// スクロール加速度の回転への係数
const STRENGTH = 0.3;
// Lenisでスクロールイベントをリッスン
lenis.on("scroll", (event) => {
const currentDeg = cardListElement.style.getPropertyValue("--base-deg");
// 現在の回転角度にスクロール速度を加算
const newbaseDeg = currentDeg - event.velocity * STRENGTH;
cardListElement.style.setProperty("--base-deg", `${newbaseDeg}`);
});
// カードをクリックするとアクティブにする
cardItemElement.forEach((element) => {
element.addEventListener("click", () => {
if (lenis.isScrolling) {
// スクロール中はアクティブにしない
return;
}
element.classList.toggle("isActive");
});
});
スクロールイベントの設定
カードを配置している.scrollable
ラッパー要素をLenis
の対象としてスクロールイベントをリッスンします。wheelMultiplier
、
touchMultiplier
、
syncTouchLerp
の値は実装する大きさなどに応じて調整してください。Lenis
からはスクロールの加速度event.velocity
があるので、それを円周配置の回転角度の--base-deg
に加算します。そのまま加算すると強すぎるので定数STRENGTH
で調整しています。
カードの配置
少しややこしいのが、裏表のあるカードの3次元配置の方法です。カード1枚1枚は自身の--index
と円周の半径、全枚数から算出した1枚あたりの配置角度の3つからrotate
プロパティとtranslate
プロパティに三角関数を用いて配置しています。実は表面、裏面の両方にこのスタイルを適用して、裏面のみさらに180度回転させています。
両面個別に3次元配置しなくても親の.sampleCard
要素を3次元配置して、そのなかで表裏を配置したほうが楽そうにみえます。実際自分も最初はそのアプローチでした。しかし、この方法だとカードが奥側に回ったとき(画面上だと裏側が見えている)になぜかFirefoxでうまく表示されなかったので複雑ですが両面を3次元配置しています。
3次元配置の計算はJavaScriptとの兼ね合いもあり単位なしの数値で計算にしています。CSSの値としては単位が必要なので最後に* 1deg
を掛けて角度にしています。
フリップ時の表を179.9
度、裏は359.9
度という中途半端な数値を足しています。これには2つ理由があります。まず表面と裏面で+の方向へ回転方向をあわせたいので現在の角度からさらに+の角度にしています。さらに微妙に180
、360
としないのは、Safariが180
、360
度にすると上記意図を無視して反対方向へ回ってしまうため、ギリギリ+の方が最短で回れるような指示にしています。
そのほか実装上の制限にはなりますが、スクロール中のフリップ動作は無効にしてあります。フリップにはtransition
が必要になりますが回転動作(こちらはtransition
なし)と干渉するため、スクロール中はフリップできません。