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

スライドでロック解除 9種 — 引くほど重くなるゴムの軌道から弧を描く電源スライダーまで

スライドでロック解除は、つまみを端まで送ってはじめて操作が実行される確認の仕組みです。スライドでロック解除は指を離す瞬間がすべてで、02番は短い位置でも速さを足して最後まで送り、03番は届かなければばねで戻し、08番は直線ではなく弧の上で角度から進み具合を測ります。

スライドでロック解除はボタンではなく位置を測ることです — スライドでロック解除のUIはつまみの位置を0から1の進み具合に変えるところから始まります

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

つまみ一つが進み具合一つで全体を動かします

九場面のつまみは role="slider" で、位置は sl.p 一つで0から始まり1で終わります。塗り・文字・色・影はこの値から計算し、つまみと塗りの端は translateX() で、文字・カード・リングは opacity と transform で動かします。08だけがSVGの弧を使い、ほかはDOMだけです。

判定は離す瞬間にだけ行います

引いているあいだは完了かどうかを決めません。離す瞬間に release() が verdict(p, vEma) を一度呼び、完了なら終わりへ向かうばねを、そうでなければ最初へ向かうばねをかけます。04だけは例外で、押している最中でもしきい値を越えるとすぐ完了へ移ります。

近い記事とのちがい

ボタンの押し込み物理 の記事は押した値が戻る仕組みを扱い、カードのスワイプUI の記事はカードを引いてはじくドラッグを扱います。この記事は端まで送ってはじめて一つの結果が決まる点がちがい、手の動きを計算で読む点では 仮想ジョイスティック の記事と重なります。

スライドでロック解除 9種 — 引くほど重くなるゴムの軌道・文字をなぞる光沢ラベル・届かなければ戻るばね復帰・越えたら最後まで送るしきい値の自動完了・速さを足すフリック判定・スライドで確認する決済の段階・ヒントでゆれるシェブロン・弧を描く電源スライダー・背景が鮮明になるロック画面ヒーロー

01は端を越えて引く手に抵抗を与え、02〜04は離す瞬間の判定を速さ・ばね・しきい値に分けます。05〜07は文字・色・ヒントでつまみの外側を満たし、08は直線をやめて弧を進み、09は同じ値一つで背景全体を動かします。

九場面の面積・動き・強さの実測

場面 累積面積 動いたフレーム 強さの平均
01 ゴムの軌道 19.1% 14/23 83.1
02 フリック判定 14.5% 14/23 61.2
03 ばね復帰 12.3% 18/23 99.0
04 しきい値の自動完了 18.6% 23/23 70.4
05 光沢ラベル 13.6% 17/23 78.4
06 決済の段階 15.7% 21/23 109.5
07 ヒントのシェブロン 12.7% 18/23 83.3
08 弧のスライダー 19.8% 16/23 69.8
09 ロック画面 40.1% 15/23 92.5

いちばん広く変わるのは風景・時計・カードが一つの値で動く09の40.1%で、いちばん狭いのは03の12.3%です。動いたフレームは、完了後にリング・チェック・軌道が続けて変わる04が23/23でもっとも多く、01と02は14/23です。強さの平均は06が109.5でもっとも高く、02が61.2でもっとも低くなりました。

01端に近づくほど引く力が重くなるゴムの軌道

指を1.3まで引いても、つまみは mapDrag が送る1.078までしか進まず、越えた分だけゴムの帯がのび、1/(1+over·BAND_THIN) の割合で細くなります。0.78(KNEE)までは指にそのままついていき、その先は指数で縮む曲線なので、端に近いほど手に重さがかかります。決済や削除の確認画面で、指が端を越えてもつまみが軌道の外へ飛び出さないようにする基本形です。

端に近づくほど引く力が重くなるゴムの軌道
新しいタブで開く

01 · 端に近づくほど引く力が重くなるゴムの軌道 — 計測 累積面積 19.147% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

