GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

ファーストビュー アニメーション css 9選 — 統計カウンターまで

ファーストビュー アニメーション cssは、9つのうち1つはカードの見える比率の値が基準を超えるたびに順番の値に間隔を掛けて現れる時点を求め直し、1つは開いた質問の回答の高さの値を内容の長さから求め直して広げ、もう1つは目標の数字までの値をフレームごとに計算し、桁が増える瞬間に新しい桁を一緒に描きます。

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

9つのセクションに表示される価格・口コミ・数値・ロゴはすべて架空のもので、掲載されるブランド名はBRAND A〜BRAND Dのようなプレースホルダーです。実在する企業名も実在する人物の写真も、実際の価格・分析APIへの通信も一切ありません。実際の操作に反応するのは9つのうち4つだけで、03(請求周期の切り替え)と05(FAQの質問)は実際のclickに、04(口コミカルーセル)は実際のpointerenter/pointerleaveで自動再生が止まり、07(フッターのリンク)も同じ2つのポインターイベントで下線の幅を求め直します。残りの01・02・06・08・09の5つは、固定のCSSループか、ページ読み込み時に一度だけ動くrequestAnimationFrameだけで再生され、クリック・ドラッグ・ポインターのリスナーはどこにも付いていません。

ランディングセクションの解剖 — 9つのセクションが計算するもの

9つのセクションは、スクロール位置・状態の切り替わり・経過時間のいずれかをブラウザが動かせる数値に変える3つの方法に分かれます。この計算層を省くと、スマホ ファーストビュー デザインであっても何にも反応しないスライドショーのように見えてしまいます。

なぜ3つのセクションは状態の切り替わりを待たずに値を増やし続けるのか

01・06・08は、スクロールや時間の入力だけで動き続ける値を計算し直します。01の--svカスタムプロパティは疑似的なスクロール比率を追いかけ、背景レイヤーと見出しレイヤーで異なる係数を掛けて位置を求め直し、06の--angleカスタムプロパティはバナーが視界に入ると増え続けて円錐グラデーションの枠が回転しているように見せ、08は横方向のオフセットの値を増やし続けて複製したロゴの列を途切れなくループさせます。

なぜ3つのセクションは真偽の値や順番の値が切り替わるまで計算をしないのか

02・03・05は何かが切り替わるまでじっとしています。02はカードの見える比率の値が基準を超えるまで待ってから、そのカードの順番の値に固定の間隔を掛けて出現時点を求め直し、03は請求周期の状態が切り替わるまで待ってから2つの事前計算済みの価格をクロスフェードしつつ強調カードの拡大率の値を求め直し、05は質問の開閉状態の値が切り替わるまで待ってから回答の実際の高さを求め直します。

なぜ3つのセクションは測定した値をほぼそのまま使うのか

04・07・09は、状態フラグではなく測定や経過時間から数値を取り出します。04はタイマーで自動再生の値を増やしてカルーセルの位置を補間し、実際のポインターが乗ると増加を止め、07はホバーしたリンクの実際の描画幅をgetBoundingClientRect()で測定してから下線をその値まで伸ばし、09は経過時間の比率を毎フレームイージング関数にかけてカウンターの表示数字を目標値まで求め直します。

セクション別コード

01ヒーロー — スクロール値に応じて背景と文字が異なる比率でずれる導入部

ページのスクロール値に応じて背景画像と見出し文字が異なる比率で位置を変え、二つの層のずれから奥行きが生まれます。

ヒーロー — スクロール値に応じて背景と文字が異なる比率でずれる導入部
新しいタブで開く

01 · ヒーロー — スクロール値に応じて背景と文字が異なる比率でずれる導入部 — 計測 累積面積 17.827% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--svtranslateY@property

数値が示すこと

23フレームの計測のうち20フレームでヒーローが変化し、累積面積は17.827%、平均強度は98.9です。背景レイヤーと見出しレイヤーの両方がループの毎段階で動くためです。--svは<number>型のカスタムプロパティなのでcalc()で直接倍率をかけられ、背景は1あたり-18px、見出しはより大きい-42pxを掛けるため、2つのレイヤーが1枚の平面ではなく別々の奥行きとして見えます。

