GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

css 開閉 ボタン 9選 — ボタンの形が変わる9つの方式

css 開閉 ボタン:9つのうち1つは開く値が変わるとボタンの角の丸み値が一緒に大きくなり、1つは閉じる値がオンになるとプラス記号が回転して閉じる記号に変わり、もう1つは展開値が伸びる間、幅の値が目標を少し越えてから戻ります。

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

この9つのデモはどれもサーバーと通信せず、localStorage にも触れず、乱数も使いません。06番のヒント一覧は本物の検索APIではなく固定された3つの文字列の配列から読んでおり、それ以外の項目はどれも一つの真偽値か数値の状態と、その値の変化がどう見えるかを決めるいくつかの定数だけで成り立っています。この狭い範囲こそが、こうした形が変わるボタンの集まりをチェックリストのように読めるようにしています——切り替わった値の名前さえ言えれば、コードを開く前に方式そのものの見分けがつきます。

9つのパターンの解剖 — どの形の値がいつ変わるか

9つのうち requestAnimationFrame ループを回すのは2つだけです。04番の幅切替は毎フレーム a = -K*(cur-target) - D*vel を積分し、展開した幅が REST_W を一度越えてから収まります。09番の折りたたみ切替も同じばねの式を、幅ではなく scaleX の値に対して走らせています。02番は $ease-elastic の曲線に乗った一回きりのCSS transitionで似た行き過ぎを演出しているだけなので、SPIN_DEG と OVER_MS という定数は毎フレーム計算される値ではなく、狙った角度と時間を示すための値にとどまります。9つのうち5つは描く前に実際のレイアウトを実測します——01番は各リスト項目の getBoundingClientRect().height を合計して --panel-h に渡し、04番と08番はともにラベルを一瞬 width: auto にして自然な幅を読み取り、05番はシェルの幅を --shell-w として読み、06番は開いた時点で入力欄の幅を --input-w として読みます。実測に基づくこのような作りと、固定のピクセル値に賭けるだけのメニュー 開閉 ボタンとの違いは、フォントや文言が変わったときにこそ表れます。残る02・03・07・09番は RADII・STEP_PX・K・D といった固定の定数だけで動き、DOM実測の呼び出しを一切しないため、フォントサイズや内容の長さに合わせて形の計算が変わることはありません。リスナーはどれも実際の操作に応えます——01・02・04・07番は click を、03番は独自の role="button" div に対して click と、EnterとSpaceのための keydown を、05番はシェルと二つの確認ボタンにまたがる click を、06番は閉じるボタンの click と入力欄の input・focus を、08番は pointerenter・pointerleave・click・focus を、そして09番だけが pointerdown と pointerup を中心に組まれており、これは押して離す動作そのものに跳ね返りが依存しているためです。

パターン別コード

01アコーディオン表示切替 — ボタンの開く値が変わると丸み値と影の値が一緒に変わり、下の内容を差し出す方式

開く値が変わるとボタンの角の丸み値が大きくなり影の値が深くなって、下の内容がその下に現れます。FAQ項目の開閉や設定グループの展開に向いており、表示そのものが少し重みを持って感じられます。

アコーディオン表示切替 — ボタンの開く値が変わると丸み値と影の値が一緒に変わり、下の内容を差し出す方式
新しいタブで開く

01 · アコーディオン表示切替 — ボタンの開く値が変わると丸み値と影の値が一緒に変わり、下の内容を差し出す方式 — 計測 累積面積 18.301% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STAGGER_MSmeasuretoggle
  var STAGGER_MS = 70; // 計算層: 項目の順番 * この値だけ遅れて現れる

  // 計算層: 下位項目が実際に占める表示高さを getBoundingClientRect で実測し、
  // panel の --panel-h に渡す — 推測の固定値ではなく今のフォント・内容基準の値。
  function measure() {
    var h = 0;
    items.forEach(function (li) { h += li.getBoundingClientRect().height; });
    panel.style.setProperty('--panel-h', h + 'px');
  }
  items.forEach(function (li, i) {
    li.style.transitionDelay = (i * STAGGER_MS) + 'ms';
  });

