Javascript – アクセシビリティを考慮したアニメーション付きでスルスルっと移動するページ内スクロール

よく使うJavascritのページ内スクロール。
clickイベントでイベントを無効にしてからアニメーションを実行するのが一般的ですが、これだと通常のブラウザ機能の恩恵を放棄してしまっているので、整理してみました。

  • ページ内リンクはアニメーションで移動
  • キーボードと同時に操作した場合はアニメーションなしで通常のイベントを実行
  • リンク先がフォームパーツの場合はフォーカスも移動
  • ブラウザバックを利用する場合も考えブラウザ履歴も残す
1
(function innerScroll(duration, easing, breakpointVal) {
2

3
    // :rootから値を取得する関数
4
    const getCustomProperty = property => {
5
        const style = getComputedStyle(document.documentElement);
6
        return String(style.getPropertyValue(property)).trim();
7
    };
8

9
    // スクロール位置を取得
10
    const scrollPosition = (() => {
11
        if ('scrollingElement' in document) return document.scrollingElement;
12
        if (navigator.userAgent.toLowerCase().match(/webkit|msie 5/)) return document.body;
13
        return document.documentElement;
14
    })();
15

16
    // 目的の場所へアニメーション付きで移動
17
    var duration = typeof duration !== 'undefined' ? duration : 400;
18
    var easing = typeof easing !== 'undefined' ? easing : function easing(t, b, c, d) {
19
        return c * (t /= d) * t * t + b;
20
    };
21
    const scrollAnimation = (targetPos, scrollFrom, duration) => {
22
        const startTime = Date.now();
23
        // Anime.js
24
        if ('anime' in window) {
25
            var scrollTo = anime({
26
                targets: 'html, body',
27
                scrollTop: targetPos + scrollFrom,
28
                duration,
29
                easing: 'easeOutQuad'
30
            });
31
        } else {
32
            // requestAnimationFrame
33
            if (window.hasOwnProperty('requestAnimationFrame')) {
34
                (function loop() {
35
                    const currentTime = Date.now() - startTime;
36
                    if (currentTime < duration) {
37
                        scrollTo(0, easing(currentTime, scrollFrom, targetPos, duration));
38
                        window.requestAnimationFrame(loop);
39
                    } else {
40
                        scrollTo(0, targetPos + scrollFrom);
41
                    }
42
                })();
43
            } else {
44
                scrollTo(0, targetPos + scrollFrom);
45
            }
46
        }
47
    };
48

49
    // キーボードイベント
50
    const isKeybordEvents = e => e.ctrlKey || e.shiftKey || e.altKey || e.metaKey;
51

52
    // 要素へフォーカス
53
    const focusElement = element => {
54
        element.focus();
55
        if (document.activeElement !== element) {
56
            element.tabIndex = -1;
57
            element.focus();
58
        }
59
    };
60

61
    // 固定要素
62
    let diffVal = 0;
63
    const diffTarget = document.querySelector('.diff-target-innerscroll');
64
    var breakpointVal = typeof breakpointVal !== 'undefined' ? breakpointVal : getCustomProperty('--breakpoint-tb');
65
    const breakpoint = function breakpoint(mq) {
66
        if (mq.matches) diffVal = 0;
67
    };
68

69
    // クリックイベント
70
    document.addEventListener('click', e => {
71

72
        // マウスのみでのクリックの場合
73
        if (e.button === 0 && !isKeybordEvents(e) && e.target.hash) {
74

75
            // イベントの無効
76
            e.preventDefault();
77

78
            // 移動先が存在するかチェック
79
            const target = document.querySelectorAll(e.target.hash)[0];
80
            if (!target) return;
81
            target.style.outline = '0'; // 移動先のフォーカスを解除
82

83
            // 固定要素の高さ分移動先をずらす
84
            if (diffTarget !== null) {
85
                diffVal = diffTarget.clientHeight;
86
            }
87
            // 特定のウィンドウ幅にでずらしていた分を戻す
88
            if (breakpointVal && matchMedia) {
89
                const mq = window.matchMedia(`(max-width: ${breakpointVal})`);
90
                mq.addListener(breakpoint);
91
                breakpoint(mq);
92
            }
93

94
            // スクロール
95
            const targetPos = target.getBoundingClientRect().top - diffVal;
96
            const scrollFrom = scrollPosition.scrollTop;
97
            scrollAnimation(targetPos, scrollFrom, duration);
98

99
            if (target.nodeName.toUpperCase() === 'INPUT' || target.nodeName.toUpperCase() === 'TEXTAREA') {
100
                focusElement(target);
101
            }
102

103
            // ブラウザ履歴
104
            history.pushState({}, '', e.target.hash);
105
        }
106
    });
107
})();