本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
アイコン

鍵のアイコンのつるが上がって開き、また閉じる

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
アイコン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

錠は、胴(角丸の四角)・つる・鍵穴の線で描きます。押して開けると、つるが弾んで持ち上がり、左脚の付け根を支点に少し傾いて止まり、鍵穴が 90° 回ります。ボタンは明暗が反転し(明るい塗りに暗い錠)、札と説明の文が解錠のものに替わります。もう一度押すと、つるが戻って勢いよく落ち、錠全体がこつんと沈んで閉まります。閉まっている間は、ボタンを成功の色で縁取ります。

開けるときは、つるを 0.4 秒の back.out(3) で 3.5(24 のグリッドで)持ち上げ、0.22 秒遅れて −16° まで elastic.out で傾けます。閉めるときは、傾きを 0.18 秒で戻してから power4.in で最後に速く落とし、錠全体を 0.9 だけ沈めて弾ませます。開けるのは弾む動き、閉めるのは加速して止まる動きと手触りを変えるので、逆再生ではなく 2 本のタイムラインを使い分けます。

つるの左脚は胴の中まで伸ばして描き、胴の上端より上だけを見せる clipPath で切ります。持ち上げても傾けても、左脚が胴から抜けません。傾ける軸は、左脚の下端(持ち上げると胴の上端に来る点)です。この軸は svgOrigin で、動かす前に 1 度だけ置きます。持ち上げている途中で渡すと、GSAP が跳ねないように位置を補い、その量が渡した時点の高さで変わるので、傾きの軸がずれ、閉じたあとにも少しずれが残ります。

色と文の切り替えは、カードの data-state を書き換えて CSS の transition に任せます。開けるときは 0.05 秒、閉めるときはつるが落ちきる頃の 0.25 秒だけ遅らせて色を替えます。ボタンの名前は次にする操作(「玄関の鍵を開ける」「玄関の鍵を閉める」)に替え、切り替わったことは role「status」の見えない要素で知らせます。実機の応答を待ってから動かし、動いている間の押し直しは受けません。

解錠して 30 秒たつと、自動で閉めます。動きを減らす設定では、ほぼ一瞬で切り替えます。持ち上げる量・傾き・弾みの強さ・落ちる速さで手触りが変わります。このデモは、48px のボタンを 4 倍に拡大して見せています。スマートホームの施錠のほか、非公開の切り替え、ロック中のコンテンツの解除、セキュリティの設定に向いています。

コード

HTML
<!-- 玄関の鍵のカード。data-state(locked / unlocked)で、ボタンの色と札・説明の文を切り替える -->
<section class="door" data-state="locked" aria-labelledby="door-name">
  <div class="door__head">
    <span class="door__badge" aria-hidden="true">
      <svg viewBox="0 0 24 24" focusable="false"><path d="M6 21V4.5A1.5 1.5 0 0 1 7.5 3h9A1.5 1.5 0 0 1 18 4.5V21M3.5 21h17M14.5 12.5v.01" /></svg>
    </span>
    <h2 class="door__name" id="door-name">玄関</h2>
    <!-- 今の状態の文だけが見え、読まれる -->
    <p class="door__chip"><span data-when="locked">施錠中</span><span data-when="unlocked">解錠中</span></p>
  </div>
  <div class="door__stage">
    <span class="lock__pulse" aria-hidden="true"></span>
    <!-- 名前は、次に押したときの操作。data-open / data-close から入れ替える -->
    <button class="lock" type="button" aria-label="玄関の鍵を開ける" data-open="玄関の鍵を開ける" data-close="玄関の鍵を閉める">
      <svg class="lock__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <defs>
          <!-- 胴の上端より上だけを見せる。つるの左脚は胴の中まで伸ばしてあるので、持ち上げても抜けない(id は部品ごとに付け直す) -->
          <clipPath id="lock-top"><rect x="-8" y="-12" width="40" height="22.5" /></clipPath>
        </defs>
        <g class="lock__all">
          <g clip-path="url(#lock-top)"><path class="lock__shackle" d="M8 14V7.5a4 4 0 0 1 8 0v3" /></g>
          <path d="M7.5 10.5h9a2.5 2.5 0 0 1 2.5 2.5v5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 5 18v-5a2.5 2.5 0 0 1 2.5-2.5z" />
          <g class="lock__keyhole"><circle cx="12" cy="14.2" r="1.25" /><path d="M12 14.8v2.4" /></g>
        </g>
      </svg>
    </button>
  </div>
  <p class="door__note"><span data-when="locked">20:02に真由さんが施錠しました</span><span data-when="unlocked">30秒後に自動で施錠します</span></p>
  <!-- 読み上げへの知らせ。文は data-* から入れる -->
  <p class="door__status" role="status" data-locked="玄関の鍵を閉めました" data-unlocked="玄関の鍵を開けました"></p>
