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

指紋認証 アニメーション 9選 — スキャン進捗値で変わる生体認証ボタン

9つの指紋認証 アニメーションのうち、1つはスキャン進捗の値が指紋の稜線を順に光らせ、1つは失敗時にスプリング減衰の値で揺れて止まり、1つは押す圧力の値が増すほどリップルの半径と振幅の値が共に大きくなります。

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

この9つのうちどれも、実際の指紋センサーAPIや顔認証APIを呼び出してはいません。すべてはひとつの数値が動かすシミュレーションで、進捗の割合、経過age、揺れのオフセット、モーフィングの補間値、押す圧力、ノイズのサンプル値、スプリングの位置、対角線の遅延、呼吸の位相のどれかひとつを見ればその場面の仕組みがわかります。この指紋認証 ボタンの9パターンを選ぶとき、見た目の印象だけで選ぶより、どの数値が認証状態に反応しているかを先に知っておくとコードを読む時間が短くなります。

9つのパターンの解剖 — どの値が認証状態に反応するか

01のapply()はスキャンラインのscanY位置を稜線ごとの活性化比率(scanY - apex) / GLOW_BANDに変換し、各稜線は自分の頂点(apex)をラインが通過したあとだけ不透明度が上がります。02のtick()は開いているリングスロットのage(経過時間)を毎フレーム進め、半径を1 - Math.exp(-age / TAU)という指数関数でPULSE_MAX_Rへ近づけます — 直線的な増加ではなく、最初は速く後半は緩やかになる近似です。03のapply()は減衰する指数関数とコサインを掛け合わせたAMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ)そのものが揺れのオフセットで、CSSキーフレームの代わりにこの式を毎フレーム計算しています。04のbuildPath()は10個の点をcirclePoint()の座標と手作業で配置したCHECK_PTS配列のあいだで単一の進捗値によって補間し、SVGのd文字列を毎フレーム作り直します。05のbuildPath()はBASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED)という式でサイン波のリングをサンプリングし、実際に指で押しているあいだはpressureがPointerEvent.pressureからそのまま入ります。06のnoise()はノイズ場をサンプリングするのではなく、周波数の異なる3つのサイン波を重み付けして合計しているだけの決定的な計算です。07のautoLoop()は毎フレームスプリングを積分し、force = (target - autoPos) * STIFFNESS - autoVel * DAMPINGという式でtargetをFLIP_MSごとに2つの値のあいだで切り替えるため、リングは完全には静止しません。08のrender()は各グリッドセルの行と列の合計から遅延delay = cells[i].diag * DIAG_STEPを計算し、それをMath.sinのパルス窓に渡します。09のrender()はscale = 1 + AMPLITUDE * Math.sin(t * OMEGA)という単一の値でアイコン全体を拡大縮小しながら、稜線ごとにRIDGE_PHASE_STEPだけ位相をずらして明るさの波が外側へ広がって見えるようにします。9つのうち8つ — 05を除く全て — はclickとkeydownで実際の訪問者を検知し、05だけはkeydownを残しつつclickの代わりにpointerdown・pointermove・pointerupを使います。連続的な圧力の値を読む必要があるからです。どの場面でも最初の実際の操作でhuman()が呼ばれ、.is-demoクラスが外れてオートプレイのループが止まります(01・03・04・05・07はisDemo && !reduce、02・06はリスナーの引数に組み込んだ同等の判定、08・09はsetActive()が同じ役目を果たします)。オートプレイのトリガーも各場面ごとの毎フレーム計算も、すべてrequestAnimationFrameで駆動されており、Math.randomは9つのファイルのどこでも実行されません。9つともmatchMediaで一度だけ判定する独自のprefers-reduced-motion: reduce分岐をJS側に持ち、Sass側にもtransition: noneのブロックを備えていますが、その分岐が実際に何をするか——タイマーを飛ばすのか、時間を圧縮するのか、静止した1フレームを描くのか——は項目によって異なり、以下の各項目で確認できます。

パターン別コード