@property --sv {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

.stage {
  --sv: 0;
  position: relative;
  overflow: hidden;
  animation: scroll-sim $duration $easing infinite;
}

.hero__bg {
  position: absolute;
  inset: -10% -10% -30% -10%;
  border-radius: $r-card;
  background: linear-gradient(180deg, rgba($color, .28), rgba($color, 0));
  transform: translateY(calc(var(--sv) * -18px));
}

02機能カードグリッド — 見える比率の値で順に現れるカード

各カードが画面に見える比率の値が基準を超えた状態になると、カードの順番の値に間隔を掛けた時点から順に現れます。

機能カードグリッド — 見える比率の値で順に現れるカード
新しいタブで開く

02 · 機能カードグリッド — 見える比率の値で順に現れるカード — 計測 累積面積 57.951% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--ianimation-delaytranslateY

数値が示すこと

23フレームの計測のうち12フレームでグリッドが変化し、累積面積は57.951%(9項目中最大)、平均強度は37.3です。グリッド全体のカードが揃って動くため、細い要素が1つだけ動く項目より面積が大きくなります。各カードはインラインの--iカスタムプロパティに自分の順番の値を持ち、animation-delay: calc(var(--i) * #{$stagger-step})がその値を1枚あたり120msの遅延に変換します。この定数は今回の1つ目のバリエーションで280msまで広げられます。

.is-demo .card {
  animation: card-in $duration $easing both;
  animation-delay: calc(var(--i) * #{$stagger-step});
}

@keyframes card-in {
  0% { opacity: 0; transform: translateY(14px); }
  100% { opacity: 1; transform: translateY(0); }
}

03料金プランカード — おすすめだけ拡大率の値が大きく計算されるリスト

おすすめ料金プランのカードは周囲より拡大率の値が大きく計算されて浮いて見え、請求周期が変わった状態になると表示金額がその値まで再計算されます。

料金プランカード — おすすめだけ拡大率の値が大きく計算されるリスト
新しいタブで開く

03 · 料金プランカード — おすすめだけ拡大率の値が大きく計算されるリスト — 計測 累積面積 5.337% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

classList.togglenth-child(2)steps(1, end)

数値が示すこと

23フレームの計測のうち9フレームで料金プランのリストが変化し、累積面積は5.337%、平均強度は56.8です。動くのは強調カードの拡大率の値とクロスフェードする価格のペアだけだからです。自動再生ではなく実際のclickリスナーが付いた請求周期ボタンを押すとis-yearlyクラスが切り替わり、表示する価格の値と強調カードの拡大率の値が同時に求め直されます。この拡大率は今回のzipでは1.08を採用しており、1.16はバリエーションの値です。

// 請求周期のボタンを押すと料金プランの状態が年額または月額になり、その状態に応じて画面に表示する金額の値を再計算する。
// 計算レイヤー: 請求周期の状態 -> 表示金額の値を再計算。実際のクリックリスナーあり。
(function () {
  var root = document.getElementById('pricing');
  var mBtn = document.getElementById('btn-monthly');
  var yBtn = document.getElementById('btn-yearly');
  if (!root || !mBtn || !yBtn) return;

  function setYearly(yearly) {
    root.classList.remove('is-demo');
    root.classList.toggle('is-yearly', yearly);
    mBtn.classList.toggle('is-active', !yearly);
    yBtn.classList.toggle('is-active', yearly);
    mBtn.setAttribute('aria-pressed', String(!yearly));
    yBtn.setAttribute('aria-pressed', String(yearly));
  }

  mBtn.addEventListener('click', function () { setYearly(false); });
  yBtn.addEventListener('click', function () { setYearly(true); });
})();

04口コミカルーセル — 自動再生の値がタイマーで増えるカードの列

口コミカードは自動再生の値が一定時間ごとに増え、位置が次のカードまで補間されて移り、ポインターが乗った状態になるとその値の増加が止まります。

口コミカルーセル — 自動再生の値がタイマーで増えるカードの列
新しいタブで開く

04 · 口コミカルーセル — 自動再生の値がタイマーで増えるカードの列 — 計測 累積面積 2.61% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

setIntervaltranslateXclearInterval

数値が示すこと

23フレームの計測のうち8フレームでカルーセルが変化し、累積面積は2.61%、平均強度は124.2です。セクション全体ではなく、トラックのうちカード1枚分だけが動くためです。window.setInterval(advance, 2600)が2.6秒ごとに順番の値を増やし、render()がその値をtranslateXの位置に変換します。実際のpointerenterリスナーがこのタイマーを止めるので、誰もカードを見ていない間だけ値が増え続けます。

function render() {
  track.style.transform = 'translateX(' + (index * -100) + '%)';
}

function advance() {
  index = (index + 1) % count;
  render();
}

function stop() {
  if (timer !== null) { window.clearInterval(timer); timer = null; }
}

function start() {
  stop();
  timer = window.setInterval(advance, 2600);
}

function goLive() {
  if (live) return;
  live = true;
  track.classList.remove('is-demo');
  render();
  start();
}

05FAQアコーディオン — 回答の高さの値を内容の長さから求め直すリスト

1つの質問を開いた状態になると、その回答の内容の高さの値を求め直してその値まで広がり、別の質問を開くと前の質問は高さの値が0まで縮んで閉じます。

FAQアコーディオン — 回答の高さの値を内容の長さから求め直すリスト
新しいタブで開く

05 · FAQアコーディオン — 回答の高さの値を内容の長さから求め直すリスト — 計測 累積面積 39.718% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scrollHeight--openclassList.remove

数値が示すこと

23フレームの計測のうち17フレームでアコーディオンが変化し、累積面積は39.718%(9項目中2位)、平均強度は160.1です。細い要素がスライドするのではなく、回答のブロック全体が開閉するためです。実際のclickリスナーがopenItem()を呼び出し、回答の実際のscrollHeightを読み取ってmax-heightに書き込むため、短い回答は余白が残らず、長い回答も途中で切れません。

function openItem(item) {
  var q = item.querySelector('.faq__q');
  var a = item.querySelector('.faq__a');
  if (!q || !a) return;
  q.setAttribute('aria-expanded', 'true');
  a.style.maxHeight = a.scrollHeight + 'px';
}

items.forEach(function (item) {
  var q = item.querySelector('.faq__q');
  if (!q) return;
  q.addEventListener('click', function () {
    faq.classList.remove('is-demo');
    var wasOpen = q.getAttribute('aria-expanded') === 'true';
    items.forEach(closeItem);
    if (!wasOpen) openItem(item);
  });
});

06強調バナー — 表示状態になると枠の角度の値が増え続ける帯

バナーがビューポートに入った状態になると、ボタンの枠を巡るグラデーション角度の値が一定速度で増え続け、枠が回転しているように見えます。

強調バナー — 表示状態になると枠の角度の値が増え続ける帯
新しいタブで開く

06 · 強調バナー — 表示状態になると枠の角度の値が増え続ける帯 — 計測 累積面積 1.011% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--angleconic-gradient@property

数値が示すこと

23フレームの計測のうち23フレームすべてでバナーが変化し(9項目中最多タイ)、それでも累積面積は1.011%にとどまり、平均強度は19.1(9項目中最小)です。毎フレーム描き直されるのが細いリングの輪郭だけで、広い面が動くわけではないためです。@property --angleは<angle>型なので@keyframes spin { to { --angle: 360deg; } }で直接アニメーションでき、.band__ringを巡る円錐グラデーションがこの1つの増え続ける値を回転する枠に変えています。

.band__ring {
  position: absolute;
  inset: 0;
  border-radius: $r-pill;
  background: conic-gradient(from var(--angle), $color, transparent 32%, $color);
  animation: spin $duration $easing infinite;
}

.band__cta {
  position: relative;
  border: 0;
  border-radius: calc(#{$r-pill} - 4px);
  padding: $sp-3 $sp-8;
  font-family: inherit;
  font-weight: 600;
  font-size: 13px;
  line-height: 1;
  color: $stage-yellow;
  background: $color;
  cursor: pointer;
}

@keyframes spin {
  to { --angle: 360deg; }
}

07フッター — 下線の幅の値を0から文字幅まで求め直すリンク一覧

リンク項目の上にポインターが乗った状態になると、その項目の下線の幅の値が0から文字幅の値まで求め直され、左から描かれます。

フッター — 下線の幅の値を0から文字幅まで求め直すリンク一覧
新しいタブで開く

07 · フッター — 下線の幅の値を0から文字幅まで求め直すリンク一覧 — 計測 累積面積 0.281% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRectquerySelectorAlladdEventListener

数値が示すこと

23フレームの計測のうち7フレームだけフッターが変化し(9項目中最少)、累積面積も9項目中最小の0.281%ですが、平均強度は232.0(9項目中最大)です。動く画素が少ない分、細い下線1本に集中しているためです。実際のpointerenterリスナーがホバーしたリンクの実際の描画幅を測定してカスタムプロパティに書き込み、下線の幅がその値を読むため、短いリンクの下線が伸びすぎることも、長いリンクの下線が足りなくなることもありません。

// ポインターがリンクの上に乗ると、その文字の実際の幅の値を求め直し、下線の幅の値を0からその値まで伸ばす。
// 計算レイヤー: ポインター進入 -> 文字幅の値(getBoundingClientRect) -> 下線幅の補間。実際のポインターリスナーあり(デモ再生自体は固定シーケンス)。
(function () {
  var panel = document.getElementById('stage-panel');
  if (!panel) return;
  var links = panel.querySelectorAll('.link');

  links.forEach(function (link) {
    link.addEventListener('pointerenter', function () {
      panel.classList.remove('is-demo');
      var w = link.getBoundingClientRect().width;
      link.style.setProperty('--tw', w + 'px');
    });
    link.addEventListener('pointerleave', function () {
      link.style.setProperty('--tw', '0px');
    });
  });
})();

08ロゴバー — 横移動の値が途切れずに増え続ける列

ロゴ列全体の横移動の値が時間とともに増え続け、右端のロゴが消えた分だけ左側に同じロゴが再び現れ、列が途切れません。

ロゴバー — 横移動の値が途切れずに増え続ける列
新しいタブで開く

08 · ロゴバー — 横移動の値が途切れずに増え続ける列 — 計測 累積面積 2.711% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

@keyframeswill-changetranslateX

数値が示すこと

23フレームの計測のうち23フレームすべてでマーキーが変化し(06と並び9項目中最多)、累積面積は2.711%、平均強度は132.9です。1つの要素ではなく列全体のロゴが毎フレーム動くためです。ロゴの並びは1回複製されて複製側にaria-hiddenが付き、トラックは複製後のトラック全体の幅のちょうど半分だけ移動してループするため、最後のロゴと最初のロゴの継ぎ目が見えずに済みます。

.marquee__track {
  display: flex;
  width: max-content;
  will-change: transform;
}

.marquee__track.is-demo {
  animation: scroll $duration $easing infinite;
}

.logo {
  margin-right: $sp-8;
  color: $color;
  font-weight: 700;
  font-size: 15px;
  letter-spacing: 0.03em;
  white-space: nowrap;
}

@keyframes scroll {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}

09統計カウンター — 目標値までフレームごとに計算され上がる数字

セクションがビューポートに入った状態になると、数字は0から目標値までフレームごとに計算され上がり、桁が増える値では新しい桁も一緒に現れます。

統計カウンター — 目標値までフレームごとに計算され上がる数字
新しいタブで開く

09 · 統計カウンター — 目標値までフレームごとに計算され上がる数字 — 計測 累積面積 3.044% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFrameMath.powtoLocaleString

数値が示すこと

23フレームの計測のうち13フレームでカウンターが変化し、累積面積は3.044%、平均強度は134.4です。周囲のレイアウトではなく、桁の文字そのものだけが描き直されるためです。requestAnimationFrameの毎ステップが経過時間を固定の時間定数(今回のzipでは1700ms、3つ目のバリエーションでは3200ms)で割り、その比率をイージング関数にかけてから目標値に掛け、画面に書き戻します。目標値が大きくても同じ速さで計算されるだけで、かかる時間そのものは変わりません。

function run(el) {
  var target = Number(el.getAttribute('data-target')) || 0;
  var suffix = el.getAttribute('data-suffix') || '';
  if (reduceMotion) {
    el.textContent = format(target) + suffix;
    return;
  }
  var start = null;
  function step(ts) {
    if (start === null) start = ts;
    var elapsed = ts - start;
    var t = Math.min(1, elapsed / COUNT_DURATION_MS);
    var value = target * easeOutCubic(t);
    el.textContent = format(value) + suffix;
    if (t < 1) {
      window.requestAnimationFrame(step);
    }
  }
  window.requestAnimationFrame(step);
}

数値がセクションを変える場所 — スクロール比率・順番の値・目標の数字

上に挙げた9つの値のうち3つは、見た目を飾るだけでなく、間違えるとセクションが壊れて見えたり誤解を招いたりします。

なぜヒーローのスクロール比率は1つではなく2つの係数を必要とするのか

01の背景と見出しは同じスクロール比率の値を読み取りますが、背景には-18px、見出しにはより大きい-42pxを掛けています。もし同じ係数を使うと2つのレイヤーは1枚の平面のように一緒に動いてしまい、このパターンの名前の由来であるパララックスの奥行きが消えてしまいます。

なぜスタガー出現の順番の値は表示状態のしきい値を必要とするのか

02はページ読み込み時にすべてのカードの順番の値へ固定の間隔をただ掛けるわけではありません。カード自身の見える比率の値が基準を超えて初めて、その遅延の計算が始まります。こうすることで、ページの下の方にあるカードが訪問者のスクロールが届く前に表示を終えてしまうことを防いでいます。

なぜカウンターの目標の数字は一度だけでなく毎フレーム求め直す必要があるのか

09はCSSプロパティを0から目標値へ単純にアニメーションさせることはできません。表示する数字はイージング関数を通り、毎フレーム改めて整形し直す必要があるためです。経過時間の比率から数字全体を毎回求め直すことで、桁が増える瞬間にありえない中間の桁数を一瞬見せることなく、新しい桁がきれいに現れます。

どこで壊れるか — 長いテキスト・低スペック端末・ダークモード

このセットが同じセクション単位のレイアウトで組んだ9セクションのチームページセットや9セクションの事例ページセットと異なるのは、それぞれが動きへ変換する計算軸です。あちらのセットはチーム名簿のレイアウトや事例の本文ブロックに紐づいた値を求め直しますが、こちらの9セクションはスクロール比率・順番の値・測定した幅・経過時間を求め直します。このセット9セクション分の実装(バニラ・React両方)は、この記事のzip内で67e9abq3の先にあります。

長いテキストがファーストビュー デザイン 参考ページに与える影響

01の見出しはデモでは短い固定文字列ですが、実際のファーストビュー デザイン 参考ページの多くは、この動く背景に長めのコピーを組み合わせることになります。見出しの-42pxという係数は文字数と関係なく一定なので、長い見出しも短い見出しと同じ距離しか動きません。とはいえ、本番では見出しの行数を制限しておかないと、狭いビューポートで下のセクションと重なる可能性があります。

低スペック端末が複数の常時アニメーションセクションに与える影響

06の円錐グラデーションのリング・08のマーキー・01の背景レイヤーはどれも一度きりではなく継続的にアニメーションするため、これらのセクションを同じページに複数並べた低スペック端末では、パララックスのレイヤーよりも先にリングとマーキーでコマ落ちが起きやすくなります。円錐グラデーションの再描画はtransformだけのアニメーションよりも1フレームあたりのコストが高いためで、リングの周期を2秒より短くしないことと、マーキーのトラックをコンポジット対象にしておくことがフレーム予算を現実的に保つ助けになります。

ダークモードがlp デザイン 参考サイトと違うこの構成に与える影響

多くのlp デザイン 参考サイトのように単一のライトパレットを直接書き込むのではなく、このセットの色はすべて生のhex値ではなく名前付きトークンから読み取られているため、ダークモード版はトークン層だけを上書きすれば足ります。CTAバナー自身の文字色だけは手動での再確認が必要な値で、ライトな帯の背景では基準を満たす色でも、帯自身のトークンがダークに切り替わるとコントラストが不足することがあります。

3つのバリエーションとアクセシビリティ

以下の数値は、プレースホルダーのブランド・価格・見出しをすべて実際のコンテンツに置き換えて初めて、lp ファーストビュー デザインのテンプレートとして扱えます。計算軸は変わらず、定数だけが変わります。

バリエーション 変わる値 トレードオフ
機能カードのスタガー間隔 項目02のスタガー定数を120msから280msへ 120msだとカードがほぼ同時に現れ一体感が出る一方、280msだと1枚ずつ順番が明確に見えるがカード枚数が多いほど全体の出現に時間がかかる
料金プラン強調カードの拡大率 項目03の拡大率定数を1.08から1.16へ 1.08だと強調カードが周囲から目立ちにくく、1.16だとはっきり浮いて見えるがカード間隔が詰まっていると隣のカードと視覚的に重なることがある
統計カウンターの進行時間 項目09の進行時間定数を1700msから3200msへ 1700msなら大きな目標値でもほとんどの訪問者が見ている間にカウントし終わるが、3200msだと読み方はより落ち着く一方、非常に大きな目標値では訪問者がスクロールし終えてもまだカウント中のことがある

このzipには120ms・1.08・1700msの値が入っています。

reduced motionとスクリーンリーダーが同じ計算値からたどり着く場所

9つすべてがreduced motionの設定を確認し、該当する場合は補間を省いて同じ最終値へ瞬時に切り替えます。reduced motionの読者もヒーローが静止位置に落ち着いた状態・強調された料金プランカードが最終的な拡大率になった状態・カウンターがすでに目標の数字になった状態を見ることになり、違いは途中の補間があるかないかだけです。料金プランの切り替えボタンとFAQの質問見出しは押した状態・開いた状態が常に一致し、ヒーローの背景・CTAバナーのリング・ロゴマーキーの複製側はすべて装飾として扱われ、スクリーンリーダーが重複したり見た目だけの層を内容として読み上げることはありません。

FAQ

9つのセクションには実在する企業名や価格、写真が使われていますか

いいえ。表示されるブランド名はすべてBRAND A〜BRAND Dのようなプレースホルダーで、価格や数値もすべてデモのマークアップやdata属性に直接書き込まれた架空の固定値です。実在する人物の写真も、実際に呼び出すAPIもどこにもありません。

9つのうち実際のクリックやポインターに反応するのはどれですか

4つです。03の請求周期の切り替えと05の質問見出しは実際のclickに、04のカルーセルは実際のpointerenter/pointerleaveで自動再生が止まり、07のフッターのリンクも同じ2つのポインターイベントで下線の幅を求め直します。残りの01・02・06・08・09の5つは、固定のCSSループか、ページ読み込み時に一度だけ動くrequestAnimationFrameだけで再生され、クリック・ドラッグ・ポインターのリスナーはどこにも付いていません。

このセクションセットはそのまま実際の製品ページに使えますか

はい、プレースホルダーのブランド・価格・見出しをすべて実際のコンテンツに置き換えれば使えます。計算の層そのもの — スクロール比率・順番の値・測定した幅・イージングをかけたカウンター — はプレースホルダーを参照していないため、画面上の文字と色を差し替えるだけでデモから本番へ移行できます。

Sources: MDN — CSS custom properties · MDN — IntersectionObserver · MDN — Element.getBoundingClientRect · MDN — prefers-reduced-motion

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

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