せっかく細部までこだわってサイトを作ったのにスクロールバーが普通過ぎる… でも細かい挙動がわからないプラグインは入れたくない… そんなあなたに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が同じ位置に残ってほしいためです。
var frameArea ;
var scrollArea ;
var trackH ;
var thumbH ;
var trackV ;
var thumbV ;
var frameRect;
var scrollRect;
var thumbVTopEdge;
var thumbHLeftEdge;
var movableLengH;
var movableLengV;
var movableBarH;
var movableBarV;
const thumbThickness = 10 ;
const trackLengRatio = 0.8 ;
const thumbMinLength = 20 ;
const trackDistanceFromEdge = 5 ;
var activeH;
var activeV;
document. addEventListener ( "DOMContentLoaded" , ( e ) => {
createHTML ( ) ;
initialize ( ) ;
updateTrack ( ) ;
} ) ;
function createHTML ( ) {
scrollArea = document. querySelector ( '.scroll-area' ) ;
frameArea = document. querySelector ( '.frame-area' ) ;
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) ;
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) ;
} ;
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 } ) ;
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" ] } ) ;
}
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" ;
}
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;
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;
}
function holdThumbH ( e ) {
console. log ( "mouseDown" ) ;
activeH = true ;
thumbHLeftEdge = e. offsetX;
}
function holdThumbV ( e ) {
console. log ( "mouseDown" ) ;
activeV = true ;
thumbVTopEdge = e. offsetY;
}
function moveThumb ( e ) {
if ( ! activeH && ! activeV) { return ; }
e. preventDefault ( ) ;
if ( activeH) {
e = e. clientX;
console. log ( "horizontal: " + e) ;
var curThumbLeftX = e - trackH. getBoundingClientRect ( ) . x - thumbHLeftEdge;
var trackRatio = curThumbLeftX / movableBarH;
var actualMoveX = movableLengH * trackRatio;
if ( curThumbLeftX < 0 )
curThumbLeftX = 0 ;
else if ( curThumbLeftX > movableBarH)
curThumbLeftX = movableBarH;
thumbH. style. transform = "translateX(" + curThumbLeftX + "px)" ;
frameArea. scrollLeft = actualMoveX;
}
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" ) ;
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)" ;
}
function clickTrackH ( e ) {
if ( activeH) { return ; }
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以外の方法で位置調整をするか、親子関係を解消するしかありません。