ライブエフェクト
コンテンツが画面に表示された後もページを動かし続ける4つのエフェクト:
- オーロラヒーロー: ヒーローセクションの背後にある、ポインターの周りで歪むWebGLの柔らかな光のフィールド。
- ホログラムカード: カーソルに向かってカードが傾き、浮き上がり、グレアと虹色のエッジが現れます。
- グリッドスポットライト: ページの背景グリッドがカーソルの周りに柔らかな円形で光ります。
- グリッチ&スクランブルテキスト: 見出しが到着時にRGBスプリットし、リンクラベルがホバー時にデコードされます。
基準は「生きているが、決して邪魔にならない」ことです。ユーザーはページが自分に反応しているのを感じられるべきですが、何もカクついたり、レイアウトがずれたり、クリックを奪ったり、スクリーンリーダーからテキストを隠したりしてはいけません。
ライブ例:https://www.lamanhtruong.com(プロジェクトカードにホバー、ヒーロー上でマウスを動かす、ナビにホバー)。
assets/fx/ の内容
| ファイル | 役割 |
|---|---|
ticker.ts | フレームを必要とするものがないときにスリープする、単一の共有 requestAnimationFrame ループ |
built.ts | 準備完了ゲート(isBuilt、buildActive)、frozen()、シード付きPRNG、ポインターメディアクエリ |
aurora.ts | WebGLシェーダー、その品質スケーラーとライフサイクル(createAurora) |
hero-shader.tsx | <HeroShader />、オーロラ用のReactラッパー |
holo.ts | カードのチルト+グレア+光沢、スプリング物理、注入されるオーバーレイ |
spotlight.ts | カーソルを追従するグリッドスポットライト(transformのみ) |
glitch.ts、glitch-copies.tsx | グリッチスケジューラー、<GlitchCopies /> と <GlitchHeading /> |
scramble.ts、scramble-text.tsx | スクランブルランナーと <ScrambleText /> マークアップフック |
fx-root.tsx | <FxRoot />:holo、spotlight、glitch、scrambleを一度だけマウント |
fx.css | すべてのスタイル、--fx-* カスタムプロパティでテーマ化 |
コードはReactのみを必要とします。アニメーションライブラリは不要です。テンプレートは厳格なTypeScriptでReact 18および19の型に対して型チェックされます。
インストール
assets/fx/をアプリケーションにコピーします(例:src/components/fx/)。ファイルは相対パスで相互にインポートするため、一緒に保管してください。- マウントします。ページ自体は静的サーバーコンポーネントのままで構いません。
FxRootとHeroShaderのみがクライアントコンポーネントです。
import { FxRoot } from "@/components/fx/fx-root";
import { HeroShader } from "@/components/fx/hero-shader";
import { GlitchHeading } from "@/components/fx/glitch-copies";
import { ScrambleText } from "@/components/fx/scramble-text";
export default function Page() {
return (
<div className="fx-grid"> {/* 固定ベースグリッド;独自のものがあればスキップ */}
<main className="relative z-[1]"> {/* コンテンツはグリッド+スポットライトの上に配置 */}
<section className="relative min-h-[80vh]"> {/* ヒーローは position: relative である必要があります */}
<HeroShader /> {/* 最初の子要素、ヒーローコンテンツの背後 */}
<h1>…</h1>
</section>
<GlitchHeading as="h2" className="text-5xl font-semibold">Selected work</GlitchHeading>
<article data-holo className="rounded-2xl border p-6">
<a href="/work/x" className="inline-flex items-center gap-2">
<ScrambleText>Read the case study</ScrambleText> <ArrowIcon />
</a>
</article>
</main>
<FxRoot /> {/* ページごとに1回、グリッドラッパー内 */}
</div>
);
}
- テーマを設定します:
fx.cssの先頭にある--fx-*変数を編集し、<HeroShader />にpalette/intensityを渡します(以下の「テーマ設定」を参照)。 - 完了とみなす前に、
references/verification.mdのPlaywrightチェックで検証してください。
プロップで個別のエフェクトをオフにできます:<FxRoot holo={false} spotlight={false} />。既存のカードをマークアップに触れずにチルトの対象にできます:<FxRoot holo={{ selector: ".card", within: "main" }} />。
スムーズさを保つルール
これらのルールが「プレミアム」と「カクつき」を分けます。それぞれのルールは、それを破ると目に見える問題が発生したために存在します。
-
コンテンツの準備ができるまで待つ。 他のスクリプトがまだ公開または測定している要素に対して、チルト、グリッチ、スクランブルを決して実行しないでください。公開途中の要素に対する変換は、それをアニメーション化しているものの測定を破壊し、結果はスタッターのように見えます。
built.tsは1つの契約を実装します:html[data-build="on"]が設定されている間、要素はその[data-build-item]と[data-build-section]が[data-built]を持っている場合にのみカウントされます。ページにビルドエンジンがない場合、すべてがすぐにビルドされたものとしてカウントされます。任意のイントロアニメーションはこれらの属性を設定することでオプトインできます。詳細:references/gating-and-performance.md。 -
1つのフレームループを使用し、それをスリープさせる。 すべてのエフェクトは、自身で
requestAnimationFrameを呼び出す代わりにticker.tsにサブスクライブします。サブスクライバーは、落ち着いたときにfalseを返し、誰もフレームを必要としなくなったときにループは停止します。アイドル状態のページは何もコストがかからず、エフェクトがフレームを取り合うこともありません。グリッチはフレームをまったく使用しません。CSSキーフレームを属性で切り替えているだけです。 -
transform、opacity、clip-pathのみをアニメーション化する。 これらはコンポジター上に留まります。ここでサイズ、フロー内の位置、フォントメトリクスを変更するものはないため、レイアウトシフトは設計上ゼロです。スクランブルは実際のテキストをその場に(透明にして)保持し、グリフをオーバーレイに描画するため、ラベルが変わっても幅が変わることはありません。 -
エフェクトを入力に合わせる。 チルトとフォロースポットライトは
(hover: hover) and (pointer: fine)の場合にのみ存在します。タッチでは、指の下で傾くとバグのように見えるため、タッチではタップポイントでの短いフェードと、自己漂流するシェーダーレンズが提供されます。 -
静止時はReactのDOMをそのままにする。 Holoオーバーレイ
<span>とインラインtransformは、カードがエンゲージしたときに追加され、元の位置に落ち着いたときに削除されます。静止時にはDOMはReactがレンダリングしたものとまったく同じであるため、再レンダリング、ハイドレーション、カード自身の:hoverスタイルは引き続き機能します。グリッチレイヤーとスクランブルスパンは、実行時にReactが所有するノードの周りに挿入されるのではなく、マークアップ(<GlitchCopies>、<ScrambleText>)に記述されます。外部からReactのツリーを再構築すると、Reconciliationが壊れます。 -
テキストを実際のものとして保つ。 グリッチコピーはクライアントのみで
aria-hiddenであるため、静的なHTML、ページ内検索、スクリーンリーダーは各見出しを1回だけ認識します。スクランブルオーバーレイはaria-hiddenであり、実際のラベルは全体を通してアクセシブルな名前のままです。 -
クリーンアップし、StrictModeでも動作する。 すべての
setup*は、リスナー、オブザーバー、オーバーレイ、インラインスタイルを削除するディスポーザーを返します。シェーダーはそのバッファーとプログラムを解放しますが、意図的にWEBGL_lose_contextは呼び出しません。なぜなら、React StrictModeは同じキャンバスでマウント、アンマウント、再マウントを行い、失われたコンテキストは失われたままになるからです。 -
GPUを予算化する。 シェーダーはピクセル予算の下で縮小解像度でレンダリングし、表示されている間のみ描画し、高リフレッシュレート画面では1秒あたり約80回に描画を制限し、ページが約38 fps未満で実行されている場合は品質を一段階下げます。ソフトウェアフォールバック(
failIfMajorPerformanceCaveat)では起動を拒否し、遅いものではなく何もレンダリングしません。
エフェクト
オーロラヒーローシェーダー(aurora.ts、hero-shader.tsx)
シェーダーはドメインワープされたバリューノイズ(fbmによってワープされたfbm)であり、広い輝きと、ワープされた輪郭に沿った細く明るいストランドとして描画されます。ポインターはレンズとして機能し、近くの流れを拡大および渦巻かせます。ヒーローに入る、クリックする、または速く動くと、柔らかいリップルが発生します。出力はプリマルチプライドアルファで、すべてのエッジでフェードアウトするため、ページの背景と固定グリッドが透けて見え、ヒーローはボックスで終わるのではなく、次のセクションにブレンドされます。
ライフサイクル:キャンバスは、そのセクションがビルドされた後、1.8秒かけてフェードインします。セクションが画面に表示されている間(80pxのマージンを持つIntersectionObserver)およびタブが表示されている間のみレンダリングします。WebGLがない場合は何もレンダリングしません。
調整可能な値:
palette:{ veil, primary, secondary, accent }を16進数で指定。パレットはシェーダーにコンパイルされるため、変更には再マウントが必要です。intensity:光を増幅します。0.6は落ち着いており、1.4は派手です。- テキストの可読性:シェーダーは光を左下から遠ざけるようにバイアスをかけます(通常ヒーローテキストはそこに配置されます)。テキストが別の場所にある場合は、
bias行を変更してください(リファレンスを参照)。
シェーダーの計算、速度、品質スケーラーを変更する前に、references/aurora-shader.md をお読みください。
ホログラムカード(holo.ts)
委譲された pointerover が、ファインポインターの下にあるカードを見つけます。エンゲージすると、カードの変換されていないボックスを測定し、1つの aria-hidden オーバーレイスパンを注入し、ポインターを狙ったチルトに向かって臨界制動スプリング(60fpsでも144fpsでも同じ動作をするように120Hzでサブステップ実行)を実行します。カーソルの下のエッジがビューアに向かって来ます。離れると、柔らかなオーバーシュートでスプリングバックし、落ち着いたらすべてを削除します。
- チルトはカードのサイズに応じて2.5°から7.5°の間でスケーリングします。大きなカードは傾きが少なく、遠い端が振れないようにします。視点は大きい方の辺の1.6倍です。浮き上がりは3pxです。
- オーバーレイには、ポインターの位置にある放射状のグレア、かすかな斜めのカラーウォッシュ、境界にマスクされた円錐グラデーションリング(全体的に暗く、ポインターの近くで明るい)があります。
overflow: hiddenのカードでは、1pxのボーダーボックスがクリップされるため、リングはそのすぐ内側に描画されます(.fx-holo--in、自動検出)。- オーバーレイは
z-index: 3でpointer-events: noneに設定されているため、カード内のコントロール(ビデオトグル、リンク)の上に描画されますが、クリックをブロックしません。 - 静的配置のカードは、アクティブな間だけ
position: relativeを取得します。 - オプトアウト:カードまたは任意の祖先に
data-holo="off"。誰かが実際の矩形を読み取っている間(インスペクター、スクリーンショットパス)、html[data-fx-freeze]ですべてのチルトを一時停止します。living-ui-swarmのインスペクトトグルは自動的に検出されます。
グリッドスポットライト(spotlight.ts、div.fx-spot)
560pxの固定ウィンドウが放射状にマスクされ、ベースグリッドの明るいコピーを保持します。各フレームで2つの変換のみが移動します:ウィンドウはカーソルを追従し、内部のグリッドは floor(x / cell) * cell - x だけシフトバックされ、その線がベースグリッドの格子に正確に重なるようにします。両方のグリッドはビューポートの原点に固定されている必要があり、.fx-grid::before は固定されています。後続の線形補間は落ち着き、カーソルが停止するとループはスリープします。ウィンドウがブラーになるか、ポインターがウィンドウを離れると非表示になります。タッチでは、タップポイントで360msのフェードが得られます。
グリッドセル、細線の間隔、ウィンドウサイズは --fx-grid-cell、--fx-grid-fine、--fx-spot-size(px値)から取得されるため、CSSが単一の信頼できる情報源であり続けます。
グリッチ見出し(glitch.ts、<GlitchHeading> / <GlitchCopies>)
マークアップ:実際の見出し(.fx-glitch__base)と2つのクライアントのみのコピーを保持する .fx-glitch[data-fx-glitch] ラッパー。GlitchHeading はそれらすべてを書き込みます。見出しがすでに存在し、他の属性を持っている場合は、GlitchCopies を直接使用します。
- エントランス: ビルドされた見出しが少なくとも50%ビュー内にある場合、560ms。同じ見出しが2.5秒以内に再度再生されることはありません。
- マイクログリッチ: 8~14秒ごとに、ランダムに表示されている1つの見出しで260ms。
- ランダム性はシードされているため、リズムは毎回の訪問およびテストで同じです。
- 一度にグリッチする見出しは1つだけです。エントランスは互いにキューイングされます。
[data-glitch]によって切り替えられる純粋なCSSキーフレームとして実装されます:clip-path: inset()でスライス、translate3dでオフセット、text-shadowで色を分割します。--fx-giは実行ごとにオフセットをスケーリングします。
スクランブルテキスト(scramble.ts、<ScrambleText>)
ラベルのみをラップし、アイコンはラップしないでください:<a><Icon/> <ScrambleText>{label}</ScrambleText></a>。
- トリガー: ホバー(タッチではない)または囲んでいる
a、button、[data-fx-scr-host]でのキーボード:focus-visible。 - 持続時間: 360~520ms(長さに依存)。
- 解決: 文字は左から右に少しジッターを伴って解決されます。未解決の末尾は毎フレームではなく約18Hzで再描画されるため、ノイズではなくデコードとして読み取れます。
- グリフプール: 大文字小文字が一致し、
@ . / : -などの句読点はそのまま保持されるため、メールやパスはスクランブル中も読みやすいままです。 - クリーンアップ: オーバーレイは常に空にされます。rAFが停止するバックグラウンドタブ用のタイマーフォールバックも含まれます。
プレーンなインラインリンクには inline を使用します(下線を保持)。塗りつぶしボタンには、--fx-scr-tail: currentColor と一緒に quiet を使用します。
テーマ設定
| 変数 | 使用箇所 | デフォルト |
|---|---|---|
--fx-accent | グリッチレイヤーA、スクランブルグリフ、スポットライトの輝き、ホロの影 | #5eead4 |
--fx-hot | グリッチレイヤーB | #f472b6 |
--fx-sheen-1..5 | ホロリング、円の周りの順序 | ティール、ブルー、バイオレット、ピンク、アンバー |
--fx-grid-line、--fx-grid-fine-line | ベースグリッドライン | 低アルファスレート |
--fx-grid-cell、--fx-grid-fine | グリッド間隔(px) | 96px、24px |
--fx-spot-size | スポットライトウィンドウ(px) | 560px |
--fx-mono | スクランブルグリフフォント | システムモノ |
--fx-scr-tail | 要素ごとのグリフ色の上書き | 未設定 |
色は color-mix(in srgb, …) を通じて混合されます。これは現在のすべてのブラウザでサポートされています。シェーダーは独自のパレットを受け取ります:<HeroShader palette={{ accent: "#22d3ee", secondary: "#f0abfc" }} intensity={0.8} />。
シェーダーの色はページのアクセントと同じファミリーから選んでください。シェーダーはムードであり、2番目のブランドカラーではありません。初めての訪問者がヘッドラインの前にシェーダーに気付く場合、それは大きすぎます。
レイヤリング
| レイヤー | z-index | 備考 |
|---|---|---|
ベースグリッド(.fx-grid::before、fixed) | 0 | ビューポートの原点に固定 |
スポットライト(div.fx-spot、fixed) | 0 | ツリーオーダーでグリッドの後ろにあるため、その上に塗装される |
コンテンツ(main、footer) | 1 | z-index: 1 で配置する必要がある |
ヒーローシェーダー(canvas.fx-shader) | auto | ヒーロー内で絶対配置、最初の子要素。後続のコンテンツはその上に塗装される |
| Holoオーバーレイ | カード内で3 | pointer-events: none |
| 固定ヘッダー、ビルドアニメーションオーバーレイキャンバス | コンテンツの上 | 影響を受けない |
テキストの上にスポットライトが表示される場合、コンテンツラッパーに position: relative; z-index: 1 がありません。
モーションプリファレンス
lamanhtruong.comでは、所有者は prefers-reduced-motion の訪問者を含むすべての人にすべてのエフェクトを再生することを選択しました。これはポートフォリオの要点が動きであるためです。これは正当な製品上の決定ですが、意図的に行ってください。代わりに設定を尊重するには:
<FxRoot motion="reduce" />は、prefers-reduced-motion: reduceが設定されている場合、すべてのエフェクトを無効にします。- 個々のエフェクトのモーション設定を尊重するには、
<FxRoot holo={{ motion: "reduce" }} />を使用します。
パフォーマンス
各エフェクトは、GPUとCPUの使用量を最小限に抑えるように設計されています。
- シェーダー: 解像度を動的にスケーリングし、表示されていないときはレンダリングを停止します。
- ホログラムカード: カードがエンゲージされている間のみアクティブになります。
- スポットライト: カーソルが動いている間のみアクティブになり、停止するとスリープします。
- グリッチとスクランブル: アニメーション中のみアクティブになり、その後はアイドル状態になります。
これらの最適化により、エフェクトはパフォーマンスに重大な影響を与えることなく、あらゆるデバイスでスムーズに実行されます。
アクセシビリティ
すべてのエフェクトは、アクセシビリティを念頭に置いて設計されています。
- スクリーンリーダー: すべての視覚エフェクトは
aria-hiddenであり、スクリーンリーダーには表示されません。 - キーボードナビゲーション: スクランブルテキストは、キーボードフォーカス時にもトリガーされます。
- モーション:
prefers-reduced-motionの設定を尊重できます。 - タッチ: タッチデバイスでは、適切に動作するようにエフェクトが調整されます。
トラブルシューティング
- エフェクトが表示されない: コンソールにエラーがないか確認してください。また、コンテンツがビルドされていることを確認してください(
data-build属性が正しく設定されているかどうか)。WebGLが利用可能であることを確認してください。 - エフェクトがカクつく: パフォーマンスルールに従っていることを確認してください。シェーダーの品質設定を調整してみてください。
- エフェクトが正しく動作しない: コンソールにエラーがないか確認してください。また、
assets/fx/のすべてのファイルが正しくインポートされていることを確認してください。 - レイアウトが崩れる: すべてのエフェクトが
transform、opacity、clip-pathのみをアニメーション化していることを確認してください。コンテンツにz-indexが正しく設定されていることを確認してください。