milvinae

web & product design.

  • 変わったGalleryをCSSアニメーションで作る

    変わったGalleryをCSSアニメーションで作る


    前回はanime.jsを使ってギャラリーを作りました。
    でも、よくよく考えてみればただ位置がうごいて少し回転するだけ…
    これ、cssで作れないか?
    遷移先の位置をランダムに作ってcssアニメーションで動かせばいいんじゃなかろうか。

    実装してみましょう。

    ※Codepenで使用している画像は引き続きPhotockさんのフリー素材です。
    https://photock.jp/

    htmlに変わりはありません。
    jsを見ていきましょう。

    const picNumber = 5;
    const picMax    = 25;
    const picIdPrefix = "pic";
    var   gitpass = "https://raw.githubusercontent.com/keiyashi/codepen-example/refs/heads/main/pic-";
    var   fileSuffix = ".webp";
    
    function initialize(){
      var back = document.querySelector(".backboard");
      var counted = [];
      for(i = 0; i < picNumber; i++){
        var num = Math.ceil(Math.random()*picMax);
        while(counted.includes(num)){
          if(num == picMax) num = 1;
          else num++;
        }
        counted.push(num);
        var picture = document.createElement("figure",{id: picIdPrefix + i}); 
        picture.classList.add("picture-frame");
        var pictureInner = document.createElement("img");
        pictureInner.classList.add("picture-inner");
        pictureInner.src = gitpass + num + fileSuffix;
        picture.appendChild(pictureInner);
        back.appendChild(picture);
      }
    }
    
    function pictureLoad(){
      var pictureInners = document.querySelectorAll(".picture-inner");
      var counted = [];
    
      pictureInners.forEach(inner => {
        var num = Math.ceil(Math.random()*picMax);
        while(counted.includes(num)){
          if(num == picMax) num = 1;
          else num++;
        }
        counted.push(num);
        inner.src = gitpass + num + fileSuffix;
      });
    }
    
    document.addEventListener("DOMContentLoaded", (e) =>{
      initialize();
      addAnimation();
    })
    
    function addAnimation(){
      var board = document.querySelector(".backboard");
      var boardRect = board.getBoundingClientRect();
      var frames = document.querySelectorAll(".picture-frame");
      frames.forEach(frame => {
        var frameRect = frame.getBoundingClientRect();
        frame.classList.add("frame-anime");
        frame.style.setProperty("--duration", Math.floor(Math.random() * 2000) / 1000  + "s");
        frame.style.setProperty("--transx", Math.floor(Math.random() * (boardRect.width  - frameRect.width)) + "px");
        frame.style.setProperty("--transy", Math.floor(Math.random() * (boardRect.height - frameRect.height)) + "px");
        frame.style.setProperty("--rotate", Math.floor(Math.random() * 90 - 45) + "deg");
      })
    }
    
    function clickPin(){
      removePic();
      pictureLoad();
      addAnimation();
    }
    
    function removePic(){
      var frames = document.querySelectorAll(".picture-frame");
      frames.forEach(frame => {
        frame.classList.remove("frame-anime");
      })
    }

    アニメーションを担当するクラスを作って、画像がロードされたらクラスを付与する、というのが方針です。
    なのでjsの重要な部分は大きく2つです。
    1.画像要素の生成
    2.アニメーションの付与

    生成に関しては前回とほぼ同じで、initializeで生成しています。
    アニメーションクラスの付与はaddAnimationで行っています。
    詳しい話はcssのほうでお話ししますが、イメージ的には遷移先、かかる時間、回転角を1枚1枚ランダムで計算して渡してる感じです。

    ではcssです。
    .backboard{
      width: 90dvw;
      height: 90dvh;
      position:relative;
      left:5dvw;
      top:5dvh;
      background-image:  url("https://raw.githubusercontent.com/keiyashi/codepen-example/refs/heads/main/CorkBoard.jpg");
      background-repeat: no-repeat;
      background-position: center;
      background-size: cover;
      padding: 15px;
      box-shadow: 2dvw 2dvw 2dvw rgba(64,64,64,.3);
      border-radius:20px;  
        
      .pin{
        position:absolute;
        width: 50px;
        aspect-ratio:1;
        top: -5dvh;
        right: -1dvw;
        background-color: transparent;
        border-color:transparent;
        cursor: pointer;
      }
    }
    
    .picture-frame{
      position:absolute;
      width:15dvw;
      aspect-ratio:1.5;
      background-color:white;
      transform-origin:center;
      box-shadow:0px 0px 3px rgba(64, 64, 64, .8);
      
      img{
        position:relative;
        width:92%;
        height:92%;
        top:4%;
        left:4%;
        color:lightblue;
      }
    }
    
    .frame-anime{
      animation-name: scatterPic;
      animation-duration:var(--duration);
      animation-iteration-count:1;
      animation-fill-mode:both;
      --transx:0px;
      --transy:0px;
      --rotate:0deg;
      --duration:0s;
    }
    
    @keyframes scatterPic{
      0%{
        transform:translate(0px, 0px) rotate(0deg);
        opacity:0;
      }
      100%{
        transform:translate(var(--transx), var(--transy)) rotate(var(--rotate));
        opacity:1;
      }
    }


    @keyframesを使ってアニメーションを指示します。
    画像がロード(リロード)されたときに、frame-animeクラスを画像たちに付与することでアニメーションが作動します。
    animation-fill-modeは、アニメーションの開始と終了時の状態を維持するかどうか、を指定するもの。
    ばらまいた先で止まってほしいのでforwardsまたはbothを指定します。
    https://developer.mozilla.org/ja/docs/Web/CSS/animation-fill-mode
    今回はアニメーション0%の時の状態から始まってほしいのでbothにしました。

    クラスで指定したスタイルは、同じクラスをもつすべての要素に共通の値が適用してしまいます。
    ですが、変数を作ってjavascriptで要素1つ1つの変数を変えることでバラバラの値を指定することが出来ます。
    今回は遷移先X,Y、回転角、アニメーションの動作時間の4つを変数にして、javascriptでframe-animation付与時に乱数を与えています。


    画像を更新するときは、frame-animeクラスを剥奪 → 画像の中身を更新 → 再度frame-animeクラスを付与するという流れです。
    前回と同じようにばらまいた位置から更にばらまきなおすようにするには、ばらまいた位置を変数で記録するようにすれば実現できます。



    これでanime.jsを使わずにばらまきギャラリーを作ることが出来ました。
    ライブラリ汚染、プラグイン汚染を防ぐためにも可能な限り自前で作ってしまうほうがいいですね。


  • 独自スクロールバーをjavascript+scssだけで実装してみる

    独自スクロールバーをjavascript+scssだけで実装してみる

    せっかく細部までこだわってサイトを作ったのにスクロールバーが普通過ぎる…
    でも細かい挙動がわからないプラグインは入れたくない…

    そんなあなたにJavascriptとcss(scss)だけで作れる独自スクロールバーを紹介します。

    まずはCodepenから

    まずは要求を明らかにしましょう。

    <要求>
    ・通常スクロールバーの挙動を満たす
     → 縦横のスクロールバーがでる
       スクロールサム(スクロール操作するためのボタンみたいなやつ)と同期した画面のスクロール
       スクロールトラック(スクロールサムの通り道)をクリックすると、サムが追従し画面もスクロールする
       画面をボタンやホイールでスクロールするとサムが追従する
       スクロールが必要のない大きさになったらスクロールバーが隠れる
    ・スクロールサムやトラックを自由に変更できる
    ・スクロールバーのHTMLはJavascriptで自動生成する

    ”スクロールが必要な要素にクラスを追加するだけでjsがいい感じにしてくれるようにしたい!”、というのが導入しやすい一番の肝かと思います。
    なのでJSでスクロールバーを自動生成するようにしました。

    まずはHTMLから
    <div class="frame-area">
      <div class="scroll-area">
     ~~~~~本文~~~~~~~~~
     </div>
    </div>

    中身となるScroll-areaと、枠を決めるFrame-areaだけのシンプルな構成です。

    次はcss(scss)
    .frame-area{
      position:relative;
      top:20dvh;
      left:20dvw;
      width:50dvw;
      height:50dvh;
      resize: both;
      overflow:auto;
      scrollbar-width: none;
      background-color: lightblue;
    
      .scroll-area{
        width: fit-content;
        white-space:pre;
      }
      
      .scroll-track-h{
        cursor: pointer;
        position:fixed;  
        
        &::after{
          content:"";
          display: block;
          position:absolute;
          width:100%;
          height:1px;
          border-radius:0.5px;
          background-color: black;
          bottom: 50%;
        }
        
        .scroll-thumb-h{
          position:relative;
          background-color: black;
          transition: all .2s;
        }
      }
      
      .scroll-track-v{
        cursor: pointer;
        position:fixed;  
        
        &::after{
          content:"";
          display: block;
          position:absolute;
          width:1px;
          height:100%;
          border-radius:0.5px;
          background-color: black;
          bottom: 0%;
          left: 4px;
          
        }
        
        .scroll-thumb-v{
          position:relative;
          background-color: black;
          transition: all .2s;
        }
      } 
    }

    frame-areaで重要なのは次のスタイルです。
     over-flow:auto → サイズ以上に内容がはみ出る場合はスクロール可能にする設定(scrollでも可)
     scrollbar-width: none → デフォルトのスクロールバーを非表示にする設定

    scroll-areaは特に重要なスタイルはありません。


    次にスクロールバーのスタイルです。
    ”子要素にscroll-thumbを持つscroll-track”というセットを、Horizontal用とVertical用で分けて生成します。
    trackにPosition:fixedを指定するのは、スクロールしてもtrackが同じ位置に残ってほしいためです。

    // element
    var frameArea ;
    var scrollArea ;
    var trackH ;
    var thumbH ;
    var trackV ;
    var thumbV ;
    
    // variable
    var frameRect;        // current frame rect info.
    var scrollRect;       // current scroll rect info.
    var thumbVTopEdge;    // client Y about vertical thumb when mouse down.  
    var thumbHLeftEdge;   // client X about horizontal thumb when mouse down.
    var movableLengH;     // length that scroll-area can move in horizontal.  
    var movableLengV;     // length that scroll-area can move in vertical.
    var movableBarH;      // length that horizontal thumb on horizontal track.
    var movableBarV;      // length that vertical thumb on vertical track.
    
    // props
    const thumbThickness = 10;
    const trackLengRatio = 0.8;
    const thumbMinLength = 20;
    const trackDistanceFromEdge = 5;
    
    // flags
    var activeH;          // is horizontal thumb draggin?
    var activeV;          // is vertical thumb draggin?
    
    
    // called after Dom loaded
    document.addEventListener("DOMContentLoaded", (e) => {
      createHTML();
      initialize();
      updateTrack();
    });
    
    function createHTML(){
      scrollArea  = document.querySelector('.scroll-area');
      frameArea   = document.querySelector('.frame-area');
      
      // Horizontal
      const horizontalTrack = document.createElement("div");
      horizontalTrack.classList.add("scroll-track-h");
      const horizontalThumb = document.createElement("div");
      horizontalThumb.classList.add("scroll-thumb-h");
      horizontalTrack.appendChild(horizontalThumb);
      frameArea.appendChild(horizontalTrack);
      
      // vertical
      const verticalTrack = document.createElement("div");
      verticalTrack.classList.add("scroll-track-v");
      const verticalThumb = document.createElement("div");
      verticalThumb.classList.add("scroll-thumb-v");
      verticalTrack.appendChild(verticalThumb);
      frameArea.appendChild(verticalTrack);
    };
    
    // initializer
    function initialize(){
      trackH      = document.querySelector('.scroll-track-h');
      thumbH      = document.querySelector('.scroll-thumb-h');
      trackV      = document.querySelector('.scroll-track-v');
      thumbV      = document.querySelector('.scroll-thumb-v');
    
      thumbH.addEventListener("mousedown",   holdThumbH, { passive: true });
      thumbV.addEventListener("mousedown",   holdThumbV, { passive: true });
      document.addEventListener("mousemove", moveThumb, { passive: false }); 
      // "passive: false" means callback has preventDefault function
      document.addEventListener("mouseup",   releaseThumb);
      scrollArea.addEventListener("selectstart",   function(e) {
        if(activeH || activeV) 
          e.preventDefault();
      });
      
      frameArea.addEventListener("scroll",  scrollingArea);
      trackH.addEventListener("click",       clickTrackH, { passive: false });
      trackV.addEventListener("click",       clickTrackV, { passive: false });
      
      window.addEventListener('resize', updateTrack);
      frameResizingObserver();
      
      thumbHLeftEdge = 0;
      thumbVTopEdge  = 0;
      movableLengH   = 0;
      movableLengV   = 0;
      movableBarH    = 0;
      movableBarV    = 0;
      activeH        = false;
      activeV        = false;
    }
    
    function frameResizingObserver(){
      const observer = new MutationObserver(updateTrack);
      observer.observe(frameArea, {attributes: true, attributeFilter: ["style"]});
    }
    
    // update variable 
    // recalled by window resize or frame resize
    function updateTrack(){
      frameRect  = frameArea.getBoundingClientRect();
      scrollRect = scrollArea.getBoundingClientRect();
      
      if(frameRect.width > scrollRect.width){
        trackH.style.visibility = "hidden";
        thumbH.style.visibility = "hidden";
      }else{
        trackH.style.visibility = "visible";
        thumbH.style.visibility = "visible";
      }
      
      if(frameRect.height > scrollRect.height){
        trackV.style.visibility = "hidden"; 
        thumbV.style.visibility = "hidden"; 
      }else{
        trackV.style.visibility = "visible";
        thumbV.style.visibility = "visible";
      }
        
      // horizontal scrollbar 
      trackH.style.width  = (frameRect.width * trackLengRatio) + "px";
      trackH.style.height = thumbThickness + "px";
      trackH.style.left   = (frameRect.x + frameRect.width * (1 - trackLengRatio) / 2)  + "px";
      trackH.style.top    = (frameRect.y + frameRect.height - (thumbThickness + trackDistanceFromEdge)) + "px";
      
      var widRatio = (frameRect.width * trackLengRatio)  / scrollRect.width;
      var hgtRatio = (frameRect.height * trackLengRatio) / scrollRect.height;
       
      var trackH_Rect = trackH.getBoundingClientRect();
      var thumbHWidth  = trackH_Rect.width  * widRatio;
      if(thumbHWidth < thumbMinLength) thumbHWidth = thumbMinLength;
      thumbH.style.width  = thumbHWidth + "px";
      thumbH.style.height = trackH_Rect.height + "px";
      thumbH.style.top = "0px";
      thumbH.style.borderRadius = thumbThickness / 2 + "px";
      
      thumbLeftEdge = thumbH.getBoundingClientRect().x; 
      movableLengH = scrollRect.width - frameRect.width;
      movableBarH  = (frameRect.width * trackLengRatio) - thumbHWidth;
      
      // vertical scrollbar
      trackV.style.width  = thumbThickness + "px";
      trackV.style.height = (frameRect.height * trackLengRatio) + "px";
      trackV.style.left   = (frameRect.x + frameRect.width - (thumbThickness + trackDistanceFromEdge))  + "px";
      trackV.style.top    = (frameRect.y + frameRect.height * (1 - trackLengRatio) / 2) + "px";
       
      var trackV_Rect = trackV.getBoundingClientRect();
      thumbV.style.width  = trackV_Rect.width  + "px";
      var thumbVHeight = trackV_Rect.height * hgtRatio;
      if(thumbVHeight < thumbMinLength) thumbVHeight = thumbMinLength;
      thumbV.style.height = thumbVHeight + "px";
      thumbV.style.top = "0px";
      thumbV.style.borderRadius = thumbThickness / 2 + "px";
      
      thumbTopEdge = thumbV.getBoundingClientRect().y; 
      movableLengV = scrollRect.height - frameRect.height;
      movableBarV  = (frameRect.height * trackLengRatio) - thumbVHeight;
    }
    
    // event 
    // mouse down on horizontal thumb 
    function holdThumbH(e){
      console.log("mouseDown");
      activeH = true;
      thumbHLeftEdge = e.offsetX;
    }
    
    // mouse down on vertical thumb 
    function holdThumbV(e){
      console.log("mouseDown");
      activeV = true;
      thumbVTopEdge  = e.offsetY;
    }
    
    // mouse move on document
    // because dont move only on thumb 
    function moveThumb(e){ 
      if(!activeH && !activeV){ return; }
      e.preventDefault();
       
      if(activeH){
        e = e.clientX;
        console.log("horizontal: " + e);
        // it use css style "translate".
        // because need diff length from element initial pos.
        var curThumbLeftX = e - trackH.getBoundingClientRect().x - thumbHLeftEdge; // A
        var trackRatio = curThumbLeftX / movableBarH;                              // B
        var actualMoveX = movableLengH * trackRatio;                               // C
        if(curThumbLeftX < 0) 
          curThumbLeftX = 0;
        else if(curThumbLeftX > movableBarH) 
          curThumbLeftX = movableBarH;
        thumbH.style.transform = "translateX(" + curThumbLeftX + "px)";            // D
        frameArea.scrollLeft = actualMoveX;                                        // E
      }
      if(activeV){
        e = e.clientY;
        console.log("vertical: " + e);
        var curThumbTopY = e - trackV.getBoundingClientRect().y - thumbVTopEdge;
        var trackRatio = curThumbTopY / movableBarV;
        var actualMoveY = movableLengV * trackRatio;
        if(curThumbTopY < 0) 
          curThumbTopY = 0;
        else if(curThumbTopY > movableBarV) 
          curThumbTopY = movableBarV;
        thumbV.style.transform = "translateY(" + curThumbTopY + "px)";
        frameArea.scrollTop = actualMoveY;
      }
    }
    
    function releaseThumb(e){
      console.log("mouseUp");
    
      // wait for move sequence.
      requestAnimationFrame(function() {
        document.body.style.overflow = "";
        e.preventDefault();
        activeV = false;
        activeH = false;
      }, 0);
    }
    
    function scrollingArea(e){
      console.log("scrolled");
      if(activeH || activeH) {
        return;  
      }
      var scrolltop  = frameArea.scrollTop;
      var scrollleft = frameArea.scrollLeft;
      var scrollRatioH = scrollleft / movableLengH;
      var scrollRatioV = scrolltop  / movableLengV;
      var curThumbTopY = movableBarV * scrollRatioV;
      var curThumbLeftX= movableBarH * scrollRatioH;
      
      thumbV.style.transform = "translateY(" + curThumbTopY + "px)";
      thumbH.style.transform = "translateX(" + curThumbLeftX + "px)";
    }
    
    // click in trackbar
    function clickTrackH(e){
      if(activeH) {return;} // reject mousemove and release
      
      console.log("clicked Horizontal");
      e.preventDefault();
      
      var thumbNewX = e.offsetX - thumbH.scrollWidth / 2;
      if(thumbNewX < 0)
        thumbNewX = 0;
      else if(thumbNewX > movableBarH)
        thumbNewX = movableBarH;
      thumbH.style.transform = "translateX(" + thumbNewX + "px)";
      
      var trackRatio = thumbNewX / movableBarH;
      var actualMoveX = movableLengH * trackRatio;
      frameArea.scrollLeft = actualMoveX; 
    }
    
    function clickTrackV(e){
      if(activeV) { return ;}
      console.log("clicked vertical");
      e.preventDefault();
    
      var thumbNewY = e.offsetY - thumbV.scrollHeight / 2;
        if(thumbNewY < 0)
        thumbNewY = 0;
      else if(thumbNewY > movableBarV)
        thumbNewY = movableBarV;
      thumbV.style.transform = "translateY(" + thumbNewY + "px)";
      
      var trackRatio = thumbNewY / movableBarV;
      var actualMoveY = movableLengV * trackRatio;
      frameArea.scrollTop = actualMoveY;
    }

    DOMがロードされたあと、HTMLの生成 ⇒ 各種変数の初期化 ⇒ スクロール対象のサイズのアップデート が実行されます。
    初期化の中でイベントリスナーの追加が行われるので、初期化以降はイベント駆動で動いていきます。

    1.HTMLの生成
      TrackとThumbを生成してframe-areaに追加します。
      Classを追加するもの忘れずに。

    2.初期化
      変数を初期化、イベントリスナーの追加します。 

    3.trackとthumbの位置・サイズ調整
      Windowのリサイズイベント、frame-areaのリサイズが起こるとupdateTrack関数が発生するようになっています。
      frame-areaのリサイズはResizeのイベントをリッスンできないので、Observerで監視します。
      
      リサイズが起きたとき、スクロールが必要ない場合はtrackとthumbを隠します。
      位置計算は適用先の状況によって変わるので適時変えてください。

    4.thumbのドラッグによる移動
      ドラッグ動作ですが、ざっくり次のような形で行われています。
      updateTrack 
        Thumbが移動できる長さとスクロール対象が移動できる長さを事前に計算
      holdThumb
       Activeフラグを立てる
       Thumbの端からクリックした位置までの距離を取得する
      moveThumb
        A Thumbの初期位置と現在位置の差分量を計算
        B 差分量がThumb移動可能範囲の何割にあたるかを計算
        C その割合をスクロール可能範囲に乗じてスクロール量を算出
        D Thumbを差分量分だけTranslateする 
        E スクロール量をスクロール対象に適用する
      releaseThumb
        Activeフラグを折る


      考慮しないといけないのは、Thumbのドラッグ中は範囲選択が発動しないようにすること&ほかのマウスイベントを除外することです。
      MouseDown、MouseMove、MouseUpは全部の要素でも発火するため、イベント除外は最重要な考慮事項です。

      ”これから先のマウスイベントはScrollbarに関するイベントである”ことを明示的にするため、scrollThumbがMouseDownした時にactiveフラグを立てるようにしています。
      これでMove/Upのイベント中にもThumbに関するイベントであることを検知することが出来ます。
      Thumbドラッグ中のMouseMove/MosuseUpイベントは、Thumbから離れていても働いてほしいのでDocumentでイベントリッスンしている点に注意してください。
      
      Thumbドラッグ中はほかのイベントを却下したいので、Active中のMove/UpではPreventDefaultしています。
      upイベントでrequestAnimationFrameでActive=falseを囲っているのは、Moveのアニメーションが終了してから処理したいためです。
      スクロールイベントは負荷が高く、Upイベントが無視される場合の対策です。
      
      PreventDefaultを使用している関数をイベントリスナーに登録する場合、addEventListenerの第3引数に{ passive:false }を指定すると
    明示的にPreventDefaultをつかなわないことをブラウザに通知し処理を減らすことが出来ます。
      https://developer.mozilla.org/ja/docs/Web/API/EventTarget/addEventListener


    5.Trackをクリックしたときの動作
      クリックした位置にThumbの真ん中が来るようにしましょう。
      trackがクリックされたときのe.offset=Track端からの距離なので、オフセット量からThumbの長さの半分を減算した量でThumbをTranslateします。
      あとは実スクロール量の算出をMoveイベントと同じように行えばOKです。

    6.ホイールやキーボードで移動した場合の動作
      overflow:auto(scroll)が設定された要素にスクロールイベントを登録します。
      ScrollTopやScrollLeftでスクロール位置が取得できるので、それを利用してスクロール可能量のうち何割移動したかを算出しましょう。
      あとはその割合をThumb移動可能量に乗じて、新しいThumb位置を算出しTranslateすればOKです。


    これでカスタムスクロールバーが出来ました。



    <注意点>
    position:fixedが指定された要素は、親以上の要素にTransformがかかっていると無効になります。
    https://www.w3.org/TR/css-transforms-1/#transform-rendering

    この場合はTransform以外の方法で位置調整をするか、親子関係を解消するしかありません。