01スキャンラインスイープ — スキャン進捗の値が上から下へ移動し、通過した位置の指紋稜線の明るさの値が順に上がる方式

スキャン進捗の値はBASE_MS / SWEEP_SPEEDミリ秒かけてアイコンの上から下まで移動し、その値が各稜線自身の頂点(apex)を通過した瞬間から、その稜線の明るさがGLOW_BANDの距離のなかで満opacityへ向かって上がります。稜線ごとに自分の頂点だけを見て判定するため、上にある稜線ほど早く明るくなり終わり、下の稜線はまだ動いていない——これが「順番に光る」ように見える理由です。ログイン画面の生体認証待機ボタンやアプリのロック解除画面に合う方式です。

スキャンラインスイープ — スキャン進捗の値が上から下へ移動し、通過した位置の指紋稜線の明るさの値が順に上がる方式
新しいタブで開く

01 · スキャンラインスイープ — スキャン進捗の値が上から下へ移動し、通過した位置の指紋稜線の明るさの値が順に上がる方式 — 計測 累積面積 12.571% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BASE_MS$subject-blue-lightGLOW_BAND
  function apply(scanY) {
    beam.setAttribute('y1', scanY.toFixed(2));
    beam.setAttribute('y2', scanY.toFixed(2));
    for (var i = 0; i < ridges.length; i++) {
      var apex = parseFloat(ridges[i].getAttribute('data-apex'));
      var act = Math.max(0, Math.min(1, (scanY - apex) / GLOW_BAND + 0.001));
      ridges[i].style.opacity = (0.26 + 0.74 * act).toFixed(3);
    }
  }

数値が語ること

01は累積面積12.57%・強度平均63.8のまま、23フレーム中12フレームでしか動きが記録されませんでした。9項目の中で累積面積は最大ですが、動いたフレーム数は最少です。スイープは自分のトリガーサイクルのあいだしか走らず、GAP_MSの待機時間はほぼ完全に静止しているためで、動くフレームでは一本のビームと最大7本の稜線が同時に塗り替わります。

02待機パルス — 認証待機状態で円形パルスの半径の値が減衰関数で徐々に大きくなりながら薄くなる方式

LAUNCH_GAPミリ秒ごとに新しいリングスロットが開き、そのスロットの半径の値は直線ではなく指数減衰の曲線に沿ってPULSE_MAX_Rへ近づいていきます——最初は速く、最大値に近づくほど緩やかになります。半径の値が大きくなるのと同時に、同じスロットの不透明度の値は逆方向へ下がっていくため、リングは生まれた瞬間が最も明るく、PULSE_MAX_Rに達する頃にはほとんど見えなくなります。生体認証待機画面や決済承認の待機状態に合う方式です。

待機パルス — 認証待機状態で円形パルスの半径の値が減衰関数で徐々に大きくなりながら薄くなる方式
新しいタブで開く

02 · 待機パルス — 認証待機状態で円形パルスの半径の値が減衰関数で徐々に大きくなりながら薄くなる方式 — 計測 累積面積 3.397% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LAUNCH_GAP$subject-blue-deepPULSE_MAX_R
  function tick(now) {
    for (var i = 0; i < slots.length; i++) {
      var slot = slots[i];
      if (slot.start === null) { continue; }
      var age = now - slot.start;
      if (age > CUTOFF) {
        slot.start = null;
        slot.el.style.opacity = '0';
        continue;
      }
      var radius = PULSE_MAX_R * (1 - Math.exp(-age / TAU));
      var opacity = Math.max(0, 1 - radius / PULSE_MAX_R);
      slot.el.setAttribute('r', radius.toFixed(2));
      slot.el.style.opacity = opacity.toFixed(3);
    }
  }

数値が語ること

02は累積面積3.40%・強度平均36.1のまま、23フレーム中19フレームで動きが記録されました。一度に存在するリングスロットは4つだけで、成長しながら不透明度が0へ向かって薄くなるため、ループ自体は止まらなくても塗り替わる面積は小さく保たれます。

