ここ最近作成した 2 つのプロジェクト、「KALEIDO-BOARD」と「DOM-SPEAKER」や、ブログ記事内のライブデモの多くは、HTML Web Components をベースに実装しています。

上記 2 つのアプリは JavaScript が有効なことが必須条件なので、Progressive Enhancement な実装とはいえないですが、小規模のアプリであれば、HTML Web Components のテクニックが有効であることを実感しています。

本記事では、これらのプロジェクトで得られた経験を踏まえながら、画像をスキャニメーション(スリットアニメーション)のように加工するマイクロアプリを作成していきます。

なお、HTML Web Components の基本的な特徴については、以下の記事で言及しています。

今回作成するアプリの完成形は以下です。

本アプリは、レンジスライダーを操作して変更した値の組み合わせによって、視覚への負担が大きくなりますので、使用する際にはご注意ください。

Live Demo

本アプリはお使いのブラウザには対応していません 🙇‍♂️

Image

1
100
0

「LEVEL」のレンジスライダーを変更すると、シュレッダーで裁断したような効果が生まれます。

「LEVEL」をある程度上げると画像の認識が難しくなりますが、「SLIT SHEET」をオンにすると認識できるようになります。この状態で「MOVE」のレンジスライダーを動かすと、スキャニメーションやレンチキュラーのような独特な効果が得られます。

そのほか、「UPLOAD」ボタンから画像ファイルをアップロードする機能や、「IMAGE SIZE」で表示する画像サイズを変更できる機能を備えています。

なお、デフォルトで指定している Kodak 社のレトロな広告の画像は、Kodak films box(Museums Victoria) を拝借しています。

本アプリの前提条件は以下とします。

  • 使用する画像は 1 枚
  • 画像は background-image で指定
  • アニメーションは background-position で表現
  • 画像のマスキングは mask-image で表現

画像 1 枚を使い回していますが、フレームごとに background-positionmask-position で位置をずらすことで、コマ送りのアニメーションを表現しています。

複数のフレームからコマ送りアニメーションを実現する方法を示した図
フレームごとに背景画像の位置(background-position)を右上に移動し、均等に位置(mask-position)をずらしてマスクしたものを統合

一般的なスキャニメーションであれば、複数画像をフレームごとに割り当てて、パラパラ漫画のようなアニメーションを実現するところですが、今回は説明をシンプルにするために、1 枚の画像が斜め上に遷移するだけの表現にとどめています。

本アプリは以下の機能を実装します。

  • 画像アップロード機能
  • マスキングレベルの変更
  • 画像サイズ(background-size)の変更
  • スリットシートの表示・非表示
  • スリットシートの移動(スキャニメーション)

それでは、HTML、JavaScript、CSS の順番で具体的な実装内容を説明していきます。

まずは、HTML から考えていきます。以下に該当するコードを抜粋します。

アプリの HTML
<micro-app>

  <!-- プレビュー -->
  <div class="c-preview">
    <div class="c-preview-inner">
      <div class="c-preview-frame" data-app="frame"></div>
      <div class="c-preview-frame" data-app="frame"></div>
      <div class="c-preview-frame" data-app="frame"></div>
      <div class="c-preview-frame" data-app="frame"></div>
      <div class="c-preview-frame" data-app="frame"></div>
      <div class="c-preview-frame" data-app="frame"></div>
      <div class="c-preview-frame" data-app="frame"></div>
      <div class="c-preview-frame" data-app="frame"></div>
      <div class="c-preview-frame" data-app="frame"></div>
      <div class="c-preview-frame" data-app="frame"></div>
      <div class="c-preview-sheet"></div>
    </div>
  </div>

  <!-- フォームコントロール -->
  <div class="c-ui">
    <div class="c-ui-column">

      <!-- 画像アップロード -->
      <div class="c-ui-group">
        <div class="c-image">
          <p class="c-label">Image</p>
          <div class="c-image-preview"></div>
          <div class="c-image-upload">
            <label for="app-image-upload" class="c-image-upload-label c-label">Upload</label>
            <div class="c-image-upload-btn">
              <input type="file" name="image" accept="image/*" id="app-image-upload">
            </div>
          </div>
        </div>
      </div>
    </div>

    <div class="c-ui-column">
      <!-- LEVEL と IMAGE SIZE のレンジスライダー -->
      <div class="c-ui-group">
        <div class="c-range">
          <label for="app-level" class="c-label">Level</label>
          <input type="range" name="level" value="1" min="1" max="10" id="app-level">
        </div>
        <div class="c-range">
          <label for="app-background-size" class="c-label">Image Size</label>
          <input type="range" name="background-size" value="100" min="1" max="100" id="app-background-size">
        </div>
      </div>

      <!-- スリットシートの操作 -->
      <div class="c-ui-group --nest">
        <label class="c-checkbox c-label">
          <input type="checkbox" name="sheet" class="c-checkbox-input">
          Slit Sheet
        </label>
        <div class="c-range">
          <label for="app-move" class="c-label">Move</label>
          <input type="range" name="move" value="0" min="0" max="20" id="app-move">
        </div>
      </div>
    </div>
  </div>