KNEESTRETCHmapDrag
    // 指の値 x をつまみの進み具合へ — KNEE まではそのまま、その先は端に近づくほど追いかけ方が弱まる
    function mapDrag(x) {
        if (x <= KNEE) {
            return Math.max(x, 0);
        }
        const span = 1 + STRETCH - KNEE;
        return KNEE + span * (1 - Math.exp(-(x - KNEE) / span));
    }
    function draw() {
        placeHandle(main, sl.p);
        const over = Math.max(0, sl.p - 1);
        const w = over > 0 ? 4 - PAD_CQW + over * travelCqw() : 0;
        band.style.transform = 'scaleX(' + (w / BAND_W).toFixed(3) + ') scaleY(' + (1 / (1 + over * BAND_THIN)).toFixed(3) + ')';
        band.style.opacity = over > 0 ? '1' : '0';
        // 指が実際にある位置 — つまみとの距離がそのまま抵抗になる
        ring.style.transform = 'translateX(' + (xOf(sl.fx) - 3).toFixed(2) + 'cqw)';
        ring.style.opacity = sl.down ? clamp((sl.fx - sl.p - 0.06) / 0.06, 0, 1).toFixed(3) : '0';
        placeHandle(mini, badX);
    }

02短く離しても速さを足して最後まで送るフリック判定

離す瞬間に位置へ速さを足し、landing(p, v) = p + max(v, 0)·FLICK_T で到着予想を出して、0.80を越えたら残りの距離をすべって完了します。デモの速い手は位置0.503・速さ1.35で離して到着予想0.84となり1.18秒で終わり、0.55まで遅く引いた手は位置0.543・速さ0.39で到着予想0.64となって戻ります。軽くはじく動作を認めたいロック解除に合い、点線の残像が到着予想を先に見せます。

短く離しても速さを足して最後まで送るフリック判定
新しいタブで開く

02 · 短く離しても速さを足して最後まで送るフリック判定 — 計測 累積面積 14.492% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLICK_TlandingTHRESH
    // 場面02にゴムはない — 指の値を0と1のあいだに収めるだけ
    function mapDrag(x) { return clamp(x, 0, 1); }
    // 到着予想 — 位置 + 速さ × FLICK_T。後ろへ向かう速さは足さない
    function landing(p, v) { return p + Math.max(v, 0) * FLICK_T; }
    function verdict(p, v) { return landing(p, v) >= THRESH; }
    // 演示の指 — 偶数の周は速くなぞって短く離し、奇数の周は同じ位置までゆっくり引いて離す
    function fingerAt(t, c) {
        if (c % 2 === 0) {
            return { down: t >= 0.2 && t < 0.6, x: ramp(t, [[0.2, 0], [0.6, 0.55]]) };
        }
        return { down: t >= 0.2 && t < 1.6, x: ramp(t, [[0.2, 0], [1.6, 0.55]]) };
    }
    function tick(h) {
        void h;
        if (sl.down) {
            land = landing(sl.p, sl.vEma);
        }
    }
    function draw() {
        placeHandle(main, sl.p);
        // 点線の残像 — 押しているあいだはいまの速さで測った到着予想、離したあとはその位置に残って1.2秒かけてうすくなる
        const since = sl.relT < 0 ? 0 : sceneT - sl.relT;
        const keep = sl.down ? 1 : (sl.relT < 0 ? 0 : clamp(1 - since / 1.2, 0, 1));
        const shown = land;
        const hit = shown >= THRESH;
        ghost.style.transform = 'translateX(' + (clamp(shown, 0, 1.05) * travelCqw()).toFixed(3) + 'cqw)';
        ghost.style.opacity = (keep * (shown > 0.05 ? 1 : 0)).toFixed(3);
        ghost.classList.toggle(cls('is-hit'), hit);
        const txt = shown <= 0.05 ? '' : (hit ? '到着予想 {n}% — しきい値を越えて最後まで行く' : '到着予想 {n}% — しきい値に届かず戻る');
        note.textContent = txt.replace('{n}', String(Math.round(shown * 100)));
        note.style.opacity = txt ? '1' : '0';
    }

03届かなければ行き過ぎを残して戻るばね復帰