03失敗振動の減衰 — 認証失敗時にアイコンがスプリング減衰の値に沿って左右に揺れ、値が0に近づくと止まる方式

認証が失敗した瞬間、揺れのオフセットの値はAMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ)という式で最大振幅から減衰を始め、最初の一瞬でもっとも大きく揺れてから滑らかに0へ収まります。SHAKE_DAMPはdecayMs(=SHAKE_DAMP * 40)を直接決めるため、この値が小さいほど揺れが0に収まるまでの時間は短くなります。生体認証失敗のフィードバックやロック解除失敗画面に合う方式です。

失敗振動の減衰 — 認証失敗時にアイコンがスプリング減衰の値に沿って左右に揺れ、値が0に近づくと止まる方式
新しいタブで開く

03 · 失敗振動の減衰 — 認証失敗時にアイコンがスプリング減衰の値に沿って左右に揺れ、値が0に近づくと止まる方式 — 計測 累積面積 5.362% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SETTLE_MS$subject-creamSHAKE_DAMP
  function apply(t) {
    var offset = AMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ);
    icon.style.transform = 'translateX(' + offset.toFixed(2) + 'px)';
  }

  function runShake(ts) {
    if (!startTs) startTs = ts;
    var t = ts - startTs;
    if (t <= SETTLE_MS) {
      apply(t);
      requestAnimationFrame(runShake);
    } else {
      apply(0);
      status.textContent = STRINGS.idle;
      running = false;
      root.setAttribute('aria-pressed', 'false');
    }
  }

数値が語ること

03は累積面積5.36%・強度平均132.2のまま、23フレーム中16フレームで動きが記録されました。アイコンが再描画されるのはt <= SETTLE_MSのあいだだけで、オフセットが十分0に近づくとループ自体が止まるため、塗り替わる面積はサイクル全体に均等に広がらず、振幅が大きい前半に集中します。

04成功モーフィング — 認証に成功すると指紋アイコンのパスの値がチェックマークのパスの値へ滑らかに補間される方式

認証成功の瞬間、補間値はMORPH_MSかけて0から1へ上がり、buildPath()はその同じ値を使って10個の点すべてを指紋の円形座標と手作業で配置したCHECK_PTS座標のあいだで補間し、SVGのd文字列を毎フレーム作り直します——2つの固定した形のあいだをトランジションで渡すのではありません。成功のマイクロインタラクションやアプリのロック解除確認に合う方式です。

成功モーフィング — 認証に成功すると指紋アイコンのパスの値がチェックマークのパスの値へ滑らかに補間される方式
新しいタブで開く

04 · 成功モーフィング — 認証に成功すると指紋アイコンのパスの値がチェックマークのパスの値へ滑らかに補間される方式 — 計測 累積面積 3.191% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MORPH_MS$subject-blue-deepCHECK_PTS
  function buildPath(p) {
    var d = '';
    for (var i = 0; i < N; i++) {
      var c = circlePoint(i);
      var k = CHECK_PTS[i];
      var x = lerp(c[0], k[0], p);
      var y = lerp(c[1], k[1], p);
      d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
    }
    return d + 'Z';
  }

数値が語ること

04は9項目の中で強度平均が最も高い158.6を記録しながら、累積面積は3.19%、動いたフレームは23フレーム中15フレームにとどまりました。毎フレーム10個の点すべてを作り直すことでパスの鋭い辺が1ピクセルあたり大きく変化する一方、モーフィング自体は1回のトリガーにつき指紋→チェック、チェック→指紋の2回しか再生されないためです。

05圧力リップル — スキャン面を押す圧力の値が増すほど同心円リップルの半径と振幅の値が共に大きくなる方式

指やポインターが押されているあいだ、PointerEvent.pressureからそのまま読み取った圧力の値が32個の点からなるリングが基準半径からどれだけ揺れるかを決めます——BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED)という式です。軽く触れたときはほとんど揺れず、強く押すほど波が遠くまで届きます。実際のポインターが押されていないときは、同じ圧力の入力が自動のサイン波サイクルから来ます。指紋センサーのインタラクションデモやタッチ圧力のフィードバック背景に合う方式です。

