ここ最近作成した 2 つのプロジェクト、「KALEIDO-BOARD」と「DOM-SPEAKER」や、ブログ記事内のライブデモの多くは、HTML Web Components をベースに実装しています。
上記 2 つのアプリは JavaScript が有効なことが必須条件なので、Progressive Enhancement な実装とはいえないですが、小規模のアプリであれば、HTML Web Components のテクニックが有効であることを実感しています。
本記事では、これらのプロジェクトで得られた経験を踏まえながら、画像をスキャニメーション(スリットアニメーション)のように加工するマイクロアプリを作成していきます。
なお、HTML Web Components の基本的な特徴については、以下の記事で言及しています。
今回作成するアプリの完成形は以下です。
本アプリは、レンジスライダーを操作して変更した値の組み合わせによって、視覚への負担が大きくなりますので、使用する際にはご注意ください。
「LEVEL」のレンジスライダーを変更すると、シュレッダーで裁断したような効果が生まれます。
「LEVEL」をある程度上げると画像の認識が難しくなりますが、「SLIT SHEET」をオンにすると認識できるようになります。この状態で「MOVE」のレンジスライダーを動かすと、スキャニメーションやレンチキュラーのような独特な効果が得られます。
そのほか、「UPLOAD」ボタンから画像ファイルをアップロードする機能や、「IMAGE SIZE」で表示する画像サイズを変更できる機能を備えています。
本アプリの前提条件は以下とします。
- 使用する画像は 1 枚
- 画像は
background-image
で指定
- アニメーションは
background-position
で表現
- 画像のマスキングは
mask-image
で表現
画像 1 枚を使い回していますが、フレームごとに background-position
や mask-position
で位置をずらすことで、コマ送りのアニメーションを表現しています。
一般的なスキャニメーションであれば、複数画像をフレームごとに割り当てて、パラパラ漫画のようなアニメーションを実現するところですが、今回は説明をシンプルにするために、1 枚の画像が斜め上に遷移するだけの表現にとどめています。
本アプリは以下の機能を実装します。
- 画像アップロード機能
- マスキングレベルの変更
- 画像サイズ(
background-size
)の変更
- スリットシートの表示・非表示
- スリットシートの移動(スキャニメーション)
それでは、HTML、JavaScript、CSS の順番で具体的な実装内容を説明していきます。
まずは、HTML から考えていきます。以下に該当するコードを抜粋します。
カスタム要素 <micro-app>
で全体を囲っています。大きく分けて「プレビュー」と「フォームコントロール」の 2 つのエリアから構成されています。
まず、生成した画像を表示するプレビューエリアの HTML ですが、以下のように 10 個のフレーム(.c-preview-frame
)と、その上に重ねるスリットシート(.c-preview-sheet
)の要素で構成されています。
各フレームに指定している data-app="frame"
は、JS で対象の要素を特定するための属性です。
フォームコントロールの種類としては、以下の要素が含まれています。
この name
属性の値は、JS で CSS のカスタム変数を指定するときに使用します。
続いて、JS のコードを抜粋します。全体のコード量はそれほど多くはありません。
トピックごとに順番に説明していきます。
以下のコードでカスタム要素を定義しています。この指定によって、<micro-app>
というカスタム要素に対して、JS で独自の振る舞いを付与することができるようになります。
class MicroApp extends HTMLElement {
// ...
}
customElements.define('micro-app', MicroApp);
ES2022 から導入された JS のクラスフィールド構文では、クラス内で使用する DOM 要素やオブジェクト(インスタンス)をまとめて定義できるので、コードの可読性が高くなり重宝しています。
class MicroApp extends HTMLElement {
// クラスフィールド
ranges = this.querySelectorAll('[type="range"]');
frames = this.querySelectorAll('[data-app="frame"]');
inputFile = this.querySelector('input[type="file"]');
controller = new AbortController();
image = new Image();
blobURL = null;
//...
}
ちなみに、#ranges
のように先頭にハッシュ(#
)を付与すると、クラス内のみでアクセスできるプライベートなクラスフィールドになりますが、カスタム要素の場合にはクラスの外側を気にすることがほとんどないので、パブリックにしています。カスタム要素におけるプライベートなクラスフィールドの利点(もしくはパブリックなクラスフィールドの欠点)が見つかれば、今後書き方を変えるかもしれません。
connectedCallback()
は、カスタム要素が追加されたときに呼び出されます。
以下のコードでは CSS 変数の初期化をおこなっており、各フレームに対してインデックス番号を --index: 0
、--index: 1
といったように指定しています。
connectedCallback() {
this.frames.forEach((level, index) => {
level.style.setProperty('--index', String(index));
});
// ...
}
続いて、レンジスライダーと画像アップロードボタンにイベントリスナーを設定しています。
レンジスライダーでは入力した内容を、カスタム要素の CSS 変数(カスタムプロパティ)として設定するという手法を取っています。
例えば、「LEVEL」であれば name="level"
を設定しているので、レンジスライダーの値を 4
に変更すると、カスタム要素に style="--level: 4"
が反映されます。
このように、レンジスライダーを操作したときに、JS でダイレクトにスタイルの値を操作するのではなく、CSS 変数を経由することで、CSS 側で calc()
を使用して柔軟に値を調整することができます。加えて、JS 側のコード量は減りますし、その分の処理の負担を軽減できます。
画像関連では、画像を追加するときの addImage()
と、画像への参照が不要になったときにメモリを解放するための releaseImageURL()
の 2 つのメソッドを定義しています。
最後に CSS のコードを見ていきます。すべてを掲載するとかなり長くなってしまうので、プレビューエリアまでに限定し、フォームコントロールのスタイルは割愛します。
それでは順番に説明していきます。
プレビューエリアの各フレーム(.c-preview-frame
)とスリットシート(.c-preview-sheet
)は、すべて同じサイズ、同じ位置で重ねる必要があるので、親要素に display: grid
を指定して同一のグリッドセルに重ねています。
各フレームの画像やスリットシートにマスクを適用する方法を見ていきます。
まず、CSS 変数 --base-size
ではマスクの基準となるサイズを定義していますが、「LEVEL」のレンジスライダーの値が大きくなると基準サイズが小さくなります。
例えば、「LEVEL」の値が 1
であれば 100% / 1 = 100%
で、2
であれば 100% / 2 = 50%
になります。
続いて、各フレーム(.c-preview-frame
)とスリットシート(.c-preview-sheet
)に指定している、mask
関連のスタイルに着目します。
mask-image
、mask-position
、mask-size
の各プロパティで指定している内容を順番に見ていきましょう。
まず、mask-image
では linear-gradient()
関数を使用して --base-size
分のストライプパターンを作成しています。black
と transparent
の指定順を逆にすることで、ストライプを交互に表示しています。
以下は、上記の mask-image
のコードを擬似的に適用したデモです。「SPLIT」や「3D」を選択すると、フレームとスリットシートのレイヤーが分かれるので、互い違いになっていることが視覚的に把握できます。
続いて、mask-position
ではマスクを開始する水平方向の位置を指定しています。
各フレームは以下のとおり、マスクの基準サイズにフレームのインデックス番号を乗算して、フレームごとにマスクの位置をずらしています。
スリットシートは「MOVE」のレンジスライダーから受け取った --move
の値によって、マスクのサイズ分、水平方向に移動しています。
このスリットシートの mask-position
の移動によって、コマ送りのようなアニメーションが実現しています。
mask-size
では、文字どおりマスクのサイズを指定していますが、この値によってストライプの密度が決まります。
サイズが大きいとその分、画像が隠れる部分が大きくなり、アニメーションも大雑把になります。一方で、細かくしすぎると「LEVEL」を上げたときに画像が見えなくなってしまいます。
本アプリでは --base-size
の 1/10 のサイズとしていますが、小さいスクリーンサイズでも画像が見えなくならないように、最小サイズ 4px
を指定しています。
.c-preview {
--base-size: calc(100% / var(--level));
--mask-size: calc(var(--base-size) / 10);
--mask-min-size: 4px;
}
.c-preview-frame {
mask-size: max(var(--mask-min-size), var(--mask-size)) 100%;
}
.c-preview-sheet {
mask-size: max(var(--mask-min-size), var(--mask-size)) 100%;
}
続いて、各フレームに指定している背景画像のスタイルについて解説します。
background-image
には、最初はデフォルトの画像が指定されていますが、ファイルをアップロードすると、Blob オブジェクトに変換された画像が反映されます。
micro-app {
--background-image: url('./img-default.webp');
}
.c-preview-frame {
background-image: var(--background-image);
}
ちなみに、カスタム要素に style
属性としてデフォルト画像を指定することもできるので、コンポーネントごとにデフォルト画像を変えたい場合にはこの方法を使います。
background-position
は、冒頭の図版で説明したように、アニメーションを表現するために画像を段階的に右上に移動している指定です。
フレームの中心を起点に、--bg-pos
の分だけ移動させていますが、この --bg-pos
には、フレームのインデックス番号である --index
を乗算しているので、フレームごとに位置が変わります。
ちなみに、ここで指定している 100cqi
は、フレームを囲っているコンテナ要素(.c-preview-inner
)のインラインサイズを指しています。
background-size
は、「IMAGE SIZE」のレンジスライダーから受け取った値が反映されます。
.c-preview-frame {
background-size: calc(var(--background-size, 100) * 1%) auto;
}
なお、背景画像は幅が基準になるため、縦横比が異なる画像の場合には、部分的に見切れたりリピートして表示されます。
スリットシートの表示は JS で制御してもよかったのですが、CSS の :has()
で実装しました。以下のようにチェックが付いていないときに opacity: 0
を指定しています。
micro-app:has([name="sheet"]:not(:checked)) .c-preview-sheet {
opacity: 0;
}
フォームコントロールのスタイルは割愛しましたが、ここまでに説明した内容でアプリの基本的な機能を備えることができます。
本記事では、HTML Web Components のテクニックをベースに、小規模なアプリを実装する方法を説明しました。少ないコード量でインタラクティブな機能を持ったアプリを実装できることが改めて認識できました。
また、スタイルは JS でダイレクトに値を変更するのではなく、CSS 変数を経由することで、CSS 側で柔軟に値をコントロールできることも、特におさえておきたいポイントです。
実際のデモでは、画像ファイルをドラッグ & ドロップでアップロードする機能や、プレビューエリアの画像をドラッグして自由に移動できる機能も実装していますので試してみてください。
本アプリは、当初 CSS でスキャニメーションを実現させるプロジェクトのプロトタイプとして作成したものでしたが、記事のテーマに合致していたため調整して使うことにしました。
ここからさらに拡張するとすれば、複数画像を登録できるようにして本来のスキャニメーションを実現させたり、マスクの向きを変更できるようにしたり、スリットシートのカラーやブレンドモードを変更できるようにしたりと、まだまだ可能性は広がりそうです。