紡Book の勉強ノートに戻る
技術ブログ2026.07.28

スクロール連動のアニメーションを、要素の数だけ伸ばす — animation-range に CSS 変数を入れる

CSSアニメーションscroll-driven紡Book

写真に重ねて綴った言葉を読ませるテンプレートに載せたときの記録です。動き自体は JavaScript を使わず CSS だけで組んでいます。

「声から」というテンプレートがあります。真っ暗な画面に言葉だけが浮かび、読み終えるにつれて写真が明るくなります。スクロール連動のアニメーションだけで組んであります。

作った時は写真1枚に言葉が1つの前提だった。そこへ「1枚に何度でも重ねて書ける」が入ってきた。

積む形は2つの理由で捨てた

最初は1画面の中に縦に積む形で書いた。実データで見たら 785px になって、画面(720px)を溢れた。

もう1つは、テンプレートの説明文がこうだったこと。

暗がりに声がひとつ。読み終えると、写真が満ちる。

積むと名前が嘘になる。書いた本人が付けた名前なので、こちらを曲げるのは筋が違う。声が1つずつ聞こえる形にした。

比率で割ると早口になる

素直に実装するなら、既存のスクロール区間を声の数で割ればいいです。改修前はこうなっていた。

css
.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つの声あたりの距離を一定に保ちたい。だったら割るのではなく、全体を伸ばす。

声3つ。区間はブラウザが描くときに計算する。保存しない。割る(高さ 320svh のまま)声1声2声3余り0%25.3%50.7%76%1声が3分の1伸ばす(高さ 320svh × 3 = 960svh)声1320svh声2320svh声3320svh割ると総距離は同じで、1声にかけられるスクロールだけ短くなる。伸ばすと1声の距離は、声が1つのときの 320svh のまま。

animation-range は calc と CSS 変数を受ける

先に確かめた。ブラウザの開発者ツールのコンソールに直接打ち込んだだけで、製品のコードには入っていない。

js
// 使い捨ての 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点だ。

tsx
// 声が何個あるかを --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>
css
.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% とほぼ一致する。既存の見え方を壊していないことが、式のほうから確認できた。

一番気を使ったのは、写真との重なり

このテンプレートには元から守るべき不等式があった。写真が明るくなり始めるのは、言葉が消え始めた後でなければいけません。逆になると、文字と人の顔が同じ明るさで重なって読めなくなります。

写真の区間も同じ変数から作って、相対関係ごとスケールさせた。

css
.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)。

声3つ。cover 全行程に対する%。数字はブラウザが計算した animation-range から。保存しない。声1声2声3消え始め 77.81%写真が明るむ81.69%0%100%消え始めより右で写真が明るむ。どちらも最後の声の区間の中。

ブラウザで測って確認した。数字は cover の全行程に対する%で、左より右が大きければ順番が守れています。

声の数 最後の声が消え始める そのあと写真が明るみ始める 順番
1つ 44.05% 53.84% OK(写真のほうが後)
3つ 77.81% 81.69% OK(写真のほうが後)

この検算は CSS を読んでいるだけではできなかった。getComputedStyle で animation-range-start / animation-range-end を読むと、calc の入れ子が全部数字になって出てくるので、式が合っているかを目でなく数字で確かめられます。

ただし読む先は選びます。--step のような自前の変数をそのまま読んでも、数字にはなりません。

js
getComputedStyle(mem).getPropertyValue('--step');
// "calc(calc((calc(320 * 3) - 1) / (calc(320 * 3) + 100) * 100%) / 3)"
 
getComputedStyle(words).animationRangeStart;
// "cover 60.3145%"
同じ要素の上。描画のたびにブラウザが計算する。保存しない。style 属性--voices と --iJSX が数字を1個渡す開いている間のメモリカスタムプロパティ--step などcalc の入れ子のまま数字にはならないanimation-rangecover 60.3145%普通のプロパティここで数字になるDB にもファイルにも残らない。読む先を選ばないと、左の文字列のまま止まる。

型を宣言していないカスタムプロパティだから、--step は var() を差し込んだ文字列のまま止まります。

学んだこと

  • animation-range が CSS 変数と calc を受けるのは、今回やってみるまで知らなかった。要素の数に応じてスクロール区間を配れます
  • 「増えたとき」をどう扱うかで見え方が変わります。割ると1つあたりの密度が上がって早口になるので、読ませたいものなら伸ばすほうだった

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。