圧力リップル — スキャン面を押す圧力の値が増すほど同心円リップルの半径と振幅の値が共に大きくなる方式
新しいタブで開く

05 · 圧力リップル — スキャン面を押す圧力の値が増すほど同心円リップルの半径と振幅の値が共に大きくなる方式 — 計測 累積面積 3.583% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PRESS_CYCLE_MS$subject-blue-lightWAVE_K
  function buildPath(t, pressure) {
    var d = '';
    for (var i = 0; i <= M; i++) {
      var theta = (i / M) * Math.PI * 2;
      var r = BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED);
      var x = 60 + r * Math.cos(theta);
      var y = 60 + r * Math.sin(theta);
      d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
    }
    return d + 'Z';
  }

数値が語ること

05は累積面積3.58%・強度平均107.7のまま、23フレーム中19フレームで動きが記録されました。32個の点からなる波は圧力の値にかかわらず毎フレーム描き直されますが、波そのものの振幅——つまり実際に塗り替わる面積——は現在の圧力の値がどれだけ大きいかにかかっています。

06ホログラムフリッカー — 2Dノイズの値を基にスキャンアイコンの明滅強度の値が微細に揺れる方式

周波数の異なる3つのサイン波(FREQS)を重み付けして合計し、およそ-1から1のあいだの値を作り、その値を各要素の不透明度にマッピングします——グリフには1つの位相オフセットを、4本のスキャンラインにはそれぞれLINE_PHASE_STEPだけずらした位相を与えるため、2本のラインが同時に同じリズムで明滅することはありません。この合計値は決定的な計算であり、値ノイズのフィールドをサンプリングしているわけではなく、Math.randomも一度も実行されません。生体認証待機アイコンやSF風の認証画面背景に合う方式です。

ホログラムフリッカー — 2Dノイズの値を基にスキャンアイコンの明滅強度の値が微細に揺れる方式
新しいタブで開く

06 · ホログラムフリッカー — 2Dノイズの値を基にスキャンアイコンの明滅強度の値が微細に揺れる方式 — 計測 累積面積 3.044% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LINE_PHASE_STEP$subject-skyFREQS
  function noise(t, phaseOffset) {
    var v = 0;
    for (var i = 0; i < 3; i++) {
      v += WEIGHTS[i] * Math.sin(t * FREQS[i] + PHASES[i] + phaseOffset);
    }
    return v / WSUM;
  }

数値が語ること

06は累積面積3.04%のまま、強度平均はわずか38.8にとどまりましたが、23フレーム中22フレームで動きが記録され——ほぼ全てのサンプルフレームで動いています。3つのサイン波の合計は完全には落ち着かないためですが、1回ごとの不透明度の変化は小さく、1ピクセルあたりの強度は9項目の中で下から2番目に低い値になっています。

07リングオーバーシュート — 円形プログレスリングが角度の値で満ちていき、完了の瞬間に慣性の値の分だけ少し行き過ぎてから戻る方式

stroke-dashoffsetの値はイージングのトゥイーンではなく、force = (target - pos) * STIFFNESS - vel * DAMPINGというスプリング物理シミュレーションを毎フレーム追いかけるため、目標値が切り替わるとリングの塗りは本当に目標を通り過ぎてから慣性で引き戻されます。自動デモはtargetをスプリングの整定時間より短いFLIP_MSごとに2つの一致率のあいだで切り替え続けるため、リングは常にどちらかへ向かう途中で、完全に静止することがありません。

リングオーバーシュート — 円形プログレスリングが角度の値で満ちていき、完了の瞬間に慣性の値の分だけ少し行き過ぎてから戻る方式
新しいタブで開く