</section>

CSS
.door {
  --bg: #111111;         /* 開いている間の錠(明るい塗りの上) */
  --surface: #262626;    /* カードの面 */
  --ink: #eeece6;        /* 文字・線・開いている間のボタンの塗り */
  --mute: #a8a6a0;       /* 補足の文字 */
  --line: rgba(238, 236, 230, 0.14); /* 枠の線 */
  --safe: #4f8a64;       /* 閉まっている間の縁と、閉めたときの輪 */
  --safe-fill: #303e35;  /* 閉まっている間のボタンの地(面に成功の色を混ぜた不透明な色) */
  --safe-text: #6ba37f;  /* 「施錠中」の文字(小さな文字でも読める濃さ) */
  --size: 48px;          /* 鍵のボタンの大きさ */
  --ease: cubic-bezier(0.45, 0, 0.55, 1);
  display: flex;
  flex-direction: column;
  max-width: 480px;
  box-sizing: border-box;
  padding: 32px;
  border-radius: 4px;
  background: var(--surface);
  color: var(--ink);
}

.door__head { display: flex; align-items: center; gap: 16px; }
.door__badge { display: grid; place-items: center; flex: none; width: 48px; height: 48px; box-sizing: border-box; border: 1px solid var(--line); border-radius: 4px; }
.door__badge svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.door__name { margin: 0; font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }

/* 状態の札(罫線の枠の丸帯)。2 つの文を同じ升に重ね、今の状態の文だけを見せる */
.door__chip {
  display: grid;
  margin: 0 0 0 auto;
  height: 28px;
  padding: 0 12px;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--line);
  font-size: 12px;
  font-weight: 700;
  line-height: 28px;
  letter-spacing: 0.06em;
  white-space: nowrap;
}

.door__chip [data-when="locked"] { color: var(--safe-text); }
.door__note { display: grid; justify-items: center; margin: 0; color: var(--mute); font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; text-align: center; }

.door [data-when] { grid-area: 1 / 1; transition: opacity 0.2s linear, visibility 0.2s; }
.door[data-state="locked"] [data-when="unlocked"],
.door[data-state="unlocked"] [data-when="locked"] { opacity: 0; visibility: hidden; }
/* 開けるときは前の文を先に消し、閉めるときは、つるが落ちきる頃に次の文を出す */
.door[data-state="unlocked"] [data-when="locked"] { transition-delay: 0.05s; }
.door[data-state="unlocked"] [data-when="unlocked"] { transition-delay: 0.15s; }
.door[data-state="locked"] [data-when="locked"] { transition-delay: 0.3s; }
.door[data-state="locked"] [data-when="unlocked"] { transition-delay: 0.4s; }

/* ボタンと輪を同じ升に重ねる。上下の余白は輪の広がるぶん */
.door__stage { display: grid; place-items: center; padding: 32px 0; }
.door__stage > * { grid-area: 1 / 1; }

/* 閉まっている(安全な)間は、成功の色の縁と地。開いている間は明暗を反転する(明るい塗りに暗い錠) */
.lock {
  display: grid;
  place-items: center;
  width: var(--size);
  height: var(--size);
  padding: 0;
  border: 0;
  border-radius: 50%;
  background-color: var(--safe-fill);
  box-shadow: inset 0 0 0 1px var(--safe);
  color: var(--ink);
  cursor: pointer;
  transition: background-color 0.32s var(--ease) 0.25s, box-shadow 0.32s var(--ease) 0.25s, color 0.32s var(--ease) 0.25s;
}