</micro-app>

カスタム要素 <micro-app> で全体を囲っています。大きく分けて「プレビュー」と「フォームコントロール」の 2 つのエリアから構成されています。

アプリのスクリーンショット。「プレビュー」と「フォームコントロール」の 2 つのエリアから構成されている

まず、生成した画像を表示するプレビューエリアの HTML ですが、以下のように 10 個のフレーム(.c-preview-frame)と、その上に重ねるスリットシート(.c-preview-sheet)の要素で構成されています。

プレビューエリアの HTML
<div class="c-preview">
  <div class="c-preview-inner">
    <div class="c-preview-frame" data-app="frame"></div>
    <div class="c-preview-frame" data-app="frame"></div>
    <div class="c-preview-frame" data-app="frame"></div>
    <div class="c-preview-frame" data-app="frame"></div>
    <div class="c-preview-frame" data-app="frame"></div>
    <div class="c-preview-frame" data-app="frame"></div>
    <div class="c-preview-frame" data-app="frame"></div>
    <div class="c-preview-frame" data-app="frame"></div>
    <div class="c-preview-frame" data-app="frame"></div>
    <div class="c-preview-frame" data-app="frame"></div>
    <div class="c-preview-sheet"></div>
  </div>
</div>

各フレームに指定している data-app="frame" は、JS で対象の要素を特定するための属性です。

フォームコントロール

見出し「フォームコントロール」

フォームコントロールの種類としては、以下の要素が含まれています。

ラベル内容typename
UPLOAD画像アップロードfileimage
LEVELマスキングレベルrangelevel
IMAGE SIZE背景画像のサイズrangebackground-size
SLIT SHEETスリットシートの表示checkboxsheet
MOVEスリットシートの位置rangemove

この name 属性の値は、JS で CSS のカスタム変数を指定するときに使用します。

続いて、JS のコードを抜粋します。全体のコード量はそれほど多くはありません。

アプリの JS
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;

  // 初期化
  constructor() {
    super();
  }

  // カスタム要素が追加されたときに実行
  connectedCallback() {
    this.frames.forEach((level, index) => {
      level.style.setProperty('--index', String(index));
    });

    const { signal } = this.controller;

    this.ranges.forEach((range) => {
      range.addEventListener('input', () => {
        this.style.setProperty(`--${range.name}`, range.value);
      }, { signal });
    });

    this.inputFile?.addEventListener('change', () => {
      const files = this.inputFile?.files;

      if (files && files[0]) {
        this.addImage(files[0]);
      }
    }, { signal });
  }

  // カスタム要素が削除されたときに実行
  disconnectedCallback() {
    this.controller.abort();
    this.releaseImageURL();
  }

  // 画像追加
  addImage = (file) => {
    this.releaseImageURL();

    try {
      this.blobURL = URL.createObjectURL(file);
      this.image.src = this.blobURL;
      this.style.setProperty('--background-image', `url(${this.image.src})`);
    } catch (e) {
      console.error(e.message);
    }
  };

  // 画像のオブジェクト URL を解放
  releaseImageURL = () => {
    this.blobURL && URL.revokeObjectURL(this.blobURL);
  };
}
customElements.define('micro-app', MicroApp);

トピックごとに順番に説明していきます。

以下のコードでカスタム要素を定義しています。この指定によって、<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));
  });
  // ...
}

フォームコントロール

見出し「フォームコントロール」

続いて、レンジスライダーと画像アップロードボタンにイベントリスナーを設定しています。

フォームコントロールにイベントリスナーを指定
connectedCallback() {
  // ...
  const { signal } = this.controller;

  // レンジスライダー
  this.ranges.forEach((range) => {
    range.addEventListener('input', () => {
      this.style.setProperty(`--${range.name}`, range.value);
    }, { signal });
  });

  // 画像アップロードボタン
  this.inputFile?.addEventListener('change', () => {
    const files = this.inputFile?.files;

    if (files && files[0]) {
      this.addImage(files[0]);
    }
  }, { signal });
}

レンジスライダーでは入力した内容を、カスタム要素の CSS 変数(カスタムプロパティ)として設定するという手法を取っています。