07 · リングオーバーシュート — 円形プログレスリングが角度の値で満ちていき、完了の瞬間に慣性の値の分だけ少し行き過ぎてから戻る方式 — 計測 累積面積 1.799% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLIP_MS$subject-blue-deepSTIFFNESS
  function autoLoop(ts) {
    if (!isDemo || reduce) return;
    if (!autoLastTs) autoLastTs = ts;
    var dt = Math.min(ts - autoLastTs, 32);
    autoLastTs = ts;
    if (!autoFlipAt) autoFlipAt = ts + FLIP_MS;
    if (ts >= autoFlipAt) { autoTargetHigh = !autoTargetHigh; autoFlipAt = ts + FLIP_MS; }
    var target = autoTargetHigh ? AUTO_HIGH : AUTO_LOW;
    var force = (target - autoPos) * STIFFNESS - autoVel * DAMPING;
    autoVel += force * dt;
    autoPos += autoVel * dt;
    paint(autoPos);
    status.textContent = STRINGS.running;
    rafId = requestAnimationFrame(autoLoop);
  }

数値が語ること

07は9項目の中で累積面積が最少の1.80%でありながら、動いたフレーム数は23フレーム中23フレームと最多で、強度平均は136.8でした。自動ループの短いフリップ間隔のもとではスプリングが一度も本当に落ち着くことがなく、まれに大きく跳ねるのではなく、ほぼ全てのサンプルフレームで細いストロークとパーセント表示をわずかずつ動かし続けるためです。

08グリッドスキャングロー — スキャン領域をグリッドに分け、セルごとに異なる遅延の値で順に光る方式

36個のグリッドセルはそれぞれ行と列を足したdiagの値を持ち、render()はそれをdiag * DIAG_STEPというセルごとの遅延に変換したうえで、遅延を差し引いたローカル時間をMath.sinのパルスに渡してセルを光らせ、ピーク時にはわずかに拡大させます。遅延が行と列の合計だけで決まるため、同じ対角線上のセルは常に同じタイミングで光り、それが角から角へ光が流れるように見える理由です。顔・指紋のグリッドスキャン演出やセキュリティ認証のローディング画面に合う方式です。

グリッドスキャングロー — スキャン領域をグリッドに分け、セルごとに異なる遅延の値で順に光る方式
新しいタブで開く

08 · グリッドスキャングロー — スキャン領域をグリッドに分け、セルごとに異なる遅延の値で順に光る方式 — 計測 累積面積 12.0% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DIAG_STEP$subject-mintCYCLE_MS
  function render(t) {
    for (var i = 0; i < cells.length; i++) {
      var delay = cells[i].diag * DIAG_STEP;
      var local = ((t - delay) % CYCLE_MS + CYCLE_MS) % CYCLE_MS;
      var v = pulse(local);
      cells[i].el.style.opacity = (0.16 + v * 0.84).toFixed(3);
      cells[i].el.style.transform = 'scale(' + (1 + v * 0.22).toFixed(3) + ')';
    }
  }

数値が語ること

08は累積面積12.00%・強度平均76.7のまま、23フレーム中20フレームで動きが記録され——01(12.57%)に次いで9項目の中で2番目に大きい累積面積でした。36個のセルが短いトリガー窓だけでなく、繰り返しのサイクルにそって常に脈打ち続けるため、グリッドの広い対角帯がほとんど常にどこか光っている状態になるからです。

09待機呼吸 — 入力がない間アイコンの大きさの値がサインイージング曲線に沿って大きくなったり小さくなったりを繰り返す方式

スキャンや押下が行われていないあいだ、ひとつの拡大率の値が1 + AMPLITUDE * Math.sin(t * OMEGA)という式でおよそ0.91から1.10のあいだを行き来し、アイコン全体に一度に適用されます。同時に7本の稜線それぞれも、隣どうしRIDGE_PHASE_STEPだけ位相をずらした自分自身のサイン波で明るさが上下します。稜線の位相が内側ほど大きくなっていくため、すべての稜線が全く同じ式で動いているにもかかわらず、明るさそのものが中心から外側へ伝わっているように見えます。生体認証ボタンの待機状態やロック画面アイコンに合う方式です。

待機呼吸 — 入力がない間アイコンの大きさの値がサインイージング曲線に沿って大きくなったり小さくなったりを繰り返す方式
新しいタブで開く