数字が語ること

01番は面積を累計18.3%埋め、平均強度154.3で23フレーム中8フレームが動いています。ずれの仕組みが3つの項目の表示を一度にではなく順番に間隔を空けて見せています。

02プラス回転切替 — 閉じる値がオンになるとプラス記号が回転して閉じる記号に変わる方式

閉じる値がオンになるとプラス記号が回転して閉じる記号に変わり、回転が目標の角度を少し越えてから戻ります。項目追加ボタンやフィルターを開くボタンに自然に使え、アイコン自体が動作を伝えます。

プラス回転切替 — 閉じる値がオンになるとプラス記号が回転して閉じる記号に変わる方式
新しいタブで開く

02 · プラス回転切替 — 閉じる値がオンになるとプラス記号が回転して閉じる記号に変わる方式 — 計測 累積面積 0.642% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPIN_DEGOVER_MStoggle
.pc__icon {
  display: block;
  transition: transform 360ms $ease-elastic;
}

.pc__v, .pc__h {
  stroke: $stage-yellow;
  stroke-width: 2.4;
  stroke-linecap: round;
  fill: none;
}

// 線を描く: 縦棒は pathLength 1 基準で dashoffset が 0 → 1 になり消える
.pc__v {
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 270ms $ease-out;
}

.pc--closed {
  .pc__icon { transform: rotate(135deg); }
  .pc__v { stroke-dashoffset: 1; }
  .pc__disc { background: rgba($stage-yellow, .32); }
}

数字が語ること

02番は面積をわずか0.64%しか埋めず、平均強度56.7で23フレーム中8フレームが動いています。アイコンがその場で回転するだけなので、パネルが開く動きよりずっと小さい面積しか動きません。

03角丸値の成長 — 開く値が大きくなるほどボタンの角の丸み値が一緒に大きくなる方式

開く値が段階ごとに大きくなるたび角の丸み値が同じ幅で大きくなり、ボタンがだんだん柔らかい形に変わります。多段階フォームの次へボタンや段階選択カードは、この形の変化を新しい文字を足さずに状態の進行として使えます。

角丸値の成長 — 開く値が大きくなるほどボタンの角の丸み値が一緒に大きくなる方式
新しいタブで開く

03 · 角丸値の成長 — 開く値が大きくなるほどボタンの角の丸み値が一緒に大きくなる方式 — 計測 累積面積 1.665% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RADIIRADII_TEXTadvance
  var RADII = [12, 16, 24, 999]; // 計算層: 段階ごとに大きくなる丸み値(px) — 最後はピル
  var RADII_TEXT = ['丸み値 12px', '丸み値 16px', '丸み値 24px', '丸み値 ピル'];
  var step = 0;

  function paint() {
    var r = RADII[step];
    // 丸み値は % ではなく px で補間しないと成長が直線に見えない — 999px はピル表記
    btn.style.borderRadius = (r === 999 ? '999px' : r + 'px');
    read.textContent = RADII_TEXT[step];
    fill.style.transform = 'scaleX(' + ((step + 1) / RADII.length) + ')';
    dots.forEach(function (d, i) {
      d.classList.toggle('cr__dot--on', i <= step);
    });
  }

  function advance() {
    step = (step + 1) % RADII.length;
    paint();
  }

数字が語ること

03番は面積を1.67%埋め、平均強度68.6で23フレーム中15フレームが動いており、これはこの9つの中で最も多いフレーム数です。4段階の自動再生が進むたびborder-radius・fillバー・ドット列をすべて描き直すためです。

04幅の慣性切替 — 展開値が伸びる間、幅の値が目標を少し越えてから戻る方式

展開値が伸びると幅の値が目標より少し越し、それから戻ってくるため、伸びることに重みがあるように見えます。本文中のもっと見るボタンやインラインツールバーの展開は、この重みのある収まり方が瞬時の切替より丁寧に見えます。

幅の慣性切替 — 展開値が伸びる間、幅の値が目標を少し越えてから戻る方式
新しいタブで開く

