GODRICH

スクロールテリング 作り方 9選 — コピペOKのCSS

スクロールテリングとは、スクロール位置を時計の代わりに使い、ひとつの区間の中で場面を順番に切り替える組み立て方です。ここにある9つは効果の違いではなく、場面の組み立て方そのものが違います。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は人気順ではなく、ひとつの話が流れていく順番です。まず舞台を立てます。図版を1枚その場に留めて文章を流すか(01)、背景ごと留めて文章カードだけを通すか(02)です。舞台が立ったら、その中で時間を回します。ひとつの場面を8コマに切ってスクロール量の分だけ巻き(03)、2つの場面を重ねて境目を押して入れ替えます(04)。次は数字と物です。バーが伸びて数値を見せ(05)、パーツが散らばっては集まり、中身を見せます(06)。最後の3つは読者を外へ連れ出します。どこを通ってきたかを地図で見せ(07)、章をめくって次の話へつなぎ(08)、読み終わった場所でだけ次の行動を勧めます(09)。9つとも自動再生のプレビューを使っていますが、指が触れた瞬間にそのループを外し、本物のスクロールに操作を渡します。

01固定図版とステップ文

画面を2つに割り、左の図版だけをその場に留めたまま、右の段落が上がっていきます。2枚が半透明に重なる中間フレームを作らないことで、「説明が切り替わった」ことがはっきり伝わります。切り替えの位置は段落が画面の中央を通る地点なので、段落の高さ96pxと画面の高さ176pxから計算した28%と76%がキーフレームの位置になります。

position: stickysteps(1, end)grid-template-columns
.sf__figure {
  position: sticky;
  top: 0;
  align-self: start;
  display: grid;
  height: 104px;
}
.sf__panel {
  grid-area: 1 / 1;
  animation-name: sf-p1;
  animation-duration: 1s;
  animation-timing-function: steps(1, end);
  animation-fill-mode: both;
  animation-timeline: scroll(nearest block);
}
.sf__panel--2 { animation-name: sf-p2; }
@keyframes sf-p1 { 0% { opacity: 1 } 28% { opacity: 0 } 100% { opacity: 0 } }
@keyframes sf-p2 { 0% { opacity: 0 } 28% { opacity: 1 } 76% { opacity: 0 } 100% { opacity: 0 } }

02固定背景を通るキャプション

背景の場面はまったく動かず、半透明のカードだけが下から上がって上へ抜けていきます。覆われた分だけ背景が暗くなるよう、オーバーレイ1枚の不透明度をスクロールの進み具合に直接つないでいます。カードが背景の全面を通り過ぎるため、変化したピクセルは画面の43.372%と9つの中で最も大きくなりました。

position: stickybackdrop-filtertranslateY
.cb__scroller {
  height: var(--h);
  overflow-y: auto;
  scroll-timeline-name: --cb-scroll;
  scroll-timeline-axis: block;
}
.cb__bg {
  position: sticky;
  top: 0;
  height: var(--h);
  overflow: hidden;
}
.cb__dim {
  position: absolute;
  inset: 0;
  animation-name: cb-dim;
  animation-fill-mode: both;
  animation-timeline: --cb-scroll;
}
@keyframes cb-dim { from { opacity: 0 } to { opacity: .46 } }

03フレームシーケンス スクラブ

8コマを一列につないだ帯をスクロール量の分だけ横に送ると、動画をスクラブしているのと同じ画面になります。steps(8, jump-none) は0と1の両方を含むので、8コマがちょうど1回ずつ止まり、中途半端な位置が出ません。コマの中身はアニメーションではなく絵柄そのものなので、ふたの角度をコマ番号に掛けて静的に描いてあります。

scroll-timeline-namesteps(8, jump-none)translateX
.sp__window {
  width: var(--frame);
  height: 104px;
  overflow: hidden;
}
.sp__strip {
  display: flex;
  width: max-content;
  animation-name: sp-scrub;
  animation-timing-function: steps(8, jump-none);
  animation-fill-mode: both;
  animation-timeline: --sp-scroll;
}
@keyframes sp-scrub {
  from { transform: translateX(0); }
  to   { transform: translateX(-87.5%); }
}
.sp__lid { transform: rotate(calc(var(--i) * -14deg)); }

