JavaScriptでMac OSXのDockのように拡大縮小するアイコン

2016-06-13

題名のとおりなんですけど、マウスホバーでアイコンが拡大表示される、Mac OSXのDockのような動きをするメニューをJavaScriptで書きました。
例によってライブラリとかjQueryを使用していないので、掲載しているコードだけで動きます。
6/13追記:スムーズさを出すためにフレームレートを変更したのと若干のコード変更を行いました。

このDockメニューはActionScript(Flash)でやってから何度も書いているのですが、HTML5やCSS3、SVGのおかげでだいぶ簡単に実現することができるようになりました。

デモ

下のSNS系のアイコンの上にマウスを乗っけてみてください。ちゃんとそれっぽく動くでしょう?!

Mac OSXが出てから15年以上にわたって変わらないGUIとして見た場合のDockは、やはり優れたGUIなんでしょうね。それをWebで実現したからといって使いどころは難しいですけど。。

クリックしても単なるダミーのURLを貼っているだけなので何も起きません。

これだけのことを実行するのに、JavaScriptの部分だけなら、Minify + gzipでたったの600バイト程度です。HTMLコードやCSS、8個のSVGアイコンも含めても3,500バイト程度ですよ。

小さいコードで書くことは、ダウンロード時間を短くするだけじゃなくて、今回のコードのように50/s程度のフレームレートで多数の計算やDOM変更を伴う場合でもスムーズに動く要因ともなります。

それでは、上記のデモを実現するコードを紹介します。