例えば、「LEVEL」であれば name="level" を設定しているので、レンジスライダーの値を 4 に変更すると、カスタム要素に style="--level: 4" が反映されます。

このように、レンジスライダーを操作したときに、JS でダイレクトにスタイルの値を操作するのではなく、CSS 変数を経由することで、CSS 側で calc() を使用して柔軟に値を調整することができます。加えて、JS 側のコード量は減りますし、その分の処理の負担を軽減できます。

画像関連では、画像を追加するときの addImage() と、画像への参照が不要になったときにメモリを解放するための releaseImageURL() の 2 つのメソッドを定義しています。

画像関連の 2 つのメソッド
// 画像追加
addImage = (file) => {
  this.releaseImageURL();

  try {
    this.blobURL = URL.createObjectURL(file);
    this.image.src = this.blobURL;
    this.style.setProperty('--background-image', `url(${this.image.src})`);
  } catch (e) {
    console.error(e.message);
  }
};

// 画像のオブジェクト URL を解放
releaseImageURL = () => {
  this.blobURL && URL.revokeObjectURL(this.blobURL);
};

最後に CSS のコードを見ていきます。すべてを掲載するとかなり長くなってしまうので、プレビューエリアまでに限定し、フォームコントロールのスタイルは割愛します。

アプリの CSS(一部)
micro-app {
  --level: 1;
  --move: 0;
  --background-image: url('./img-default.webp');
  --text-color: hsl(180 17% 84%);
  --bg-color: hsl(180 78% 7%);

  display: block;
  background-color: var(--bg-color);
  color: var(--text-color);
}

/* プレビュー */
.c-preview {
  --base-size: calc(100% / var(--level));
  --mask-size: calc(var(--base-size) / 10);
  --mask-min-size: 4px;

  padding: 20px;
  background-color: black;
}
.c-preview-inner {
  container-type: inline-size;
  display: grid;
  inline-size: 100%;
  max-inline-size: 400px;
  aspect-ratio: 1;
  margin-inline: auto;
  background-color: var(--bg-color);
}
:is(.c-preview-frame, .c-preview-sheet) {
  grid-column: 1 / -1;
  grid-row: 1 / -1;
}

/* フレーム */
.c-preview-frame {
  --mask-pos: calc(var(--base-size) * var(--index));
  --bg-pos: calc(100cqi / var(--level) * var(--index));

  mask-image: linear-gradient(to right, black var(--base-size), transparent var(--base-size));
  mask-position: var(--mask-pos) 0%;
  mask-size: max(var(--mask-min-size), var(--mask-size)) 100%;
  background-image: var(--background-image);
  background-position: calc(50% + var(--bg-pos)) calc(50% + var(--bg-pos) * -1);
  background-size: calc(var(--background-size, 100) * 1%) auto;
  background-repeat: repeat;
}

/* スリットシート */
.c-preview-sheet {
  mask-image: linear-gradient(to right, transparent var(--base-size), black var(--base-size));
  mask-position: calc(var(--base-size) * var(--move)) 0%;
  mask-size: max(var(--mask-min-size), var(--mask-size)) 100%;
  background-color: black;
  transition: opacity 0.2s ease;
}
micro-app:has([name="sheet"]:not(:checked)) .c-preview-sheet {
  opacity: 0;
}

/* フォームコントロールは割愛 */

それでは順番に説明していきます。

プレビューエリアのレイアウト

見出し「プレビューエリアのレイアウト」

プレビューエリアの各フレーム(.c-preview-frame)とスリットシート(.c-preview-sheet)は、すべて同じサイズ、同じ位置で重ねる必要があるので、親要素に display: grid を指定して同一のグリッドセルに重ねています。

1 つのグリッドセルに要素を重ねる
.c-preview-inner {
  display: grid;
}
:is(.c-preview-frame, .c-preview-sheet) {
  grid-column: 1 / -1;
  grid-row: 1 / -1;
}

各フレームの画像やスリットシートにマスクを適用する方法を見ていきます。

まず、CSS 変数 --base-size ではマスクの基準となるサイズを定義していますが、「LEVEL」のレンジスライダーの値が大きくなると基準サイズが小さくなります。

マスクの基準サイズ
.c-preview {
  --base-size: calc(100% / var(--level));
}

例えば、「LEVEL」の値が 1 であれば 100% / 1 = 100% で、2 であれば 100% / 2 = 50% になります。

続いて、各フレーム(.c-preview-frame)とスリットシート(.c-preview-sheet)に指定している、mask 関連のスタイルに着目します。