04ビフォーアフター ワイプ

同じ位置に2つの場面を重ね、上の層だけを右から切り取ると、下の層がその分だけ現れます。切り取りは clip-path: inset() の右の値ひとつだけで、ハンドルの線は同じタイムラインにつないであるので切り口からずれません。プレビューの曲線だけ真ん中を最も速くして、代表画像が半分ほど切り取られた場面から選ばれるようにしています。

clip-path: insetview-timeline-nametranslateX
.bw__frame {
  view-timeline-name: --bw-view;
  view-timeline-axis: block;
  position: relative;
  overflow: hidden;
}
.bw__layer--before {
  clip-path: inset(0 0 0 0);
  animation-name: bw-cut;
  animation-fill-mode: both;
  animation-timeline: --bw-view;
}
@keyframes bw-cut {
  from { clip-path: inset(0 0 0 0); }
  to   { clip-path: inset(0 100% 0 0); }
}
@keyframes bw-handle {
  from { transform: translateX(calc(var(--fw) - 4px)); }
  to   { transform: translateX(0); }
}

05入ると伸びるバー

5本のバーが床に押しつぶされていて、区間が画面に入った分だけ伸びます。高さを直接変えるのではなく、底を軸に潰しておいて戻すだけなので、レイアウトの再計算が起きません。時間差は遅延ではなく、バーごとに animation-range を少しずつずらして作りました。スクロールのタイムラインには時間の概念がないので、遅延を指定しても意味がないからです。

animation-timeline: view()scaleYtransform-origin
.db__chart {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  align-items: end;
  height: 96px;
}
.db__bar {
  transform: scaleY(.04);
  transform-origin: bottom;
  animation-name: db-grow-in;
  animation-fill-mode: both;
  animation-timeline: view(block);
}
.db__col:nth-child(1) .db__bar { height: 34px; animation-range: entry 8%  cover 38%; }
.db__col:nth-child(5) .db__bar { height: 80px; animation-range: entry 32% cover 62%; }
@keyframes db-grow-in {
  from { transform: scaleY(.04); }
  to   { transform: scaleY(1); }
}

06パーツ分解・組み立て

4枚重なった板が四方に開いて中身を見せ、また一か所に集まります。板ごとに行き先が違うのでキーフレームを4つに分け、名前のラベルは板が十分に開いてから現れるので文字が重なりません。8つのアニメーションがひとつのタイムラインにつながっているため、どの位置で止めても1枚の分解図になります。

animation-timeline: scroll()translaterotate
.ex__part {
  position: absolute;
  top: 48px;
  animation-duration: 1s;
  animation-fill-mode: both;
  animation-timeline: scroll(nearest block);
}
.ex__part--1 { animation-name: ex-p1; }
.ex__part--4 { animation-name: ex-p4; }
@keyframes ex-p1 {
  from { transform: translate(0, -6px) rotate(0deg); }
  to   { transform: translate(-16px, -54px) rotate(-7deg); }
}
@keyframes ex-p4 {
  from { transform: translate(0, 6px) rotate(0deg); }
  to   { transform: translate(16px, 54px) rotate(7deg); }
}
@keyframes ex-tag { 0%, 10% { opacity: 0 } 38%, 100% { opacity: 1 } }

07ルートマップの描画

薄い破線の上に濃い線が引かれ、同じ曲線をマーカーが走ります。pathLength="100" を書いておけば線の実際の長さを知らなくてよく、stroke-dashoffset を100から0にするだけで済みます。マーカーは同じ曲線を offset-path で受け取って走るので、線とマーカーがずれることがありません。

stroke-dashoffsetoffset-pathoffset-distance
.rm__line {
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
  animation-name: rm-draw;
  animation-fill-mode: both;
  animation-timeline: scroll(nearest block);
}
@keyframes rm-draw {
  from { stroke-dashoffset: 100; }
  to   { stroke-dashoffset: 0; }
}
.rm__mark {
  offset-path: path("M20 96C62 96 58 34 100 34C142 34 138 92 180 92C206 92 214 60 228 36");
  offset-distance: 0%;
  offset-rotate: 0deg;
  animation-name: rm-run;
  animation-timeline: scroll(nearest block);
}
@keyframes rm-run { from { offset-distance: 0% } to { offset-distance: 100% } }