しきい値0.80に届かず離すと、v += (−SPR_K·p − SPR_C·v)·h の減衰ばねがつまみを最初へ戻し、つまみは出発点を越えてから戻ってきます。デモで0.525に離したつまみは出発点を二度通って最低−0.117まで下がり、離して1.17秒後に最初の位置で止まります。うっかり触れても安全に戻したいロック画面に使い、下の対比版は離した位置に塗りが中途半端に残ります。

届かなければ行き過ぎを残して戻るばね復帰
新しいタブで開く

03 · 届かなければ行き過ぎを残して戻るばね復帰 — 計測 累積面積 12.287% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPR_KSPR_CTHRESH
        else if (sl.mode === M_BACK) {
            sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
            sl.p += sl.v * h;
            if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
                sl.p = 0;
                sl.v = 0;
                sl.mode = M_IDLE;
            }
        }
        else if (sl.mode === M_FIN) {
            sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
            sl.p += sl.v * h;
            if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
                sl.p = 1;
                sl.v = 0;
                sl.mode = M_DONE;
                sl.doneT = 0;
            }
        }
    function draw() {
        placeHandle(main, sl.p);
        placeHandle(mini, badP);
        // ばねが出発点を越えた分(進み具合0未満)を文字で見せる
        const under = Math.max(0, -sl.p);
        const txt = under > 0.004 ? '出発点を {n}% 越えて戻る' : '';
        note.textContent = txt.replace('{n}', String(Math.round(under * 100)));
        note.style.opacity = txt ? '1' : '0';
    }

04しきい値を越えたら残りを自分で進みチェックに変わる自動完了

押している最中でも進み具合が THRESH 0.6に届くと autoCross が M_FIN へ移し、手を離さなくても残りを自分で進みます。デモでは1.03秒に0.60へ届いて自動完了に入り、1.82秒に完了状態になって、矢印がチェックに変わり、周りへ輪が一度広がり、軌道がうすくなります。最後まで握らせたくない確認ステップの完了フィードバックに向きます。

しきい値を越えたら残りを自分で進みチェックに変わる自動完了
新しいタブで開く

04 · しきい値を越えたら残りを自分で進みチェックに変わる自動完了 — 計測 累積面積 18.602% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

THRESHautoCrossRING_S
        if (sl.mode === M_DRAG) {
            const old = sl.p;
            sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
            sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
            if (autoCross && sl.p >= THRESH) {
                sl.down = false;
                sl.relP = sl.p;
                sl.relV = sl.vEma;
                sl.relT = sceneT;
                sl.ok = true;
                sl.v = sl.vEma;
                sl.mode = M_FIN;
            }
        }
    function draw() {
        placeHandle(main, sl.p);
        const t = sl.mode === M_DONE ? sl.doneT : -1;
        const u = t < 0 ? 0 : clamp(t / CHECK_S, 0, 1);
        const arrow = main.ico;
        arrow.style.opacity = (1 - u).toFixed(3);
        check.style.opacity = u.toFixed(3);
        check.style.transform = 'scale(' + (0.6 + 0.4 * easeOut3(u)).toFixed(3) + ')';
        // 周りのリング — つまみの後ろで一度大きくなってうすくなる
        const r = t < 0 ? 0 : clamp(t / RING_S, 0, 1);
        pulse.style.transform = 'translateX(' + (travelCqw()).toFixed(3) + 'cqw) scale(' + (1 + 1.3 * easeOut3(r)).toFixed(3) + ')';
        pulse.style.opacity = t < 0 ? '0' : (0.7 * (1 - r)).toFixed(3);
        // 完了後は軌道全体がうすくなる
        const f = t < 0 ? 0 : easeInOut((t - FADE_AT_S) / 0.6);
        main.bg.style.opacity = (1 - 0.45 * f).toFixed(3);
        const txt = t >= 0 ? '解除済み' : 'スライドでロック解除';
        if (main.label.textContent !== txt) {
            main.label.textContent = txt;
        }
    }

05つまみが通った文字をうすく消し、光沢がなぞる案内ラベル

待機中は光沢の帯が SHIM_PERIOD 1.6秒ごとに文字を左から右へなぞり、つまみが通った文字は (つまみの右端 − 文字の中心)/FADE_W の分だけ透明になります。文字9個の中心位置は measure() が getBoundingClientRect で一度測り、幅が変わったときだけ測りなおし、手を触れると idle = 1 − p/0.12 で光沢が消えます。ボタンの案内文を目立たせつつ、通りすぎた言葉は消したいときに使います。

