milvinae

web & product design.

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

,

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

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

まずはCodepenから

See the Pen custom scroll bar 2 by keiyashi (@keiyashi) on 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以外の方法で位置調整をするか、親子関係を解消するしかありません。