08チャプターのページめくり

左端を軸に紙が1枚めくれて次の面が現れます。回転角をスクロールの進み具合に等速でつないであるのでスクロール量とめくれ具合が1対1になり、裏側を隠してあるので表の文字が透けて重なりません。紙1枚が反対側の半分を丸ごと覆うため、1フレームあたりの変化量の最大値は21.014%と9つの中で最も大きくなりました。

rotateYbackface-visibilitysteps(1, end)
.pt__page {
  position: absolute;
  right: 0;
  width: 120px;
  transform-origin: left center;
  transform-style: preserve-3d;
  animation-name: pt-turn;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: scroll(nearest block);
}
@keyframes pt-turn {
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(-180deg); }
}
.pt__face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
}
.pt__face--back { transform: rotateY(180deg); }

09最後に現れるCTA

本文を読んでいる間は置き場所さえなく、最後の区間が画面に入った分だけ下のバーが上がってきます。区間そのものが名前付きのビュータイムラインを持ち、その中に入っているバーが進み具合を受け取るので、要素が画面に入ったかを調べるコードは1行も要りません。中のボタンは animation-range を24%から始めて、一拍遅れて大きくなります。

view-timeline-nameposition: stickytranslateY
.ec__end {
  display: flex;
  align-items: flex-end;
  height: var(--h);
  view-timeline-name: --ec-end;
  view-timeline-axis: block;
}
.ec__bar {
  position: sticky;
  bottom: $sp-2;
  transform: translateY(140%);
  animation-name: ec-rise;
  animation-fill-mode: both;
  animation-timeline: --ec-end;
  animation-range: entry 0% entry 80%;
}
@keyframes ec-rise {
  from { transform: translateY(140%); }
  to   { transform: translateY(0); }
}

どこで壊れるか — 落とし穴ひとつ

この回の落とし穴は、つないだつもりの場所にタイムラインがつながっていないことでした。scroll(nearest) が選ぶ「最も近いスクロールコンテナ」には overflow: hidden の箱も入ります。03の帯はコマを切り出して見せる窓の中にあり、02のオーバーレイは背景を閉じ込める箱の中にありました。どちらの箱もスクロールが0から0までなので、宣言は正しいのに進み具合がいつまでも0のままです。コンパイルも通り、コンソールも静かなので、「なぜ何も起きないのか」だけが残ります。view() も同じ規則を使うため、04の上の層は自分を包む箱をスクロールポートとして拾っていました。3つとも scroll-timeline-nameview-timeline-name で名前を付けて直しました。名前を付けたタイムラインは、その要素の子孫であればどこからでも拾えます。

つながる場所の問題は position: sticky でもう一度出ました。03・06・07・08は窓を留めてその下を空けてスクロールの余裕を作るのですが、最初はその余裕を親の padding-bottom で与えていました。留まっていられる範囲は親のコンテンツボックスなので、パディングで伸ばした場所は計算に入りません。窓は留まるどころかすぐ上へ流れていきましたが、プレビューのループは正常だったので、実際にスクロールしてみるまで気づきませんでした。余裕を height に変えたら4つが一度に直りました。

プレビューと本物のスクロールが別々のコードを通ることも落とし穴になりました。05のバーはループ用のキーフレームが伸びきったあとまた潰れるようになっていて、そのキーフレームをそのままスクロールにつないだところ、読み終えてさらにスクロールするとグラフが平らになりましたanimation-fill-modebackwards にしていたことも重なっていました。区間が終わったあとを埋めないので、バーが記述どおりの状態に戻ってしまいます。そこでスクロール版は db-grow-in という一方向のキーフレームと both を使い、ループ版は元のキーフレームを使うように分けました。09は逆でした。バーは position: sticky で留まるのに、プレビューでは誰もスクロールしないので sticky が効く場面がなく、撮影した24フレームのどれにも写りませんでした。プレビューの間だけ絶対配置で立てておくことにしました。9つとも .is-demo を外した状態で0%・50%・100%を別々に撮ってみて、はじめてこの3つに気づきました。