つまみが通った文字をうすく消し、光沢がなぞる案内ラベル
新しいタブで開く

05 · つまみが通った文字をうすく消し、光沢がなぞる案内ラベル — 計測 累積面積 13.636% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SHIM_PERIODSHIM_WFADE_W
    function measure() {
        const w = rootW();
        if (w === measuredW) {
            return;
        }
        measuredW = w;
        const tr = main.track.getBoundingClientRect();
        const k = 100 / w;
        charX = [];
        for (let i = 0; i < chars.length; i += 1) {
            const r = chars[i].getBoundingClientRect();
            charX.push((r.left + r.width / 2 - tr.left) * k);
        }
        const first = chars[0].getBoundingClientRect();
        const last = chars[chars.length - 1].getBoundingClientRect();
        lineL = (first.left - tr.left) * k;
        lineW = Math.max(1, (last.right - first.left) * k);
    }
    function draw() {
        measure();
        placeHandle(main, sl.p);
        const xr = PAD_CQW + sl.p * travelCqw() + HANDLE_CQW;
        // 光沢の帯の中心(文字列の長さに対する割合) — 一度なぞったら休まず続く。手を触れると徐々に消える
        const idle = 1 - clamp(sl.p / 0.12, 0, 1);
        const s = ((sceneT / SHIM_PERIOD) % 1) * (1 + 2 * SHIM_W) - SHIM_W;
        for (let i = 0; i < chars.length; i += 1) {
            const f = (charX[i] - lineL) / lineW;
            const gain = Math.max(0, 1 - Math.abs(f - s) / SHIM_W) * idle;
            const gone = clamp((xr - charX[i]) / FADE_W, 0, 1);
            chars[i].style.opacity = ((BASE_ALPHA + (1 - BASE_ALPHA) * gain) * (1 - gone)).toFixed(3);
            chars[i].style.transform = 'scale(' + (1 + 0.1 * gain).toFixed(3) + ')';
        }
        const sx = lineL + s * lineW - 10;
        sheen.style.transform = 'translateX(' + sx.toFixed(2) + 'cqw) skewX(-20deg)';
        sheen.style.opacity = (0.6 * idle).toFixed(3);
    }

06スライドするほど色と金額の文言が段階で変わる決済確認スライダー

進み具合が STAGE_1 0.33と STAGE_2 0.7を過ぎると、塗りの色がラベンダーから空色、ミントに変わり、金額の文言が「スライドで確認」「もう少し」「離すと決済」に変わり、色は境目の BLEND_W 0.12の幅だけで混ざります。完了後はロードのリングが SPIN_S 0.9秒まわるあいだ「決済中」と出て、そのあとチェックで閉じて「決済完了」になります。送金・注文のようにうっかり決済を防ぎたい確認ステップに使います。

スライドするほど色と金額の文言が段階で変わる決済確認スライダー
新しいタブで開く

06 · スライドするほど色と金額の文言が段階で変わる決済確認スライダー — 計測 累積面積 15.686% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STAGE_1STAGE_2stageColor
    // 進み具合 → 塗りの色 — 三段階の色を境目の BLEND_W の幅でだけ混ぜる
    function stageColor(p) {
        const a = easeInOut((p - STAGE_1 + BLEND_W / 2) / BLEND_W);
        const b = easeInOut((p - STAGE_2 + BLEND_W / 2) / BLEND_W);
        const c = [0, 0, 0];
        for (let i = 0; i < 3; i += 1) {
            const m1 = lerp(COLORS[0][i], COLORS[1][i], a);
            c[i] = Math.round(lerp(m1, COLORS[2][i], b));
        }
        return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
    }
    function stageText(p) {
        if (p >= STAGE_2) {
            return '{a} 離すと決済'.replace('{a}', AMOUNT);
        }
        if (p >= STAGE_1) {
            return '{a} もう少し'.replace('{a}', AMOUNT);
        }
        return '{a} スライドで確認'.replace('{a}', AMOUNT);
    }
    function draw() {
        placeHandle(main, sl.p);
        placeHandle(mini, badP);
        const p = clamp(sl.p, 0, 1);
        const t = sl.mode === M_DONE ? sl.doneT : -1;
        main.fill.style.background = stageColor(p);
        // 完了の直後はロードのリングが回り、回りきったらチェックで閉じる
        const spin = t >= 0 && t < SPIN_S;
        const ok = t >= SPIN_S;
        spinner.style.opacity = spin ? '1' : '0';
        spinner.style.transform = 'rotate(' + ((t / SPIN_TURN_S) * 360).toFixed(1) + 'deg)';
        main.ico.style.opacity = t >= 0 ? '0' : '1';
        check.style.opacity = ok ? '1' : '0';
        check.style.transform = 'scale(' + (ok ? 0.6 + 0.4 * easeOut3((t - SPIN_S) / 0.25) : 0.6).toFixed(3) + ')';
        const txt = ok ? '決済完了' : (spin ? '決済中' : stageText(p));
        if (main.label.textContent !== txt) {
            main.label.textContent = txt;
        }
    }

