GODRICH

フローティングメニュー デザイン9選 — ホバーで隣まで膨らむ

フローティングメニュー デザインとは、画面の端に浮かせた小さなメニュー帯の作り方のことで、数個のアイコンボタンと、今どの枠にいるかを示す印1つで成り立ちます。9つのうち1つは、ポインターが近づくと隣のアイコンを押し退けながら膨らみます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並べた順番は人気ではなく、アクティブの印が占める面積が少しずつ大きくなっていく順です。はじめの3つは、アイコンの上に足されるものが線・面・文字です。1本の下線(01)、枠を丸ごと埋めるカプセル(02)、アイコンの上へせり出す名前ラベル(03)という流れです。4つ目(04)からは印がアイコンそのものの大きさになり、ここから先は隣のアイコンが譲る場所まで計算しなければなりません。5つ目と6つ目は印の形ではなく帯そのものの素材を変えたものなので隣どうしに置きました — 曇りガラス(05)は backdrop-filter に、柔らかい影(06)は box-shadow 1対に頼っていて、狙う場所は同じでも受ける印象は正反対です。7つ目(07)は明るい背景でガラスが本当にガラスに見えるかを確かめる板、8つ目(08)はドックが板へ開く場合、最後(09)は横ではなく縦に立てた場合です。9つともライブラリなしで動きます。選ばれている状態を持つのは6つ(01・02・05・06・08・09)で、その状態は aria-currentaria-pressed といった属性に入れてあります。残る3つ(03・04・07)はホバーに応えるだけで、選択という概念そのものがありません。

01ミニマルラインドック

細い枠線のバーに5つのアイコンが収まり、押した項目の下で下線が中央から両側へ伸びます。下線は幅を変えずに scaleX を0から1へ動かすだけなので、行全体のレイアウトが計算し直されることはありません。飾りを最小にしたいポートフォリオやブログの下部固定メニューに向きます。

transformscaleXaria-current
.mn__line {
  position: absolute;
  left: 11px;
  right: 11px;
  bottom: 2px;
  height: 2px;
  border-radius: $r-pill;
  background: $color;
  transform: scaleX(0);
  transition: transform $duration $easing;
}
.mn__btn.on .mn__line { transform: scaleX(1); }

02ピルスライドインジケーター

アクティブ枠を埋めるカプセル形の背景が、1枠の幅ずつ横へ滑っていきます。1枠はアイコンボタン48pxに間隔8pxを足した56pxの固定値で、アイコン自体は位置を動かしません。動く背景そのものが現在地を示すので、アプリ画面の下部タブの切り替えに置くと読み取りやすい形です。

@propertytranslateXflex
@property --idx {
  syntax: "<integer>";
  inherits: true;
  initial-value: 0;
}
.pl__pill {
  position: absolute;
  width: 48px;
  height: 48px;
  border-radius: $r-pill;
  background: $color;
  transform: translateX(calc(var(--idx) * 56px));
  transition: transform $duration $easing;
}
.pl__btn { flex: 0 0 auto; width: 48px; }

03ラベルツールチップ

アイコンにポインターを載せると、名前ラベルがアイコンの上へ跳ね上がります。支点になるのはラベルの下辺なので transform-origin: bottom center が必要で、消えるときはフェードせずに1フレームで切れます。アイコンだけのドックで、初めての訪問者に各ボタンの役割を伝えたいときの定番の手です。