ブラウザー対応は落とし穴ではなく条件です。スクロールのタイムラインは2026年9月時点で Chrome・Edge 115以上、Safari 26以上、Firefox 158以上で動き(caniuse の集計では全世界87.22%)、それより前のバージョンでは宣言が丸ごと無視されます。無視されると要素は記述どおりの状態のまま残るので、記述どおりの状態を「見えない状態」にしておくと古いブラウザーで内容が消えます。JavaScript のフォールバックは入れていません。代わりに9つとも、スクロールのタイムラインがなくても読めるように書いてあります。01・02は position: sticky だけで配置が決まり、05のバーは高さが本来の値のままで、潰れた状態が解けないだけです。

アクセシビリティ

prefers-reduced-motion: reduce では9つとも演出を止めて結果を残します。何を残すかは項目ごとに違います。

項目 止めるもの 残るもの
01 固定図版 図版の切り替え・段落の送り 最初の図版と3つの段落すべて
02 固定背景 背景が暗くなること 背景の場面とキャプション3枚
03 コマ送り 帯の移動 6コマ目が1枚
04 ワイプ 境目の移動 半分ずつ分かれた前と後の場面
05 伸びるバー 伸びる動き 伸びきったバー5本
06 パーツ分解 開くこと・ラベルの登場 開いた分解図とラベル
07 ルートマップ 線の描画・マーカーの移動 引き終わった経路と点灯した地点
08 ページめくり 回転 最初の面と半分まで進んだバー
09 最後のCTA せり上がり・ボタンの拡大 上がりきったバーとボタン

表の「残るもの」がそのまま静止状態になります。その状態が画面として成り立っているかを、9つとも別々に撮って確かめました。05が最初 scaleY(.04) で潰れたまま残り、空のグラフになっていたのもこの確認で見つかりました。3言語版と React 版まで同じ値をそろえた9組のソースは、圧縮パスワード rgaqtmah を入れると開く zip の中にあります。

読む順番もそのままです。9つのどこにも内容を横取りするスクロールジャックがなく、すべての文字が文書の順番どおりに残っているので、スクリーンリーダーはアニメーションと関係なく上から下へ読みます。飾りの図形とアイコンには aria-hidden を付けて読み上げから外しました。プロパティーごとの対応範囲と値の意味は MDN の animation-timeline の解説にまとまっています。

スクロール系をもっと探すなら、ホームページのスクロール効果9選読み進み表示9選が隣にあります。

FAQ

スクロールのタイムラインが効かないブラウザーではどう見えますか

宣言が無視され、要素は記述どおりの状態で描かれます。そのため、記述どおりの状態を「完成した姿」に近づけておくことがフォールバックになります。05のバーは高さを本来の値で書き、transform だけで潰してあるので、潰れが解けなくても枠とラベルは所定の位置にあります。逆に opacity: 0display: none で隠しておくと、対応していないブラウザーで内容が丸ごと消えるため、そういう書き方はしていません。

animation-timeline: scroll()view() は何が違いますか

scroll() はスクロールコンテナが上から下までどれだけ転がったかを0から1で見ます。区間全体をひとつの進み具合として使いたいとき、つまり01・02・03・06・07・08のように窓を留めてその中を転がすときに使います。view()その要素自身が画面にどれだけ入ったかを見ます。04・05・09のように「これが見え始めたら」が条件のときに使います。view() の側は animation-rangeentrycover といった区間名を使い、いつからいつまでかを別に決めます。

プレビューのループと本物のスクロール動作をなぜ別々に書くのですか

グリッドに埋め込まれるデモは iframe の中にあり、誰もスクロールしないからです。スクロールが0なら、スクロールのタイムラインの進み具合も0になり、画面が止まった絵になります。そこで9つとも .is-demo クラスが付いている間だけ時間軸で回し、指が触れるか実際にスクロールされた瞬間にそのクラスを外してスクロールのタイムラインに渡します。01〜04と06は同じキーフレームをそのまま使い、05・07・08・09だけプレビュー用のキーフレームを別に持たせています。渡すときに画面が飛ばない程度に2つの経路を近づけておくのがコツです。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。