写真に重ねて綴った言葉を読ませるテンプレートに載せたときの記録です。動き自体は JavaScript を使わず CSS だけで組んでいます。
「声から」というテンプレートがあります。真っ暗な画面に言葉だけが浮かび、読み終えるにつれて写真が明るくなります。スクロール連動のアニメーションだけで組んであります。
作った時は写真1枚に言葉が1つの前提だった。そこへ「1枚に何度でも重ねて書ける」が入ってきた。
積む形は2つの理由で捨てた
最初は1画面の中に縦に積む形で書いた。実データで見たら 785px になって、画面(720px)を溢れた。
もう1つは、テンプレートの説明文がこうだったこと。
暗がりに声がひとつ。読み終えると、写真が満ちる。
積むと名前が嘘になる。書いた本人が付けた名前なので、こちらを曲げるのは筋が違う。声が1つずつ聞こえる形にした。
比率で割ると早口になる
素直に実装するなら、既存のスクロール区間を声の数で割ればいいです。改修前はこうなっていた。
.mem {
/* svh = 画面の高さ1つぶん。320svh は画面3.2個ぶんの縦長ということ */
height: 320svh; /* スクロールの長さ */
/* この要素が画面を通り過ぎる進み具合を、--mem という名前のものさしにする */
view-timeline: --mem block;
}
.words {
/* both = 出番の区間の外では、端のキーフレームの見た目のまま止まる */
animation: cWords auto linear both;
animation-timeline: --mem; /* 時間ではなく上の --mem を再生ヘッドに使う */
animation-range: cover 0% cover 76%; /* 言葉が出て、消えるまで */
}これを声の数で割ると、動きはします。
読む速さは変わらないのに、画面の進みだけ3倍速くなるということ。声が増えるほど早口になっていきます。
欲しいのは逆で、1つの声あたりの距離を一定に保ちたい。だったら割るのではなく、全体を伸ばす。
animation-range は calc と CSS 変数を受ける
先に確かめた。ブラウザの開発者ツールのコンソールに直接打ち込んだだけで、製品のコードには入っていない。
// 使い捨ての div を作って、変数入りの animation-range を実際に当ててみる
const el = document.createElement('div');
el.style.cssText = '--i:1; --n:3; animation-range: cover calc(76% / var(--n) * var(--i)) cover calc(76% / var(--n) * (var(--i) + 1));';
document.body.appendChild(el); // ページに入れないとブラウザが計算してくれない
// getComputedStyle = ブラウザが計算し終えた「後」の値を読む。76% / 3 * 1 が数字で返れば成功
getComputedStyle(el).animationRangeStart; // "cover 25.3333%"解決された。パーセントを数値で割るのも通ります。
実装
要素の数を CSS 変数で渡し、高さと区間の両方をそこから計算します。式そのものは上の方針を渡して AI に書いてもらった。自分が決めたのは「割らずに伸ばす」ことと、写真との前後関係を崩さないことの2点だ。
// 声が何個あるかを --voices で CSS へ渡す。as CSSProperties は
// 「-- で始まる独自の変数名も許して」と TypeScript に断るための型注記
<section className={styles.mem} style={{ "--voices": blocks.length } as CSSProperties}>
{blocks.map((block, k) => (
// --i = この声が何番目か(0 始まり)。CSS はこれで自分の持ち分を決める
<div key={k} className={styles.words} style={{ "--i": k } as CSSProperties}>
...
</div>
))}
<div className={styles.ph}>{/* 写真 */}</div>
</section>.mem {
/* 声1つあたり 320svh。声が増えたら距離のほうを伸ばす。
あとで割り算に使うので、ここでは単位を付けずに数値だけ持つ */
--mem-h: calc(320 * var(--voices, 1));
/* cover の全行程 = 要素の高さ + ビューポート1つぶん(= +100)。
その全行程のうち「最後の声が消え終わる位置」が何%かを出している */
--voice-end: calc((var(--mem-h) - 1) / (var(--mem-h) + 100) * 100%);
/* それを声の数で割った、声1つぶんの持ち分 */
--step: calc(var(--voice-end) / var(--voices, 1));
height: calc(var(--mem-h) * 1svh); /* ここで初めて svh を掛けて実際の長さにする */
view-timeline: --mem block;
}
.words {
/* 声はすべて画面の上端に張り付いたまま、その場で出番を待つ */
position: sticky;
top: 0;
height: 100svh;
animation: cWords auto linear both;
animation-timeline: --mem;
/* k 番目の声は「持ち分 k 個ぶん進んだところ」から「k+1 個ぶん」までが出番 */
animation-range:
cover calc(var(--step) * var(--i, 0))
cover calc(var(--step) * (var(--i, 0) + 1));
}実物はこれを @supports (animation-timeline: view()) と prefers-reduced-motion: no-preference の中に入れてあります。sticky と負マージンを外に出すと、未対応ブラウザで写真が言葉に永久に重なってしまいます。
声はすべて画面の上端に張り付き、自分の持ち分(--step)の間だけ浮かびます。持ち分の外では上の both が端のキーフレーム(透明度0)を保つので、要素としては全部重なっていても見えるのは1つだけ。
数値で確かめると、声が1つのときは --voice-end が 75.95%、--step も同じ。改修前に手で書いていた cover 0% cover 76% とほぼ一致する。既存の見え方を壊していないことが、式のほうから確認できた。
一番気を使ったのは、写真との重なり
このテンプレートには元から守るべき不等式があった。写真が明るくなり始めるのは、言葉が消え始めた後でなければいけません。逆になると、文字と人の顔が同じ明るさで重なって読めなくなります。
写真の区間も同じ変数から作って、相対関係ごとスケールさせた。
.phImg {
animation-range:
/* 開始 = 最後の声の出番の頭(持ち分 × (声の数 - 1))から、さらに持ち分の 10.5% ぶん進んだところ */
cover calc(var(--step) * (var(--voices, 1) - 1) + var(--step) * 0.105)
/* 終了 = 最後の声が消え終わったあと、持ち分の 7.9% ぶん遅れて */
cover calc(var(--voice-end) + var(--step) * 0.079);
}係数の 0.105 と 0.079 は改修前の実測値から出した(8 / 75.95 = 0.105、(82 - 75.95) / 75.95 = 0.079)。
ブラウザで測って確認した。数字は cover の全行程に対する%で、左より右が大きければ順番が守れています。
| 声の数 | 最後の声が消え始める | そのあと写真が明るみ始める | 順番 |
|---|---|---|---|
| 1つ | 44.05% | 53.84% | OK(写真のほうが後) |
| 3つ | 77.81% | 81.69% | OK(写真のほうが後) |
この検算は CSS を読んでいるだけではできなかった。getComputedStyle で animation-range-start / animation-range-end を読むと、calc の入れ子が全部数字になって出てくるので、式が合っているかを目でなく数字で確かめられます。
ただし読む先は選びます。--step のような自前の変数をそのまま読んでも、数字にはなりません。
getComputedStyle(mem).getPropertyValue('--step');
// "calc(calc((calc(320 * 3) - 1) / (calc(320 * 3) + 100) * 100%) / 3)"
getComputedStyle(words).animationRangeStart;
// "cover 60.3145%"型を宣言していないカスタムプロパティだから、--step は var() を差し込んだ文字列のまま止まります。
学んだこと
animation-rangeが CSS 変数と calc を受けるのは、今回やってみるまで知らなかった。要素の数に応じてスクロール区間を配れます- 「増えたとき」をどう扱うかで見え方が変わります。割ると1つあたりの密度が上がって早口になるので、読ませたいものなら伸ばすほうだった
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。