scaleYsteps(1, end)transform-origin
.tt__tip {
  position: absolute;
  bottom: calc(100% + #{$sp-2});
  left: 50%;
  opacity: 0;
  transform-origin: bottom center;
  transform: translateX(-50%) scaleY(0);
  transition: transform $duration $easing, opacity $dur-instant steps(1, end);
}
.tt__btn:hover .tt__tip,
.tt__btn:focus-visible .tt__tip {
  opacity: 1;
  transform: translateX(-50%) scaleY(1);
}

04アイコン拡大ウェーブ

ポインターに近いアイコンほど大きく膨らみ、遠いアイコンほど膨らみが小さくなります。やっかいなのは倍率のほうではなく押し退けです。膨らんで増えた幅を手前のアイコンから積み上げて配り、最後に増加分の合計の半分を引くと、行が中央に留まります。

@propertyscalepointermove
function wave(x) {
  var s = btns.map(function (b) {
    var c = b.offsetLeft + W / 2;
    var t = Math.max(0, 1 - Math.abs(x - c) / RANGE);
    return 1 + MAX * t * t;
  });
  var grown = s.map(function (v) { return (v - 1) * W; });
  var total = grown.reduce(function (a, b) { return a + b; }, 0);
  var before = 0;
  btns.forEach(function (b, i) {
    b.style.setProperty('--s', s[i].toFixed(3));
    b.style.setProperty('--x', (before + grown[i] / 2 - total / 2).toFixed(1) + 'px');
    before += grown[i];
  });
}

05グラスモーフィズムドック

曇りガラスの帯の後ろに、紫と水色の丸いにじみを敷いてあります。ガラスは後ろに何かがあって初めてガラスに見えるので、このにじみは飾りではなく材料です。アクティブなカプセルがガラスの上を滑り、その位置のアイコンだけが3px浮き上がります。

backdrop-filterradial-gradienttranslateY
.gl__blob--a {
  top: -40px;
  left: -20px;
  background: radial-gradient(circle, rgba(185, 160, 255, .95), rgba(185, 160, 255, 0) 70%);
}
.gl__dock {
  border: 1px solid rgba(255, 255, 255, .3);
  border-radius: $r-pill;
  background: rgba(255, 255, 255, .14);
  backdrop-filter: blur(12px) saturate(1.6);
}
.gl__btn[aria-current="page"] { color: #fff; transform: translateY(-3px); }

06ニューモーフィズムドック

背景とボタンが同じ色で、影1対だけが高さの差をつくります。押された状態は新しい影を描いたものではなく、同じ影を inset で裏返しただけで、その状態は aria-pressed が持っています。インテリアやベビー用品のように、柔らかい印象を売る画面に合います。

box-shadowinsetaria-pressed
.nu__btn {
  border-radius: 50%;
  background: $bg;
  box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light;
  transition: box-shadow $duration $easing, color $duration $easing;
}
.nu__btn[aria-pressed="true"] {
  color: $color;
  box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light;
}

07半透明ブラーバッジ

明るい背景では、ガラス効果はよく失敗します。白いバッジに白い背景では、ぼかす相手そのものがないからです。そこで色の帯3本をバッジの下へ通してあり、帯は右端まで進んでから同じ道を戻ってくるので、ループが巻き戻る瞬間に飛びが出ません。

backdrop-filterblurtranslateX
.bb__dock {
  border: 1px solid rgba(255, 255, 255, .7);
  background: rgba(255, 255, 255, .55);
  backdrop-filter: blur(8px);
}
.is-demo .bb__band--2 { animation-delay: -.22s; }
@keyframes bb-drift {
  0% { transform: translateX(0); }
  50% { transform: translateX(420px); }
  100% { transform: translateX(0); }
}

08ランチパッドグリッド展開

1行だったドックが7枠のグリッドへ開きます。板の大きさは畳んでも開いても変わらずアイコンだけが動くため、下にくっついたボタンが上下に揺れません。位置と倍率は、登録した3つのプロパティが受け持ちます。

@propertytranslatescale
.lp__btn {
  position: absolute;
  top: 50%;
  left: 50%;
  translate: calc(-50% + var(--x)) calc(-50% + var(--y));
  scale: var(--s);
  transition: translate $duration $easing, scale $duration $easing, opacity $dur-instant $easing;
}
.lp__btn--more { --s: 0; opacity: 0; pointer-events: none; }
.lp.open .lp__btn--more { --s: 1; opacity: 1; pointer-events: auto; }
.lp.open .lp__btn:nth-child(1) { --y: -44px; }

09縦サイドドック

ドックを画面の左端に縦へ立てた板です。アクティブな点が1枠36pxずつ下へ降り、名前ラベルはアイコンの右へ押し出されます。縦の中央揃えと押し出しがどちらも transform なので、1つの transform に順番どおり書かないと、後ろの指定が前の指定を打ち消してしまいます。

translateYopacityaria-current
.sr__dot {
  position: absolute;
  width: 3px;
  height: 16px;
  background: $color;
  transform: translateY(calc(var(--row) * 36px));
  transition: transform $duration $easing;
}
.sr__label {
  opacity: 0;
  transform: translateY(-50%) translateX(-6px);
  transition: transform $duration $easing, opacity $duration $easing;
}
.sr__btn:hover .sr__label { opacity: 1; transform: translateY(-50%) translateX(0); }

どこで壊れるか — 落とし穴

9つを組み上げるあいだ、いちばん手を入れ直したのはアニメーションそのものではなく、2つの要素が同じ場所を通り過ぎる一瞬でした。08番は当初、隠していた4つのアイコンをドックの真ん中からそれぞれの枠へ飛ばしていました。コードを読むかぎり何もおかしくないのに、代表画像を書き出してみると、虫めがねのアイコンの上に人のアイコンが半分乗ったコマが出てきたのです。手前の3つが上段へ上がっていく経路と、隠していた4つが飛んでくる経路が交差していたためで、2つの集団が同じ地点を別々の時刻に通るのではなく同時に通っていました。隠していた4つを最初から自分の空き枠に置いて倍率0のまま待たせ、手前の3つが上がりきってから大きくなるよう順番を分けると、重なりは出なくなりました。

03番は、同じ病気が別の顔で出たものでした。ラベルをだんだん薄くして消すようにしたところ、ポインターが隣のアイコンへ移るあいだラベルが2枚半透明で重なり、どちらの文字も読めませんでした。消える側を steps(1, end) で1フレームに切ってしまうと、その区間ごとなくなります。06番は別の理由で2度直しています。背景もボタンも影もすべて同じ色系統なので押した印が目に入らず、暗いほうの影を1段濃くして押されたアイコンの色まで変えたら、変わった画素の平均差が19.5から22.8へ上がりました。3つとも機械の検査には一度も引っかからず、人が見たときにだけ「ときどき妙なコマが混じる」と感じる種類のものです。

症状 本当の原因 直す場所
アイコンが2つ重なったコマが撮れる 2つの集団の移動経路が同じ時刻に交差している 片方をその場で大きくし、順番を分ける
名前ラベルが2枚、半透明で重なる 消え方がフェードになっている opacitysteps(1, end) で断つ
アクティブの印がアイコンから横へずれる ボタンが均等配分で実寸が固定値と食い違う ボタンに flex: 0 0 auto と固定幅
印が枠と枠のあいだをぱっと飛び越える 数値プロパティが未登録で中間値が作られない @propertysyntax を宣言する

この表の4行をすべて反映した9本のソースは、パスワード wd6vwhyu を入れると開く zip に収めてあり、バニラ版と React 版が同じ3つの変数を読みます。

アクセシビリティ

選ばれている状態を持つ6つは、それをクラスではなく属性に持たせました。01・02・05・09番は aria-current="page"、06番は aria-pressed、08番の開閉ボタンは aria-expandedaria-controls を使います。このうち02・05・06・09番は CSS もその属性セレクタで描いているので、見えているものと伝わるものが食い違いようがありません。01番だけは aria-current を付けたうえで、下線を伸ばすほうは .mn__btn.on というクラスセレクタが受け持っています。03番のラベルは aria-hidden="true" にして、代わりに視覚的に隠したテキストをボタンの中へ入れてあります。同じ言葉が二重に読み上げられるのを避けるためです。ボタンの名前の与え方は2通りあり、01・02・04・05・06・07・08番はアイコンだけのボタンに aria-label を付け、03・09番はボタンの内側にあるテキスト(03番は視覚的に隠した .tt__sr、09番は右へ押し出される .sr__label)がそのまま名前になります。どのボタンも :focus-visible で輪郭を残すので、キーボードでたどっている最中も現在位置が見えます。アイコンはすべて Phosphor Icons のデュオトーン SVG をインラインで埋め込んだもので、フォントも CDN も呼びません。prefers-reduced-motion: reduce のときは自動再生のループと、滑り・跳ね上がりの動きだけを止め、アクティブの印そのものは残します。どの枠にいるかは演出ではなく情報だからです。現在地を支援技術がどう読むかは MDN の aria-current の解説にまとまっています。

同じ系統をもっと探すなら、ホバーのカテゴリランディングページのカテゴリに、組み合わせやすい部品が集まっています。

FAQ

アクティブの印を固定pxで動かすと、画面幅が変わったときにずれませんか

ずれます。ただし、ずれる原因は画面幅ではなくボタンの幅が可変であることのほうです。02番のように translateX(calc(var(--idx) * 56px)) で運ぶなら、ボタンは flex: 0 0 auto と固定幅でなければならず、そうしておけば画面が狭くなっても1枠は56pxのままです。逆に flex: 1 1 0 で余った幅を分け合う設定にすると、実際に描かれる幅が毎回変わるのでカプセルが隣の枠にはみ出します。狭い画面で縮めたいときは、メディアクエリでボタン幅と間隔、そして1枠の値を3つそろえて書き換えてください。

アイコンが大きくなるとき、隣を押し退ける計算はなぜ必要なのですか

倍率だけを与えるとアイコンどうしが重なるからです。scale は要素が占める場所を変えずに描かれる大きさだけを変えるので、1.8倍に膨らんだアイコンは自分の枠をはみ出して両隣の上へかぶさります。04番が各アイコンの増えた幅を手前から積み上げて移動量に配っているのはこのためで、最後に増加分の合計の半分を引いているのは、行全体が右へずれていかないようにするためです。

曇りガラスのドックが背景によってのっぺり見えます

backdrop-filter は後ろにある絵をぼかすフィルタなので、後ろが単色だと何も起きません。05番がドックの裏に色のにじみを2つ敷いたのも、07番が色の帯をバッジの下へ通したのも、どちらもそれが理由です。背景が真っ白な画面なら、ガラスに頼らず、ごく薄い影と1pxの枠線で浮いている感じを作ったほうがきれいにまとまります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。