mask 関連の CSS を抜粋
.c-preview-frame {
  --mask-pos: calc(var(--base-size) * var(--index));

  mask-image: linear-gradient(to right, black var(--base-size), transparent var(--base-size));
  mask-position: var(--mask-pos) 0%;
  mask-size: calc(var(--base-size) / 10) 100%;
}
.c-preview-sheet {
  mask-image: linear-gradient(to right, transparent var(--base-size), black var(--base-size));
  mask-position: calc(var(--base-size) * var(--move)) 0%;
  mask-size: calc(var(--base-size) / 10) 100%;
}

mask-imagemask-positionmask-size の各プロパティで指定している内容を順番に見ていきましょう。

まず、mask-image では linear-gradient() 関数を使用して --base-size 分のストライプパターンを作成しています。blacktransparent の指定順を逆にすることで、ストライプを交互に表示しています。

mask-image で作成したストライプパターンを交互に指定
.c-preview-frame {
  mask-image: linear-gradient(to right, black var(--base-size), transparent var(--base-size));
}
.c-preview-sheet {
  mask-image: linear-gradient(to right, transparent var(--base-size), black var(--base-size));
}

以下は、上記の mask-image のコードを擬似的に適用したデモです。「SPLIT」や「3D」を選択すると、フレームとスリットシートのレイヤーが分かれるので、互い違いになっていることが視覚的に把握できます。

Live Demo

このデモは、お使いのブラウザでは対応していません 🙇‍♂️

続いて、mask-position ではマスクを開始する水平方向の位置を指定しています。

各フレームは以下のとおり、マスクの基準サイズにフレームのインデックス番号を乗算して、フレームごとにマスクの位置をずらしています。

各フレームのマスクの位置
.c-preview-frame {
  --mask-pos: calc(var(--base-size) * var(--index));

  mask-position: var(--mask-pos) 0%;
}

スリットシートは「MOVE」のレンジスライダーから受け取った --move の値によって、マスクのサイズ分、水平方向に移動しています。

スリットシートのマスクの位置
.c-preview-sheet {
  mask-position: calc(var(--base-size) * var(--move)) 0%;
}

このスリットシートの 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%;
}

続いて、各フレームに指定している背景画像のスタイルについて解説します。

背景画像の CSS を抜粋
micro-app {
  --background-image: url('./img-default.webp');
}
.c-preview-frame {
  --bg-pos: calc(100cqi / var(--level) * var(--index));

  background-image: var(--background-image);
  background-position: calc(50% + var(--bg-pos)) calc(50% + var(--bg-pos) * -1);
  background-size: calc(var(--background-size, 100) * 1%) auto;
  background-repeat: repeat;
}

background-image には、最初はデフォルトの画像が指定されていますが、ファイルをアップロードすると、Blob オブジェクトに変換された画像が反映されます。

micro-app {
  --background-image: url('./img-default.webp');
}
.c-preview-frame {
  background-image: var(--background-image);
}

ちなみに、カスタム要素に style 属性としてデフォルト画像を指定することもできるので、コンポーネントごとにデフォルト画像を変えたい場合にはこの方法を使います。

HTML 側でデフォルト画像を指定
<micro-app style="--background-image: url('./img-default.webp');">
  <!-- ... -->
</micro-app>

background-position

この見出しのリンク

background-position は、冒頭の図版で説明したように、アニメーションを表現するために画像を段階的に右上に移動している指定です。

フレームの中心を起点に、--bg-pos の分だけ移動させていますが、この --bg-pos には、フレームのインデックス番号である --index を乗算しているので、フレームごとに位置が変わります。

背景位置を移動することでアニメーションを表現
.c-preview-frame {
  --bg-pos: calc(100cqi / var(--level) * var(--index));

  background-position: calc(50% + var(--bg-pos)) calc(50% + var(--bg-pos) * -1);
}

ちなみに、ここで指定している 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 側で柔軟に値をコントロールできることも、特におさえておきたいポイントです。

実際のデモでは、画像ファイルをドラッグ & ドロップでアップロードする機能や、プレビューエリアの画像をドラッグして自由に移動できる機能も実装していますので試してみてください。

Live Demo

本アプリはお使いのブラウザには対応していません 🙇‍♂️

Image

2
20
0

本アプリは、当初 CSS でスキャニメーションを実現させるプロジェクトのプロトタイプとして作成したものでしたが、記事のテーマに合致していたため調整して使うことにしました。

ここからさらに拡張するとすれば、複数画像を登録できるようにして本来のスキャニメーションを実現させたり、マスクの向きを変更できるようにしたり、スリットシートのカラーやブレンドモードを変更できるようにしたりと、まだまだ可能性は広がりそうです。