デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
開けるときは弾み、閉めるときは勢いよく落ちる。この錠のアイコンは、開け閉めで動きの性格が違います。開けると、つるが持ち上がって左脚の付け根を支点に少し傾き、鍵穴が90°回ります。ボタンは明るい塗りに暗い錠の配色へ反転し、カードのラベルと説明も解錠中のものに替わります。閉めると、つるが落ちきったところで錠全体がこつんと下がり、元の位置に戻ります。
この違いを作るために、開ける動きと閉める動きは別々のタイムラインにしました。開けるときは back.out(3) で持ち上げ、少し遅れて elastic.out で−16°まで傾けます。閉めるときは傾きを戻してから、power4.in で終わりに向けて速く落とします。1本を逆再生するだけでは、閉める動きが開ける動きの裏返しになってしまいます。
つるの左脚は胴の中まで伸ばして描き、clipPath で胴の上端より上だけを見せています。持ち上げても傾けても、脚の下端が胴から抜けて見えることはありません。傾ける軸はこの左脚の下端で、svgOrigin を使って動かす前に一度だけ設定します。持ち上げている途中で軸を渡すと、GSAP が見た目が跳ねないよう位置を補正し、その量が渡した時点の高さで変わります。結果として軸がずれ、閉じたあとにも少しずれが残ります。
色と文の切り替えはカードの data-state を書き換えて CSS の transition に任せ、閉めるときは、つるが落ちきるころまで色を替えるのを遅らせています。ボタンの名前は「玄関の鍵を開ける」「玄関の鍵を閉める」と次にする操作に替え、状態が変わったことは role="status" の見えない要素で知らせます。
実際の鍵とつなぐ場合は、機器の応答を待ってから動かし、動いているあいだの押し直しは受け付けません。解錠から30秒たつと、自動で施錠します。
コード
<!-- 玄関の鍵のカード。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>
.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; }
}
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);
});