コード(HTML + CSS + JavaScript + アイコンSVG)

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <title>Dock</title>
    <style>
    #sns {
        position: relative;
        margin: 0 auto;
        display: flex;
    }

    /* for touch devices and javascript off user */
    #sns li {
        flex: 1 1 auto;
        list-style: none;
    }
    #sns a {
        display: block;
        text-align: center;
    }
    #sns a::before {
        display: block;
        margin: 0 auto;
    }

    .dock li {
        display: block;
        position: absolute;
        bottom: 0;
        transform-origin: 50% 100%;
    }
    .dock li.hide {
        opacity: 0;
    }
    .dock a {
        display: block;
        width: 30px;
        height: 30px;
        padding: 4px;
        margin: 0 1px;
        border-radius: 4px;
    }
    .dock span {
        display: none;
    }
    .dock li:hover span {
        position: absolute;
        display: block;
        margin: 0 0 3px;
        bottom: 100%;
        left: 50%;
        transform: translateX(-50%);
        white-space: nowrap;
        text-align: center;
        font-size: 10px;
        border-radius: 1em;
        padding: .1em 1em;
        color: #fff;
        background: #666;
    }
    .dock li:hover span::before {
        content:'';
        position:absolute;
        display:block;
        width:0;
        bottom:-3px;
        left:50%;
        margin-left: -4px;
        border-style:solid;
        border-color:#666 transparent;
        border-width: 4px 4px 0;
    }

    .facebook {
        background: #315096;
    }
    .facebook::before {
        content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg'  viewBox='0 0 32 32'><path fill='#fff' d='M18 32h-6V16H8v-5.5l4-.02V7.24C12 2.74 13.2 0 18.5 0h4.42v5.5h-2.75C18.1 5.5 18 6.3 18 7.73v2.76h4.95L22.38 16H18v16z'/></svg>");
    }
    .twitter {
        background: #55acee;
    }
    .twitter::before {
        content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg'  viewBox='0 0 32 32'><path fill='#fff' d='M32 6.08c-1.18.52-2.44.87-3.77 1.03 1.35-.8 2.4-2.1 2.9-3.62-1.28.75-2.7 1.3-4.18 1.6-1.2-1.3-2.9-2.08-4.8-2.08-3.62 0-6.56 2.94-6.56 6.56 0 .52.05 1.02.16 1.5-5.46-.27-10.3-2.9-13.53-6.86-.57.97-.9 2.1-.9 3.3 0 2.28 1.17 4.3 2.93 5.47-1.08-.04-2.1-.33-2.97-.83-.02.03-.02.06-.02.1 0 3.17 2.27 5.82 5.27 6.42-.55.15-1.13.23-1.73.23-.42 0-.83-.05-1.23-.12.82 2.6 3.25 4.5 6.12 4.56-2.25 1.76-5.08 2.8-8.16 2.8-.53 0-1.05-.03-1.56-.1C2.9 27.93 6.35 29 10.06 29c12.08 0 18.68-10 18.68-18.68 0-.28 0-.56-.02-.85 1.3-.92 2.4-2.08 3.28-3.4z'/></svg>");
    }
    .hateb {
        background: #008fde;
    }
    .hateb::before {
        content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path fill='#fff' d='M23.87 24.35c0 1.74 1.4 3.15 3.15 3.15s3.16-1.4 3.16-3.15c0-1.74-1.42-3.16-3.16-3.16s-3.15 1.4-3.15 3.15zm.28-20.62h5.75v15.7h-5.75zM15.4 14.3s3.98-.26 3.98-5.02c0-5.5-4.98-5.54-7.82-5.54H1.82v23.78h9.65c7.86 0 9.2-4.3 9.2-7.05s-1.34-5.34-5.26-6.18zM7.88 8.2h2.67c.5 0 2.67.2 2.67 2.28 0 2.44-1.88 2.36-3.13 2.36H7.87V8.22zM11 22.46H7.9v-5.22h3.2s3.2.38 3.2 2.6-1.8 2.62-3.27 2.62z'/></svg>");
    }
    .pocket {
        background: #ee4056;
    }
    .pocket::before {
        content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg'  viewBox='0 0 32 32'><path fill='#fff' d='M27.5 3.17H4.33c-1.58 0-2.86 1.27-2.86 2.85v8.45c0 7.98 6.47 14.45 14.45 14.45 7.9 0 14.32-6.34 14.46-14.2v-8.7c0-1.58-1.27-2.85-2.85-2.85zM24 14l-6.47 6.47c-.42.44-1.02.62-1.6.55-.56.07-1.15-.1-1.6-.55L7.85 14c-.75-.75-.75-1.98 0-2.74s1.98-.75 2.74 0l5.33 5.34 5.35-5.34c.75-.75 1.98-.75 2.73 0s.76 2 0 2.74z'/></svg>");
    }
    .feedly {
        background: #6cc655;
    }
    .feedly::before {
        content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg'  viewBox='0 0 32 32'><path fill='#fff' d='M30.56 16.67l-13.3-13.3c-.7-.7-1.82-.7-2.52 0l-13.3 13.3c-.7.7-.7 1.82 0 2.52l9.96 9.95h9.2l9.96-9.96c.7-.7.7-1.83 0-2.53zm-22.13 2.8l-1.48-1.5c-.1-.1-.1-.26 0-.37l8.78-8.78c.1-.1.27-.1.37 0l1.98 1.98c.1.1.1.27 0 .37l-8.3 8.3H8.44zm9.73 5.5l-1.48 1.47h-1.36l-1.48-1.48c-.1-.1-.1-.27 0-.37l1.97-2c.12-.1.28-.1.4 0l1.96 2c.1.1.1.26 0 .36zM18.2 18l-4.94 4.92H11.9l-1.5-1.48c-.1-.1-.1-.27 0-.37l5.44-5.42c.1-.1.27-.1.37 0l2 1.98c.1.1.1.27 0 .37z'/></svg>");
    }
    .googlep {
        background: #dd4b39;
    }
    .googlep::before {
        content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg'  viewBox='0 0 32 32'><path fill='#fff' d='M17.47 2H9.1C5.34 2 1.8 4.84 1.8 8.14c0 3.36 2.57 6.08 6.4 6.08.26 0 .5 0 .77-.02-.25.47-.43 1-.43 1.56 0 .94.5 1.7 1.14 2.3-.48 0-.94.03-1.45.03C3.58 18.1 0 21.04 0 24.1c0 3.02 3.92 4.92 8.57 4.92 5.3 0 8.23-3 8.23-6.04 0-2.42-.7-3.87-2.93-5.44-.75-.53-2.2-1.84-2.2-2.6 0-.9.26-1.34 1.6-2.4 1.4-1.08 2.37-2.6 2.37-4.37 0-2.1-.94-4.17-2.7-4.85h2.66L17.47 2zm-2.92 20.48c.06.28.1.57.1.87 0 2.44-1.58 4.35-6.1 4.35-3.2 0-5.53-2.04-5.53-4.48 0-2.4 2.88-4.4 6.1-4.35.74 0 1.44.13 2.08.33 1.74 1.2 3 1.9 3.35 3.28zm-5.15-9.1c-2.16-.08-4.2-2.43-4.58-5.26s1.07-5 3.23-4.93c2.16.05 4.2 2.32 4.58 5.16s-1.07 5.07-3.23 5zM26 8V2h-2v6h-6v2h6v6h2v-6h6V8z'/></svg>");
    }
    .line {
        background: #00c300;
    }
    .line::before {
        content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg'  viewBox='0 0 32 32'><path fill='#fff' d='M15.93 1.95c-7.96 0-14.4 5.28-14.4 11.8 0 5.84 5.2 10.7 12.02 11.63h.08l.23.04c.75.1 1.1.28 1.1 1.1-.02.92-.38 1.6-.6 2.04s-.67 2.25 1.4 1.13c1.58-.88 9.37-4.7 12.87-10.37.93-1.4 1.5-3 1.67-4.68v-.15l.03-.25v-.5c0-6.52-6.44-11.8-14.4-11.8zM9.9 17.15H7.13c-.4 0-.73-.33-.73-.73v-5.57c0-.4.33-.74.74-.74s.73.34.73.75v4.83h2c.42 0 .75.33.75.73s-.33.75-.74.75zm3.12-.73c0 .4-.33.73-.74.73s-.73-.33-.73-.73v-5.57c0-.4.33-.74.73-.74s.74.34.74.75v5.56zm6.7.5c0 .02-.02.03-.03.04l-.1.06c0 .02-.03.03-.06.04l-.06.03c-.02 0-.05 0-.08.02h-.05l-.15.03c-.05 0-.1 0-.14-.02-.02 0-.05 0-.07-.02s-.06 0-.08 0l-.07-.05c-.02 0-.04 0-.05-.02-.1-.07-.17-.15-.23-.24L15.72 13v3.4c0 .4-.33.73-.73.73s-.74-.33-.74-.74v-5.55-.04-.1c.02-.02.03-.05.03-.07v-.06l.06-.1s0-.02.02-.03c.05-.08.12-.15.2-.2.02 0 .03-.02.05-.03l.08-.03.08-.02.07-.02.13-.02H15c.05 0 .1.02.14.03.02 0 .04 0 .06.02.02 0 .05 0 .07.02.03 0 .05.02.07.04l.06.04c.04.02.08.05.1.1.05.03.1.08.12.13l2.85 3.77v-3.4c0-.4.33-.73.73-.73s.74.34.74.75v5.56c0 .06 0 .1-.02.16v.05l-.04.1c0 .02-.02.03-.03.05 0 .03-.02.05-.04.07 0 .03-.03.06-.05.08l-.03.03zm4.82-4.02c.4 0 .73.33.73.73s-.33.73-.73.73h-2v1.32h2c.4 0 .73.33.73.73s-.33.75-.73.75H21.8c-.42 0-.74-.33-.74-.74v-5.55c0-.4.33-.74.73-.74h2.74c.4 0 .73.34.73.75s-.33.73-.73.73h-2v1.3h2z'/></svg>");
    }
    .github {
        background: #333;
    }
    .github::before {
        content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg'  viewBox='0 0 32 32'><path fill='#fff' d='M16 0C7.16 0 0 7.16 0 16s7.16 16 16 16 16-7.16 16-16S24.84 0 16 0zm9.5 25.5c-1.23 1.24-2.67 2.2-4.27 2.88-.4.18-.82.33-1.24.46v-2.4c0-1.26-.44-2.2-1.3-2.78.54-.06 1.03-.13 1.5-.22s.92-.23 1.42-.4.96-.4 1.36-.64.8-.56 1.16-.95.68-.84.93-1.33.45-1.1.6-1.78.2-1.46.2-2.3c0-1.6-.5-2.98-1.57-4.12.48-1.25.43-2.6-.15-4.07l-.4-.05c-.26-.03-.75.08-1.45.34s-1.5.7-2.37 1.28c-1.24-.34-2.53-.5-3.86-.5-1.34 0-2.62.16-3.84.5-.56-.37-1.08-.68-1.57-.93s-.9-.42-1.2-.5-.56-.15-.82-.17-.42-.03-.5-.02L8 7.85c-.6 1.48-.64 2.84-.16 4.08-1.06 1.14-1.58 2.5-1.58 4.13 0 .83.07 1.6.22 2.3s.34 1.27.6 1.77.55.94.92 1.33.76.7 1.16.95.85.45 1.36.63.98.3 1.43.4.96.17 1.5.23c-.86.58-1.28 1.5-1.28 2.78v2.44c-.48-.14-.94-.3-1.4-.5-1.6-.67-3.04-1.64-4.27-2.88s-2.2-2.67-2.88-4.27c-.7-1.66-1.06-3.42-1.06-5.23s.36-3.58 1.06-5.23C4.3 9.17 5.26 7.73 6.5 6.5s2.67-2.2 4.27-2.88c1.66-.7 3.42-1.06 5.23-1.06s3.58.36 5.23 1.06c1.6.67 3.04 1.64 4.27 2.88s2.2 2.67 2.88 4.27c.7 1.65 1.06 3.4 1.06 5.23s-.36 3.57-1.06 5.23c-.67 1.6-1.64 3.04-2.88 4.27z'/></svg>");
    }
    </style>