04 · 幅の慣性切替 — 展開値が伸びる間、幅の値が目標を少し越えてから戻る方式 — 計測 累積面積 4.333% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

REST_WKD
  function measure() {
    var prev = more.style.width;
    more.style.width = 'auto';
    var w = more.getBoundingClientRect().width;
    more.style.width = prev || '0px';
    return Math.ceil(w);
  }
  var openW = measure() + REST_W + 24; // 24 = 基準幅に足す余裕分

  var open = false;
  var cur = REST_W, vel = 0, target = REST_W;
  var raf = null;

  // ばね減衰計算: 毎フレーム a = -K*(cur-target) - D*vel — 行き過ぎてから収まる
  function tick() {
    var a = -K * (cur - target) * 0.001 - D * vel * 0.01;
    vel += a;
    cur += vel;
    if (Math.abs(cur - target) < 0.4 && Math.abs(vel) < 0.05) { cur = target; vel = 0; }
    btn.style.width = cur.toFixed(1) + 'px';
    var p = Math.max(0, Math.min(1, (cur - REST_W) / (openW - REST_W)));
    more.style.opacity = String(p);
    raf = requestAnimationFrame(tick);
  }

数字が語ること

04番は面積を4.33%埋め、平均強度164.3——この9つの中で最も高い値——で23フレーム中14フレームが動いています。ばねの行き過ぎと収まりが、単発の遷移より長くボタンの端を動かし続けています。

05二つ割れ切替 — 確定値がオンになるとボタンが二つの破片に割れ、それぞれ別の値を現す方式

確定値がオンになるとボタンが二つに割れ、それぞれの破片が反対方向に開いて中の値を現します。削除確認ボタンや二択の確認は、割れること自体が選択を求めているという合図になります。

二つ割れ切替 — 確定値がオンになるとボタンが二つの破片に割れ、それぞれ別の値を現す方式
新しいタブで開く

05 · 二つ割れ切替 — 確定値がオンになるとボタンが二つの破片に割れ、それぞれ別の値を現す方式 — 計測 累積面積 6.995% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PART_PXmeasuretoggle
  var PART_PX = 26; // 計算層: 二つの半分が開く距離(px) — シェル幅の半分基準の算術

  // 計算層: シェル幅を getBoundingClientRect で実測し --shell-w に渡す。
  // 半分はそれぞれシェル幅の50%で描かれるため、開く距離もこの値に合わせる。
  function measure() {
    shell.style.setProperty('--shell-w', shell.getBoundingClientRect().width + 'px');
  }

  var split = false;
  function paint() {
    root.classList.toggle('sd--split', split);
    shell.setAttribute('aria-expanded', String(split));
  }

  function toggle() {
    split = !split;
    paint();
  }

数字が語ること

05番は面積を7.0%埋め、平均強度はちょうど100.0で23フレーム中7フレームが動いており、二つの半分が一度の遷移で反対の端へ分かれます。

06丸薬から帯への切替 — 検索値がオンになると丸薬形の値が帯形の値に変わり、入力の場所が生まれる方式

検索値がオンになると丸薬形が帯形に伸びて入力の場所が現れ、オフになると再び丸薬に縮みます。ヘッダーの検索ボタンや狭い画面の検索展開に向いており、下のヒント一覧は実際に何かを検索するわけではなく固定のままです。

丸薬から帯への切替 — 検索値がオンになると丸薬形の値が帯形の値に変わり、入力の場所が生まれる方式
新しいタブで開く

06 · 丸薬から帯への切替 — 検索値がオンになると丸薬形の値が帯形の値に変わり、入力の場所が生まれる方式 — 計測 累積面積 8.393% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BAR_WSEEDSpaintHints
  // シード固定のローカル値 — 実際の検索APIは使わない
  var SEEDS = ['サンプル値 1', 'サンプル値 2', 'サンプル値 3'];
  function paintHints() {
    var hints = root.querySelectorAll('.ps__hint');
    var q = input.value.trim();
    var n = q ? Math.min(2, SEEDS.length) : 0;
    hints.forEach(function (h, i) {
      h.classList.toggle('ps__hint--on', i < n);
      if (i < n) h.textContent = SEEDS[i];
    });
  }