09 · 待機呼吸 — 入力がない間アイコンの大きさの値がサインイージング曲線に沿って大きくなったり小さくなったりを繰り返す方式 — 計測 累積面積 3.988% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RIDGE_PHASE_STEP$subject-lilacOMEGA
  function render(t) {
    var s = 1 + AMPLITUDE * Math.sin(t * OMEGA);
    icon.style.transform = 'scale(' + s.toFixed(4) + ')';
    for (var i = 0; i < ridges.length; i++) {
      var phase = t * OMEGA + i * RIDGE_PHASE_STEP;
      var b = 0.5 + 0.5 * Math.sin(phase);
      ridges[i].style.opacity = b.toFixed(3);
    }
  }

数値が語ること

09は9項目の中で強度平均が最も低い23.9にとどまりながら、累積面積3.99%・23フレーム中21フレームで動きが記録されました。呼吸のような拡大率は止まることがありませんが、揺れ幅そのものは小さく——大きな塗り替えではなく、ゆるやかなサイズ変化と稜線の淡いフェードにとどまるため、ほとんどのフレームがわずかに動きながらも1フレームあたりの変化量は小さいままです。

値が揺れと明るさを変える基準 — 進捗値・圧力値・減衰値

zipにはこの9つの場面がまとめて入っており、開くパスワードはxysfva3xのひとつだけで、項目ごとに別のパスワードを探す必要はありません。

生体認証 アイコンのUIというと、ひとつのスピナーが「お待ちください」を表すだけになりがちですが、この9つのうち3つの項目はそれぞれ全く異なる基準に反応しています。01の稜線の明るさはスキャン進捗の値そのものに反応します——apply()が読む唯一の入力はビームのscanY位置で、スイープ全体に何ミリ秒かかろうと、稜線はビームが通過した順番どおりに明るくなります。05のリップルの振幅は代わりに押す圧力の値に反応します——PointerEvent.pressureが毎フレームリングの揺れ幅を決めるため、軽く触れたときと強く押したときでは同じ32点の波でも揺れる量が大きく変わります。03の揺れのオフセットはそれとは対照的に、減衰の値だけに反応します——SHAKE_DAMPが決めるのは揺れが0に収まるまでの時間だけで、最初に振れる振幅の大きさには関わりません。デモを標準よりも大きく揺らしたいときと早く止めたいときで、変えるべき数値がはっきり分かれているということです。

進捗・圧力・減衰という3つの基準の違いは、この指紋認証 ボタンのアニメーションを実際のフォームに組み込むときにこそ意味を持ちます。ログイン 画面 デザイン9選 — 会員登録からセッション切れまでは会員登録・ログイン・セッション切れという画面の流れ全体を扱うもので、各画面はこの9つの場面のように毎フレームの物理値ではなく、ステップの段階を反映しています。本人確認 画面UI 9選 — そのまま使える認証カードは本人確認画面という静的なレイアウト構造を並べたもので、ボタンひとつの連続したフィードバック値とは軸が異なります。ログイン 失敗 ロック 9選 — 値が計算で絞られていく9つの方式は残り試行回数という段階的な整数値をゼロへ向けて数えるもので、この9つの生体認証の場面が毎フレーム読み続ける連続的な減衰値やスプリング値とは性質が違います。

どこで壊れるか — 素早い再試行、低スペック端末、reduced-motion

生体認証スキャンボタンのシミュレーションは、実際のタイミングと実際のハードウェアが絡むと振る舞いが変わる場面が3つあります。

揺れが収まる前の再試行

03のtrigger()は冒頭でif (running) return;を実行しており、最初の失敗の揺れがまだ終わっていないあいだに来た2回目の失敗は、アニメーションを再スタートさせずに黙って無視されます。失敗した直後にもう一度タップした訪問者には、最初の揺れがそのまま最後まで減衰していく様子だけが見え、2回目の失敗はアプリケーションの状態には存在しても、独自の見た目としては現れません。