</head>

<body>
    <p id="dummy" style="height:300px;">DUMMY for space</p>
    <ul id="sns">
        <li>
            <a class="pocket" href="#">pocket</a>
        </li>
        <li>
            <a class="facebook" href="#">facebook</a>
        </li>
        <li>
            <a class="twitter" href="#">twitter</a>
        </li>
        <li>
            <a class="github" href="#">github</a>
        </li>
        <li>
            <a class="googlep" href="#">google+</a>
        </li>
        <li>
            <a class="line" href="#">line</a>
        </li>
        <li>
            <a class="hateb" href="#">hateb</a>
        </li>
        <li>
            <a class="feedly" href="#">feedly</a>
        </li>
    </ul>
    <script>

    // マウスホバーした時の拡大率
    var zoom = 4;

    (function(zoom) {
        'use strict'

        //
        if('ontouchstart' in window) return;
        var anime;
        var mouseX;

        var dock = document.getElementById('sns');
        dock.classList.add('dock');
        var icons = dock.children;
        var defaultWidth = icons[0].offsetWidth;
        var bound = defaultWidth * 3.14;
        dock.style.width = defaultWidth * (icons.length -1) +'px';
        dock.style.height = defaultWidth +'px';
        [].forEach.call(icons, function(icon, i) {
            var span = document.createElement('span');
            var anchor = icon.getElementsByTagName('a')[0];
            var text = anchor.textContent;
            span.textContent = text;
            icon.appendChild(span);
            anchor.textContent = '';

            // icon.style.transition = ...でもOK
            icon.setAttribute('style', 'transition:.3s;left:' + i * defaultWidth + 'px');
        });

        // 50/sのフレームレートで実行される、各アイコンの位置や大きさを決めている関数
        function scaling(x) {
            for(var i = icons.length; i--;) {
                var icon = icons[i];
                var distance = (i * defaultWidth + defaultWidth / 2) - x;
                if(-bound < distance && distance < bound) {
                    var rad = distance / defaultWidth * .5;
                    var currentScale = icon.getBoundingClientRect().width / icon.offsetWidth;
                    var scale = currentScale + (1 + (zoom - 1) * Math.cos(rad) - currentScale)/5;
                    var currentLeft = icon.offsetLeft;
                    var left = currentLeft + (i * defaultWidth + 2 * (zoom - 1) * defaultWidth * Math.sin(rad) -currentLeft)/5;
                    // icon.style.left .., icon.style.transform = ..じゃないのは、速度を
                    // 優先したため
                    icon.setAttribute('style', 'left:' + left + 'px;transform:scale(' + scale + ')');
                } else {
                    var left;
                    if(-bound < distance) {
                        left = i * defaultWidth + 2.05 * (zoom - 1) * defaultWidth;
                    } else {
                        left = i * defaultWidth - 2.05 * (zoom - 1) * defaultWidth;
                    }
                    var currentLeft = icon.offsetLeft;
                    var currentScale = icon.getBoundingClientRect().width / icon.offsetWidth;
                    icon.setAttribute('style', 'left:' + (currentLeft + (left - currentLeft)/5) + 'px;transform:scale('+(currentScale + (1-currentScale)/5)+')');
                }
            }
        }

        dock.addEventListener('mousemove', function(e) {
            mouseX = e.clientX - dock.getBoundingClientRect().left;
        });

        // dockの上にマウスが入ったときに各アイコンの拡大処理をスタート
        dock.addEventListener('mouseenter', function(e) {
            // フレームレート 50/s で各アイコンを拡大/縮小する
            anime = setInterval(function() {
                scaling(mouseX);
            }, 20);

            // dock自体も拡大しないと、iconの隙間にマウスが入ったときに
            // 不意に縮小する
            dock.style.height = defaultWidth * zoom + 'px';
            dock.style.marginTop = -(zoom - 1) * defaultWidth + 'px';
        });

        // マウスがdockから離れたとき
        dock.addEventListener('mouseleave', function(e) {
            clearInterval(anime);
            dock.style.height = defaultWidth + 'px';
            dock.style.marginTop = 0;

            // 通常の状態に戻る縮小処理はJavaScriptじゃなくてcssのtransitionでやってる
            // jsでやると、全ての処理が終了するまでanime処理を終わらせられなくて、その間に
            // またmouseenterになったときの処理が面倒
            for(var i = icons.length; i--;) {
                // tansform:scaleは書かなくても、setAttributeでやっていると
                // transform属性自体削除されているのでscale(1)扱いになる
                icons[i].setAttribute('style', 'transition: .3s;left:' + i * defaultWidth + 'px');
            }
        });
    }(zoom));
    </script>
</body>
</html>

このコードをコピーしてHTMLファイルを作れば、簡単に同じものが再現できますので、ぜひやってみてください。

JavaScriptSVGCSS3HTML5