.door[data-state="unlocked"] .lock { background-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); color: var(--bg); transition-delay: 0.05s; }
.lock:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 24 グリッドで線幅 2。鍵穴の丸だけ塗る */
.lock__icon { width: calc(var(--size) / 2); height: calc(var(--size) / 2); overflow: visible; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.lock__keyhole circle { fill: currentColor; stroke: none; }

/* 押したときに広がって消える輪。色は、閉めたときは成功の色、開けたときは文字の色 */
.lock__pulse { width: var(--size); height: var(--size); border-radius: 50%; box-shadow: 0 0 0 var(--spread, 0px) currentColor; color: var(--safe); opacity: 0; pointer-events: none; }
.door[data-state="unlocked"] .lock__pulse { color: var(--ink); }

/* 目には見せず、読み上げにだけ届ける */
.door__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

@media (prefers-reduced-motion: reduce) {
  .door[data-state] [data-when], .door[data-state] .lock { transition: none; }
}

JavaScript
import { gsap } from "gsap";

const LIFT = 3.5; // つるが持ち上がる量(24 のグリッドで)
const SWING = -16; // 開いたつるの傾き(度)
const AUTO_LOCK = 30; // 開けてから自動で閉めるまでの秒数(0 なら閉めない)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える

// 実機へ開け閉めを送る処理に置き換える。応答が来てから動かし、失敗したら reject する
const sendLock = (locked) => Promise.resolve(locked);

document.querySelectorAll(".door").forEach((door, i) => {
  const button = door.querySelector(".lock");
  const [shackle, keyhole, all, pulse, status] = [".lock__shackle", ".lock__keyhole", ".lock__all", ".lock__pulse", ".door__status"].map((s) => door.querySelector(s));
  // 同じページに何個あっても切り抜きを取り違えないように、clipPath の id を付け直す
  const clip = door.querySelector("clipPath");
  clip.id = `lock-top-${i + 1}`;
  door.querySelector("[clip-path]").setAttribute("clip-path", `url(#${clip.id})`);

  // SVG の中の図形は、回す軸を svgOrigin で置く。つるは左脚の下端(持ち上げると胴の上端に来る点)、鍵穴はその中心。
  // 動かす前に 1 度だけ置く。動いている途中で置くと、GSAP が跳ねないように位置を補い、軸と閉じたあとの位置がずれる
  gsap.set(shackle, { svgOrigin: "8 14" });
  gsap.set(keyhole, { svgOrigin: "12 15.2" });
  // ボタンのまわりに広がって消える輪(色は CSS が状態で決める)。広がる幅はボタンの 16%
  const ring = () => [pulse, { "--spread": "0px", opacity: 0.55 }, { "--spread": `${Math.round(pulse.offsetWidth * 0.16)}px`, opacity: 0, duration: 0.6, ease: "power2.out", immediateRender: false }];

  // 開ける: つるが弾んで持ち上がり、左脚を支点に傾いて止まる。鍵穴が回る
  const unlock = gsap
    .timeline({ paused: true })
    .fromTo(...ring(), 0)
    .to(shackle, { y: -LIFT, duration: 0.4, ease: "back.out(3)" }, 0)
    .to(shackle, { rotation: SWING, duration: 0.7, ease: "elastic.out(1, 0.5)" }, 0.22)
    .to(keyhole, { rotation: -90, duration: 0.32, ease: "back.out(2)" }, 0.02);
  // 閉める: つるが戻って最後に速く落ち、錠がこつんと沈んで戻る。落ちきった瞬間に輪
  const lock = gsap
    .timeline({ paused: true })
    .to(shackle, { rotation: 0, duration: 0.18, ease: "power2.inOut" }, 0)
    .to(shackle, { y: 0, duration: 0.18, ease: "power4.in" }, 0.12)
    .to(keyhole, { rotation: 0, duration: 0.28, ease: "back.out(2)" }, 0.28)
    .fromTo(all, { y: 0 }, { y: 0.9, duration: 0.06, ease: "power2.out", immediateRender: false }, 0.3)
    .to(all, { y: 0, duration: 0.3, ease: "elastic.out(1, 0.4)" }, 0.36)
    .fromTo(...ring(), 0.3);

  let locked = door.dataset.state !== "unlocked";
  let busy = false; // 応答を待つ間と、動いている間は受けない
  let auto = null;
  const show = () => {
    door.dataset.state = locked ? "locked" : "unlocked";
    button.setAttribute("aria-label", locked ? button.dataset.open : button.dataset.close);
  };
  // 最初から開いている(読み込んだ状態が解錠のとき)は、動かさずに開いた形にする
  if (!locked) unlock.progress(1);
  show();

  const toggle = async () => {
    if (busy) return;
    busy = true;
    auto?.kill();
    try {
      await sendLock(!locked);
    } catch {
      busy = false; // 送れなかったら、今の状態のまま
      return;
    }
    locked = !locked;
    show();
    status.textContent = locked ? status.dataset.locked : status.dataset.unlocked;
    // ease の違いで手触りを変えるので、逆再生ではなく 2 本を使い分ける
    await (locked ? lock : unlock).timeScale(SPEED).restart();
    busy = false;
    if (!locked && AUTO_LOCK > 0) auto = gsap.delayedCall(AUTO_LOCK, toggle);
  };
  button.addEventListener("click", toggle);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。