数字が語ること

06番は面積を8.39%埋め、平均強度141.9で23フレーム中7フレームが動いています。丸薬から帯形への幅の変化と現れる入力欄が、その面積のほとんどを占めています。

07重ね持ち上げ切替 — 詳細値がオンになると後ろに重なっていた同じ形の値が上へ順に持ち上がる方式

詳細値がオンになるとボタンの後ろに重なっていた同じ形が一拍ずつずれて上に持ち上がり、オフになると順に下がります。カード重なりのもっと見るボタンや通知一覧の展開は、このずれた持ち上がりで後ろに何個控えているかを匂わせます。

重ね持ち上げ切替 — 詳細値がオンになると後ろに重なっていた同じ形の値が上へ順に持ち上がる方式
新しいタブで開く

07 · 重ね持ち上げ切替 — 詳細値がオンになると後ろに重なっていた同じ形の値が上へ順に持ち上がる方式 — 計測 累積面積 12.562% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STEP_PXSTAGGER_MStoggle
  var STEP_PX = 34; // 計算層: 重なりカード一枚が上へ持ち上がるyオフセット(px) — ばね減衰で算出
  var STAGGER_MS = 80; // 計算層: 後ろのカードほどこの値ぶん遅れて持ち上がる

  ghosts.forEach(function (g, i) {
    g.style.transitionDelay = ((ghosts.length - 1 - i) * STAGGER_MS) + 'ms';
  });

  var open = false;
  function paint() {
    root.classList.toggle('sl--open', open);
    btn.setAttribute('aria-expanded', String(open));
    sub.textContent = open ? '値3つ展開' : '後ろに値3つ重なり';
  }

  function toggle() {
    open = !open;
    paint();
  }

数字が語ること

07番は面積を12.56%埋め、平均強度59.5で23フレーム中8フレームが動いています。3枚のゴーストカードが手前のカードの後ろで順にずれながら持ち上がります。

08アイコン成長ラベル切替 — 焦点値がオンになるとアイコンの大きさの値が大きくなり名前の幅の値が伸びる方式

焦点値がオンになるとアイコンの大きさの値が大きくなり、続いて名前の幅の値が伸び、オフになると両方縮みます。丸いツールボタンや名前が現れるアクションバーに合い、名前は注目が集まった時だけ場所を必要とします。

アイコン成長ラベル切替 — 焦点値がオンになるとアイコンの大きさの値が大きくなり名前の幅の値が伸びる方式
新しいタブで開く

08 · アイコン成長ラベル切替 — 焦点値がオンになるとアイコンの大きさの値が大きくなり名前の幅の値が伸びる方式 — 計測 累積面積 1.013% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ICON_ONICON_OFFmeasure
  var ICON_ON = 1.25, ICON_OFF = 1; // 計算層: 表示スケール値 — 焦点値に応じて掛け合わされる

  // 計算層: ラベル幅を getBoundingClientRect で実測し --label-w に渡す。
  // max-width の遷移は実測幅と対にしないと文字が途中で切れる。
  function measure() {
    var prev = label.style.maxWidth;
    label.style.maxWidth = 'none';
    var w = Math.ceil(label.getBoundingClientRect().width);
    label.style.maxWidth = prev || '0px';
    tool.style.setProperty('--label-w', w + 'px');
  }

  var on = false;
  function paint() {
    measure();
    root.classList.toggle('ig--on', on);
    tool.setAttribute('aria-pressed', String(on));
  }

数字が語ること

08番は面積をわずか1.01%しか埋めず、平均強度68.6で23フレーム中8フレームが動いています。アイコンの拡大とラベルの幅だけが変わり、ツールバー自体は固定されたままです。

09弾性戻り切替 — 折りたたみ値がオンになると形の値が縮み、反対に少し伸びてから元の形に戻る方式