07放っておくとシェブロンが波でゆれて向きを知らせるヒント

触れていないあいだ、シェブロン三つが HINT_PHASE 0.16の位相差で順に明るくなり、つまみは HINT_AMP·sin² で0.14だけ前へ出て戻る動きを HINT_P 1.2秒ごとにくり返します。押すと mode が変わってヒントはすぐ消え、離したあとも HINT_RESUME_S 0.6秒たつまで再開しません。初めて使う人に押す向きを言葉なしで伝えたいオンボーディングに使います。

放っておくとシェブロンが波でゆれて向きを知らせるヒント
新しいタブで開く

07 · 放っておくとシェブロンが波でゆれて向きを知らせるヒント — 計測 累積面積 12.687% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HINT_PHINT_PHASEHINT_AMP
    // ヒントが点いた状態 — つまみが最初の位置にあり、押してもいなければ離した直後でもないとき
    function hintOn() {
        return sl.mode === M_IDLE && sl.p < 0.001 && (sl.relT < 0 || sceneT - sl.relT > HINT_RESUME_S);
    }
    function draw() {
        const on = hintOn();
        const ph = (sceneT / HINT_P) % 1;
        // つまみはサイン曲線(sin の二乗)で少し前へ出て戻る
        const hint = on ? HINT_AMP * Math.pow(Math.sin(Math.PI * ph), 2) : 0;
        placeHandle(main, sl.p + hint);
        for (let i = 0; i < chevs.length; i += 1) {
            const w = Math.sin(2 * Math.PI * (sceneT / HINT_P - i * HINT_PHASE));
            const a = on ? 0.22 + 0.78 * Math.pow(Math.max(0, w), 2) : 0.22;
            chevs[i].style.opacity = a.toFixed(3);
        }
    }

08直線ではなく弧の上を角度で測ってまわる電源スライダー

つまみは arcX・arcY で半円の弧に沿って動き、進み具合は angleP が atan2 で測った中心角から 1 − 角度/π で求めます。しきい値は THRESH_DEG 144°で、越えて離すと弧全体が FLASH_TAU 0.18秒の減衰で一度光り、電源アイコンがうすくなって文言が「電源オフ」に変わります。電源を切る・緊急停止のような危険な操作の画面に使います。

直線ではなく弧の上を角度で測ってまわる電源スライダー
新しいタブで開く

08 · 直線ではなく弧の上を角度で測ってまわる電源スライダー — 計測 累積面積 19.829% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

