円周上に配置されたカード

クリックスクロール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の対象としてスクロールイベントをリッスンします。wheelMultipliertouchMultipliersyncTouchLerpの値は実装する大きさなどに応じて調整してください。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つ理由があります。まず表面と裏面で+の方向へ回転方向をあわせたいので現在の角度からさらに+の角度にしています。さらに微妙に180360としないのは、Safariが180360度にすると上記意図を無視して反対方向へ回ってしまうため、ギリギリ+の方が最短で回れるような指示にしています。

そのほか実装上の制限にはなりますが、スクロール中のフリップ動作は無効にしてあります。フリップにはtransitionが必要になりますが回転動作(こちらはtransitionなし)と干渉するため、スクロール中はフリップできません。

Copyright Web Motion Catalog all rights reserved.