折りたたみ値がオンになると形の値が縮み、反対方向に少し伸びてから元の形に戻り、畳みに弾力が感じられます。フィルター折りたたみボタンやコメント欄を閉じる操作は、この戻りが押して離す動作への直接の反応として読めます。

弾性戻り切替 — 折りたたみ値がオンになると形の値が縮み、反対に少し伸びてから元の形に戻る方式
新しいタブで開く

09 · 弾性戻り切替 — 折りたたみ値がオンになると形の値が縮み、反対に少し伸びてから元の形に戻る方式 — 計測 累積面積 11.242% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

KDtick
  var K = 190, D = 14; // 計算層: ばね定数・減衰 — 押して離す瞬間の跳ね返り値を決める

  var collapsed = false;
  var cur = 1, vel = 0, target = 1;
  var raf = null;

  // ばね減衰計算: 毎フレーム a = -K*(cur-target) - D*vel — 縮んでから反対に少し伸び、元の形に収まる
  function tick() {
    var a = -K * (cur - target) * 0.001 - D * vel * 0.01;
    vel += a;
    cur += vel;
    if (Math.abs(cur - target) < 0.004 && Math.abs(vel) < 0.005) { cur = target; vel = 0; }
    btn.style.transform = 'scaleX(' + cur.toFixed(3) + ')';
    var p = Math.max(0, Math.min(1, (1 - cur) / 0.28));
    root.style.setProperty('--press', p.toFixed(3));
    raf = requestAnimationFrame(tick);
  }

  function toggle() {
    collapsed = !collapsed;
    target = collapsed ? 0.72 : 1;
    btn.setAttribute('aria-pressed', String(collapsed));
    root.classList.toggle('eb--collapsed', collapsed);
  }

数字が語ること

09番は面積を11.24%埋め、平均強度79.3で23フレーム中12フレームが動いています。押して離すたびにばねが0.72の目標を一度越えてから戻ります。

値がパターンを変える基準 — 角丸・回転角・幅の基準

アコーディオン表示切替の STAGGER_MS、70から140へ — 01番の各項目が現れる間隔を二倍にし、パネルの中身が一つの塊としてではなく一つずつ順番に埋まっていくのがはっきり見えるようになります。

幅の慣性切替の K、170から90へ — 04番のばね定数を弱め、目標幅を越える行き過ぎが大きくなり収まるまでの時間も伸びます。同じ展開の重みが軽快さより弾力寄りに感じられるようになります。

弾性戻り切替の D、14から22へ — 09番の減衰比を上げ、跳ね返りがより少ない周期で収まり、たたむ動きが緩やかではなく短く硬く感じられるようになります。

この記事のほかに、【コピペOK】CSS ボタン アニメーション 9選(SCSS + React) はホバー軸を扱っています——ボタン自身の中身や形は変えず、色や大きさだけがトゥイーンする9つです。CSS アコーディオン アニメーション 9選 — JS最小・CSSだけ は内容軸を扱っています——高さが決まっていない大きなパネル全体が開閉し、ここでの01番はその開閉をボタン一つの範囲に絞った例にあたります。フローティング ボタン アイコン 9選 — 押す・広がる・動かす はアイコンだけのボタンを押す・引く・広がる状態でまとめており、02番のプラスから閉じるへの回転はアイコン自身の外にはみ出さない点でその集合に最も近い位置にあります。この3本を並べて見比べることが、CSSを一行書く前にどの軸の話をしているのか見極める一番早い方法です。

どこで壊れるか — 文字幅が変わる瞬間・狭い画面・モーション削減

文字幅が変わる瞬間

04番と08番はどちらも、読み込み時に一度だけラベルを width: auto にして getBoundingClientRect() で自然な幅を測ります。もしその実測のあとにWebフォントが差し替わって再レイアウトが起きると、保存された幅は今必要な幅より短すぎたり長すぎたりし、遷移が広いラベルを切り詰めたり、次に測り直すまで狭いラベルの周りに余白が残ったりします。01番にも似た、より穏やかな弱点があります——measure() はトグルのたびに一度だけリスト項目の高さを合計するため、パネルが開いている最中にフォントサイズが変わっても、パネルを一度閉じて開き直すまでその変化は反映されません。html プラス ボタン 展開のような実測ベースの実装ほど、このタイミング依存を意識して作る価値があります。