THRESH_DEGanglePFLASH_TAU
    // 弧の上の一点 — 進み具合 p は左端(180度)から右端(0度)まで、中心からの角度 θ = π(1 − p)
    function arcX(p) { return CX + R * Math.cos(Math.PI * (1 - p)); }
    function arcY(p) { return CY - R * Math.sin(Math.PI * (1 - p)); }
    // ポインタ位置 → 進み具合 — 中心から見た角度を測って極座標に変える。弧の下側は近い端へ寄せる
    function angleP(e) {
        const r = scene.getBoundingClientRect();
        const ctrX = r.left + r.width * CX / 100;
        const ctrY = r.top + r.height * CY / 62.5;
        let a = Math.atan2(ctrY - e.clientY, e.clientX - ctrX);
        if (a < -Math.PI / 2) {
            a = Math.PI;
        }
        else if (a < 0) {
            a = 0;
        }
        return 1 - a / Math.PI;
    }
    function draw() {
        const p = sl.p;
        handle.style.transform = 'translate(' + (arcX(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw,' + (arcY(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw)';
        // つまみの中の矢印は弧の接線の向きを見る — 左端では上、頂上では右、右端では下
        arrow.style.transform = 'rotate(' + ((p - 0.5) * 180).toFixed(1) + 'deg)';
        const shown = clamp(p, 0, 1);
        fillArc.style.strokeDasharray = (ARC_LEN * shown).toFixed(2) + ' ' + ARC_LEN.toFixed(2);
        const off = sl.mode === M_DONE;
        const t = off ? sl.doneT : 0;
        flashArc.style.opacity = off ? (0.9 * Math.exp(-t / FLASH_TAU)).toFixed(3) : '0';
        fillArc.classList.toggle(cls('is-off'), off);
        // 中央の電源アイコンは進み具合が半分を越えるとうすくなり、オフになったあとがいちばんうすい
        const dim = easeInOut((shown - 0.5) / 0.5);
        power.style.opacity = (1 - 0.65 * dim).toFixed(3);
        const txt = off ? '電源オフ' : '電源オン';
        if (powerLabel.textContent !== txt) {
            powerLabel.textContent = txt;
        }
    }

09スライドするほど背景が鮮明になり時計が引く、ロック画面ヒーロー

値一つ p から blur(BLUR_MAX·(1−p)) で風景のぼかしが減り、時計は CLOCK_RISE 9cqwだけ上がって CLOCK_MIN 0.5倍まで小さくなり、通知カード二枚は CARD_FROM 0.3から CARD_GAP 0.15の間隔で下から上がってきます。離して戻すと同じ式が逆にほどけ、すべての値が同じ道をたどって戻ります。アプリ・キオスクの最初の画面やサービスランディングのヒーローに使います。

スライドするほど背景が鮮明になり時計が引く、ロック画面ヒーロー
新しいタブで開く

09 · スライドするほど背景が鮮明になり時計が引く、ロック画面ヒーロー — 計測 累積面積 40.138% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BLUR_MAXCLOCK_MINCARD_FROM
    function draw() {
        placeHandle(main, sl.p);
        const p = clamp(sl.p, 0, 1);
        // 風景は進み具合に反比例してぼやけ、時計は上へ上がりながら小さくうすくなる — 値一つ(p)からすべて出る
        img.style.filter = 'blur(' + (BLUR_MAX * (1 - p)).toFixed(3) + 'cqw)';
        clock.style.transform = 'translateY(' + (-CLOCK_RISE * p).toFixed(3) + 'cqw) scale(' + lerp(1, CLOCK_MIN, p).toFixed(3) + ')';
        clock.style.opacity = (1 - 0.3 * p).toFixed(3);
        for (let i = 0; i < cards.length; i += 1) {
            const u = easeOut3((p - CARD_FROM - i * CARD_GAP) / CARD_SPAN);
            cards[i].style.transform = 'translateY(' + (16 * (1 - u)).toFixed(3) + 'cqw)';
            cards[i].style.opacity = u.toFixed(3);
        }
        const txt = sl.mode === M_DONE ? '解除済み' : 'スライドでロック解除';
        if (main.label.textContent !== txt) {
            main.label.textContent = txt;
        }
    }

スライドでロック解除のUIをコードに移すと、つまみはいくつの状態をもてばよいのでしょうか?

五つの状態で足ります

M_IDLE(じっとしている)・M_DRAG(引いている)・M_BACK(ばねで最初へ)・M_FIN(ばねで終わりへ)・M_DONE(完了)の五つがすべてです。引き始めは M_DRAG、離すと verdict の結果で M_BACK か M_FIN へ進み、ばねが止まると M_IDLE か M_DONE になります。

完了のあとに戻る道を一つ残します

人が触れた場面は完了から DONE_HOLD_S 1.8秒たつと最初へ戻り、キーボードは完了後に Enter・Space・Home で戻せます。自動デモは LOOP_S が一周するたびに cycle が一つ上がり、完了する手と戻される手を交互に見せます。ロック解除 スライド の操作は、完了させる道と戻す道が対になっていてはじめて安心して使えます。

どこに付けるか — 決済・電源・最初の画面

送金・注文の確認には06の段階の色を、電源オフや緊急停止には08の弧を、アプリやキオスクの最初の画面には09を付けます。スマホ ロック解除 スライド のような起動画面を自前で作るなら09が土台になり、決済や電源へ移すなら06か08を選びます。しきい値の割合とばねの強さの二つだけを外へ出しておけば、設定画面を別に置いても対応できます。

軌道の幅を測り、つまみの位置を進み具合に変え、離した位置と速さで完了を決め、届かなければ戻すまで — スライドでロック解除が一回処理される順番です

軌道の幅は cqw で測って舞台の幅に比例させます

つまみが動く距離は TRACK_CQW − HANDLE_CQW − 2·PAD_CQW で、72なら57cqw、480pxの舞台で273.6pxです。単位をpxではなくcqwにしたので、舞台が縮むと同じ割合で縮みます。

指にはついていき、速さはならして読みます

引いている最中の位置は p += (mapDrag(x) − p)·(1 − e^(−h·FOLLOW))、速さは vEma += (dp/h − vEma)·(1 − e^(−h·VEL_SMOOTH)) で、FOLLOW は40、VEL_SMOOTH は20です。位置を指に直接貼りつけず一拍遅れてついていかせるので、離す瞬間の速さがフレームごとにぶれません。

一フレームを1/240秒の欠片に刻みます

update は ceil(dt·240) の欠片に分けて stepPhysics を回し、一フレームの時間は DT_MAX 0.05秒までしか受けません。そのためフレームの間隔が変わっても、ばねは同じ曲線を描きます。

    function update(dt) {
        const n = Math.max(1, Math.ceil(dt * 240));
        const h = dt / n;
        for (let i = 0; i < n; i += 1) {
            stepPhysics(h);
            tick(h);
        }
    }
    // 一フレームに渡す時間の上限(秒) — 長く隠れて戻った最初のフレームも50msまでしか計算に入れない
    const DT_MAX = 0.05;
    function advance(dt) {
        sceneT += dt;
        if (demo && sceneT >= LOOP_S) {
            sceneT -= LOOP_S;
            cycle += 1;
            fullReset();
        }
        update(dt);
    }

スライドでロック解除が壊れるのは、端を越えていくつまみ・離したのに途中で止まったまま残る塗り・タッチが軌道の外へ出ると途切れるドラッグです

限界がないとつまみが端を越えて飛び出します

01の下の小さな軌道は badX = clamp(fx, 0, 1.3) で指にそのままついていくので、つまみが端を越えて飛び出します。上の大きな軌道は同じ手に対して1.078で止まるので、一つの画面で二つの方式を並べて比べられます。

戻さないと塗りが途中に残ります

03の対比版は離した位置にそのまま立ちます。badP は押しているあいだだけ clamp(p, 0, 1) についていくので、0.525で離すと半分ほど満ちた塗りがそのまま残ります。

ポインタが軌道の外へ出るとドラッグが途切れます

06の対比版は指が LEAVE_X 0.55を過ぎると lost = true で、その位置に固定して指を追うのをやめます。実際の操作ではつまみに setPointerCapture をかけて touch-action: none を置くので、指が軌道の外へ出てもドラッグは続きます。圧縮ファイルはパスワード smsvvdkv で開き、06のフォルダの index.html でつまみを0.55の先まで引いてみると、下の小さな行がその位置で途切れるのを見られます。

軌道の長さとしきい値の比率とばねの強さを変えると完了の判定はどれだけ変わるのでしょうか — 幅・しきい値・硬さを変えて比べます

三つの値を一つずつだけ変えて、同じデモの手で回した結果です。数値は描画の測定ではなく、60Hzのフレームと1/240秒の欠片で stepPhysics を移して回したシミュレーションの値です。

名前 変えた値 感じ
軌道を長く 02 TRACK_CQW 72 → 84 つまみが動く距離が57cqwから69cqwへ(480pxの舞台で274pxから331pxへ)長くなります。同じ手(138pxの地点から秒速369pxで離す)が、進み具合0.50・速さ1.35から0.42・1.12と読まれ、到着予想が0.84から0.69に下がってしきい値0.80を下回り、最後まで行っていた離し方が戻る離し方に変わります
しきい値を高く 04 THRESH 0.6 → 0.85 0.75まで引いて離す同じ手が、0.6では押したまま1.03秒に自動完了(完了状態は1.82秒)になりますが、0.85では1.62秒に離すまでしきい値に届きません。出発点を5%ほど越えてから、2.47秒に最初の位置で止まります
ばねを硬く 03 SPR_K 110 → 240 減衰比が0.43から0.29に下がり、出発点を越える幅が離した距離の22.5%から38.5%に増えます(0.525で離すと−0.12から−0.20)。出発点に最初に届く時間は0.20秒から0.10秒に早まり、周波数は1.51Hzから2.36Hzに速くなり、SPR_C 9はそのままなので止まるまでの時間はどちらも1.2秒前後です

値を変える前に見ておくこと

三つの変形はデモの手が固定なので、値一つが判定を裏返す境目を見せるだけで、実際の人の手は毎回ちがいます。THRESH は下げるほど軽く完了し、上げるほど最後まで送らなければならなくなるので、決済の確認のように間違いが高くつく画面は高めに、軽いロックは低めに決めます。

動きを減らす設定では解除された一枚です — 進み具合を文で読み上げ、キーボードの矢印でつまみを動かすアクセシビリティです

動きを減らす設定と解除された一枚

prefers-reduced-motion: reduce のとき、メディアクエリ の値を読んだ reduceQuery がループを止め、showRest() がトゥイーンなしで p = 1・M_DONE の解除された一枚を描きます。この状態でも引くことはでき、pointermove が p をすぐ書き換え、離すと settleNow() が速さを0として判定してその場で最終状態を選びます。ページを開いたまま設定を変えても、change リスナーが読みなおします。

スライダーの役割と文で読む進み具合

つまみは slider ロール に aria-valuemin・aria-valuemax(0〜100)と aria-valuenow をもち、aria-valuetext にその瞬間の状態の文が入ります。目で見る状態の行は aria-hidden で、完了は .vj__live に finalText() の一文を一度だけ書きます。デモ中は aria-live="off" で、人が初めて触れると polite に切り替わります。

キーボードと一時停止

右・上の矢印は KEY_STEP 0.1ずつ上げ、左・下の矢印は下げ、Home は最初へ、End・Enter・Space は最後まで送ります。矢印でしきい値を越えてもそのまま完了になり、完了後は Enter・Space・Home が最初へ戻します。一時停止ボタンは aria-pressed で状態を知らせ、タブが隠れると(visibilitychange)ループが止まります。

スライドでロック解除のよくある質問 — ライブラリなしで作る方法・タッチとマウスを一緒に受ける方法・スライドでロック解除ができないときに代わりに使う確認ボタン

ライブラリなしでも作れますか

九場面ともライブラリ・WebGL・CDNを使わず、Math.random・Date・fetch もないバニラのコードです。つまみ一つを pointerdown・pointermove・pointerup で読み、requestAnimationFrame が位置を描けば動きます。

タッチとマウスを一緒に受けるにはどうしますか

ポインターイベント 一つでマウス・タッチ・ペンを同じに受けられます。pointerdown で setPointerCapture をかけ、マウスは e.button !== 0 なら無視し、pointercancel も離したときと同じに扱います。

スライドでロック解除 できないときの代わりに使う確認ボタンはどんな形ですか

スライダーをやめたいなら、<button> 一つで同じ確認を受けられます。この記事のスライダーも Enter・Space が completeNow() でそのまま最後まで送るので、キーボードの人はすでにボタンのように使っていて、間違い防止が目的ならボタンをもう一度押して確かめさせる形が同じ役目を果たします。

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

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