フレーム落ちの中のスプリング物理

07のautoLoop()は自身の経過時間をMath.min(ts - autoLastTs, 32)で32ミリ秒に丸めているため、実際には100ミリ秒かかった重いフレームでも、スプリングはあたかも32ミリ秒しか経っていないかのように進みます。FLIP_MSはスプリングの状態にかかわらず480ミリ秒ごとに目標を切り替え続けるため、フレーム落ちがひどい端末ではリングが次の切り替えまでにどちらの目標にも追いつけません。

reduced-motionはスイープを省略せず圧縮する

01はDUR = reduce ? 1 : (BASE_MS / SWEEP_SPEED)という式を使っており、prefers-reduced-motion: reduceはクリックで始まるスイープそのものを取り除くのではなく、上から下までの全経路をわずか1ミリ秒に圧縮します。動きを減らす設定を選んだ訪問者がボタンをクリックしても、アニメーションが完全に省略されるのではなく、すべての稜線がほぼ一瞬で光る様子が見えることになります。

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

スイープ速度を1.4から0.6へ下げると進捗の値がゆっくり上がり、稜線が順に明るくなる流れがはっきり見えます。SHAKE_DAMPを18から8へ下げるとdecayMs(=SHAKE_DAMP×40)が720msから320msへ縮まり、揺れの値が0に収まるまでの時間が短くなって、失敗の振動は一、二回跳ねてすぐ止まります。最大半径の値を40から70へ上げるとパルスがより広く広がってから薄くなり、待機状態がより余裕をもって見えます。

バリエーション 変わるもの 変わらないもの
スキャンラインを遅くする SWEEP_SPEED 1.4 → 0.6 GLOW_BAND、BASE_MS、稜線の頂点配置そのもの
失敗の揺れを早く収める SHAKE_DAMP 18 → 8 AMPLITUDE、FREQ、SETTLE_MS
待機パルスを広げる PULSE_MAX_R 40 → 70 TAU、LAUNCH_GAP、リングスロットの割り当て順

9項目とも視覚的なアイコン部分(01〜07・09は自身のSVGアイコン、08はグリッドセルのdiv)にaria-hidden="true"を付けており、アイコンやセルはあくまで装飾的なフィードバックで情報そのものではないためです。代わりに目に見えるステータス文(「待機中」「スキャン中」「認証失敗」「認証成功」など)を用意し、値が変わるたびにそのテキスト自体を書き換えます。切り替え可能なボタンにはすべてaria-pressedが付き、9項目全てがSpaceキーやEnterキーによるkeydownもクリックと並んで受け付けるため(05はpointerdown・pointermove・pointerupも加わります)、マウスで見る失敗の揺れや成功のモーフィング、スイープと同じ体験をキーボードからも呼び出せます。

FAQ

スマホ 指紋認証 ボタンのようなUIに、本物のセンサーは必要ですか

必要ありません。9つとも実際の指紋・顔認証APIを呼び出さないシミュレーションで、スキャン進捗・押す圧力・一致率はすべてクリックやキー操作、PointerEvent.pressureから来る単純な数値です。展開するzipにも同じ数値ベースのコードがそのまま入っています。

この指紋認証 アニメーションの9パターンは、それぞれ別ファイルですか、それとも設定違いの1コンポーネントですか

9つとも独立したファイルです。項目ごとに自分自身の定数と自分自身のステップ関数・描画関数を持っており、上のコード欄はテンプレートに数値を差し込んで生成した出力ではなく、そのファイルから直接抜き出した抜粋です。

06のホログラムフリッカーは、なぜ実際のノイズライブラリを使っていないのですか

使う必要がないためです。周波数の異なる3つのサイン波を重み付けして合計するnoise()だけで、人の目には繰り返しに気づきにくい値が十分に作れます。外部のノイズライブラリを省くことで、このセットの他の項目と同じくファイル単体で完結させています。

Sources: MDN — CSS アニメーション · MDN — Web Animations API · MDN — stroke-dashoffset · MDN — Window.requestAnimationFrame()

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

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