狭い画面

9つとも @media (max-width: 340px) をSCSSに持ち、狭い画面でアニメーション経路を切り替えるものはありません。04番はコンテナにも max-width: 340px の上限を持たせ、インラインの成長が行を折り返させないようにしており、残りは単にpaddingとフォントサイズを詰めて同じ遷移をその下で動かし続けます。この9つのソース全部は上のzipに入っていて、pv87z5gpを入力すれば開きます。

モーション削減

9つとも自動再生を始める前にJavaScriptで matchMedia('(prefers-reduced-motion: reduce)') を確認し、9つとも CSS の @media (prefers-reduced-motion: reduce) ブロックを備えていますが、それぞれが落ち着く先は異なります。01・02・04・05・07・08・09番はアニメーションせずに開閉済みの状態へ直接移り、03番は setInterval のループを始めず最初に描かれたフレームのまま止まり、06番はループの代わりに open = true; paint() で開いた状態へ直接移ります。09番だけは特筆に値します——止まるのは自動再生のタイマーだけなので、モーション削減を有効にした訪問者が実際にボタンを押せば同じ requestAnimationFrame のばねがそのまま動き、外れるのはCSSの遷移だけでポインター駆動の物理演算は外れません。

3つのバリエーションとアクセシビリティ

バリエーション 変わるところ 変わらないところ
アコーディオン表示切替、広い間隔(STAGGER_MS 70から140) 01番の項目ごとの表示間隔だけ measure()、パネルのマークアップ、影の値
幅の慣性切替、柔らかいばね(K 170から90) 04番の行き過ぎと収まる時間だけ REST_W、D、実測される openW の目標値
弾性戻り切替、速い減衰(D 14から22) 09番の跳ね返りの収まり方だけ K、0.72の折りたたみ目標値、aria-pressed

9つを通して、伝えられる状態はネイティブな意味づけに近いところにとどまります。01・04・05番(シェル経由)・07番は aria-expanded を持ち、02・08・09番は aria-pressed を持ちます。03番だけが独自のコントロールで、role="button" の div に tabindex="0" と aria-label を添え、keydown ハンドラーがEnterとSpaceに応えることでネイティブなボタンと同じ振る舞いをただで補っています。01・02・05・07・09番の装飾的なSVGアイコンは aria-hidden で、06番の入力欄は独自の aria-label を持ち閉じるボタンにもラベルが付いています——9つのどれも disabled やカスタムの tabindex には頼っておらず、キーボードのフォーカス順はどの形の状態が今表示されているかに左右されません。

FAQ

なぜ人は自分で書かずに「開閉 ボタン デザイン」を検索するのですか

面白いのはCSSそのものではなく、どの一つの状態値をトグルし、その値がどこまで動くかをどの定数が決めているかだからです。解決済みの9つの例を並べて見るほうが、白紙のファイルからばねの数式や実測の幅のパターンを導き出すより速く済みます。

この9つのうちどれかが本物の検索やフォームAPIを呼びますか

いいえ——検索欄の形をしているのは06番だけで、そのヒント一覧も固定の SEEDS 配列から読んでいるだけでネットワーク通信は一切行いません。他の項目の「値」もローカルな真偽値・番号・ばねの位置にすぎず、入出力は伴いません。

09番の跳ね返りはCSSアニメーションと物理演算ライブラリのどちらで作られていますか

どちらでもありません——tick() が毎フレーム a = -K*(cur-target) - D*vel を二つの定数と二つの累積変数だけで手計算しており、それがばねのすべてです。この9つの中でその行き過ぎを得るためにアニメーションや物理演算のライブラリを読み込んでいるものはありません。

出典: MDN — Using CSS transitions · MDN — Window: requestAnimationFrame() method · MDN — prefers-reduced-motion · MDN — button role

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

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