メインコンテンツまでスキップ

hero に車が走るアニメーションを実装した

· 約47分
Software Engineer

このサイト「Reinvent Notes」のトップページを開くと、車が走り抜けていくアニメーションが目に入ります。Docusaurus の hero に自前で実装したメインビジュアルで、SVG の線画を React コンポーネント化し、タイヤの回転と背景のパララックスをすべて CSS で動かしています。

派手なアニメーションライブラリは使わず、svgr (Docusaurus 同梱) と純粋な CSS だけで作りました。テーマのライト / ダーク切り替えにも追従し、prefers-reduced-motion を有効にしている人には静止画として見せています。

この記事は、Web 開発を始めたばかりの人でも最後まで追えること を目標に書きました。そのため「SVG とは何か」「viewBox とは」「CSS の mask とは」といった基礎から順番に説明し、その土台の上で実装を読み解いていきます。長くなりますが、一つひとつの仕組みが分かるように噛み砕いたので、気になる章から拾い読みしても構いません。

2026-06-13 改訂

公開後にデザインを調整したため、現行の実装に合わせて本文を更新しました。主な変更は 3 つです。ビルを 2 層にして窓を抜いたこと、街路の標識・街灯などを車と同じ線画にしたこと、ループの境目で背景がカクッとずれる問題を直したこと。とくに 3 つ目は「無限ループの章」をまるごと書き直しています。

なぜ車なのかReinvent Notes とロゴの必然性

このブログのテーマは、サイト名そのままに 「車輪の再発明 (reinventing the wheel)」 です。タグラインも「車輪を再発明して学ぶ」。既存のツールや概念をあえて自分で作り直すことで仕組みを深く理解する——そういう学びを記録する場所として運営しています (この名前に至った経緯は ブログのドメインを notes.rewheel.dev に変更しました に書きました)。

このサイトのロゴは、文字通り 車輪 です。外周のリムと中心のハブ、そして 4 本のスポークだけのシンプルな図形で、favicon にも同じものを使っています。

ある日ふと、「ロゴの車輪が止まったままなのはもったいない」と思いました。トップページくらいは、この車輪を実際に回してみたい。車輪が回るなら、いっそ車体に乗せて走らせてしまおう——というわけで、hero に「車が走るアニメーション」を置くことにしました。サイトの世界観を一目で伝える装飾であり、同時に「車輪の再発明」というコンセプトを動きで表現する小さな試みです。

完成形何が動いているのか

まず、画面で何が起きているのかを大づかみに把握しておきましょう。細かい数値はあとの章で扱うので、ここでは 「どんな部品が、どう重なって、どう動くか」 だけを押さえます。

中央に線画の車があり、前輪と後輪が回転しています。車体は左を向いていて、背景の雲・ビル・街路の標識や街灯・路面の破線が右へ流れていきます。背景が右に流れると、相対的に車が左へ進んでいるように見える——という仕掛けです。

色はテーマに追従します。ライトモードでは落ち着いた sage green、ダークモードでは明るい緑。背景のビルや街路、雲もすべて同じ色なので、テーマを切り替えると全体の色がまとめて変わります。

ライトテーマのトップページ hero。sage green で車と背景が統一されている

同じ hero をダークテーマで開いたところ。currentColor が明るい緑に切り替わる

画面には、奥から手前へ次の部品が重なっています。いちばん奥に雲、その手前に遠景のビル、中景のビル (窓付き)、街路 (標識・街灯・街路樹・信号機)、路面、そしていちばん手前に車。これを図にすると、こうなります。

ここで 1 つ大事なポイントがあります。この重なり順は、CSS で重なりを指定する z-index ではなく、HTML に書いた順番 で決まっています。後に書いた要素ほど手前に重なる、というブラウザの基本ルールをそのまま使っているだけです。だから雲を最初に、車を最後に書いています。

「なぜ HTML の順番で重なるのか」「なぜ部品が縦に積まれず同じ場所に重なるのか」は、次の章の CSS の基礎で説明します。

CSS レイアウトの基礎

この章では、背景や車を「同じ場所に重ねて配置する」ために使っている CSS の道具を 3 つ押さえます。実装で何度も出てくる前提知識なので、先に固めておきましょう。

position と要素の重なり

ふつう、HTML の要素は上から下へ順番に「積まれて」並びます。段落を 2 つ書けば、2 段に並びますよね。

ところが、この hero では雲もビルも車も 同じ場所に重ねたい。それを実現するのが position: absolute です。

  • position: relative を付けた要素は「基準点」になります。この hero では親要素 .scene がこれにあたります。
  • position: absolute を付けた子要素は、通常の「積み上げ」の流れから外れて、基準点 (.scene) を原点として自由な位置に浮かびます。

.scene の中の雲・ビル・街路・路面・車は、すべて position: absolute で浮いています。だから縦に積まれず、同じ .scene の上に重なって配置されるのです。そして重なったときの前後関係が、前章で触れた「HTML に書いた順番」で決まります。

.scene {
position: relative; /* 子要素の配置の基準点になる */
overflow: hidden; /* はみ出した背景を枠の外に見せない */
}
.clouds {
position: absolute; /* .scene を基準に浮く */
}

overflow: hidden は、枠 (.scene) からはみ出した部分を切り取って見せなくする指定です。背景は枠より広く作って流すので、これがないと横にはみ出してしまいます。

% は何に対する割合か

この実装では、位置やサイズの多くを % (パーセント) で指定しています。left: 78%top: 80%height: 38% のように。

CSS の % は、原則として 親要素のサイズに対する割合 です。雲レイヤの height: 38% は「親 .scene の高さの 38%」、タイヤの left: 78% は「親 .car の幅の 78% の位置」という意味になります。

% を使う利点は、画面幅が変わっても比率が保たれる ことです。スマホでも PC でも、タイヤは車体の同じ位置に付いてきます。ピクセル固定だと画面サイズごとに調整が必要ですが、% なら 1 つの値で追従してくれます。

ただし例外もあります。この実装では、ビルと街路のレイヤの高さだけは あえてピクセル固定 にしています。なぜ % にしないのかは、無限ループの章で説明します。

::after 疑似要素と background

最後に 3 つ、背景描画でよく使う道具を紹介します。

::after 疑似要素 は、HTML を増やさずに「その要素の後ろに、もう 1 つ仮想の子要素を生やす」CSS の機能です。この hero では、路面 .road.road::after を生やして「流れる破線」を作っています。content: '' を書くと中身が空の要素が現れ、そこに自由な見た目を与えられます。

background 系のプロパティ は背景の塗り方を決めます。よく使うのは次の 4 つです。

  • background-color: 背景を単色で塗る。
  • background-image: 背景に画像やグラデーションを敷く。
  • background-size: 敷いた背景 1 枚分の大きさ。
  • background-repeat: 背景を繰り返すかどうか (repeat-x なら横方向に繰り返す)。

そして opacity は要素全体の 透明度 です。1 で不透明、0 で完全に透明。0.2 のように小さくすると、その要素がうっすら透けます。この hero では、奥のレイヤほど薄くして奥行きを強めるのに使っています。

これらを組み合わせると、画像ファイルを用意しなくても CSS だけで雲や破線を描けます。詳しくは背景の章で見ていきます。

まずは基礎からSVG とは何か

車体とタイヤの素材は SVG という形式の画像です。アニメーションの主役なので、まず SVG が何者なのかを理解しておきましょう。

ベクター画像とラスター画像

画像には大きく 2 種類あります。

  • ラスター画像 (JPEG / PNG など): 写真のように、小さな点 (ピクセル) の集まり で絵を表します。拡大すると点が大きくなり、ぼやけたりギザギザになったりします。
  • ベクター画像 (SVG): 「ここからここへ線を引く」「半径◯◯の円を描く」という 図形の指示 (座標と数式) で絵を表します。拡大しても指示を計算し直して描くだけなので、どれだけ大きくしても劣化しません

SVG は Scalable Vector Graphics の略で、まさに「拡大縮小できるベクター画像」という意味です。車のロゴやアイコンのような線画は、SVG にぴったりの題材です。

SVG の中身はテキスト (XML) で、こんな図形要素を組み合わせて絵を作ります。

  • <rect>: 長方形
  • <circle>: 円
  • <polygon>: 多角形
  • <path>: 自由な曲線・輪郭 (いちばん表現力が高い)

viewBox は「座標を切り取る窓」

SVG を理解するうえで最重要なのが viewBox です。SVG は座標で図形を描きますが、その**「どの座標範囲を表示枠に映すか」を決める窓** が viewBox です。

viewBox は 4 つの数値で指定します。

viewBox="min-x min-y width height"
  • min-x / min-y: 映し始める左上の座標。
  • width / height: そこから右・下に向かって、どれだけの範囲を映すか。

つまり「座標 (min-x, min-y) を左上として、幅 width・高さ height の長方形を切り取り、それを画面の枠いっぱいに引き伸ばして表示する」という指示です。下の図で確認してください。

説明用の図です(実装の car.svg そのものではありません)
(min-x, min-y)映し始める左上の座標width(映す幅)height

実際の車体 car.svgviewBox="83 349 2650 747" です。座標 (83, 349) を左上として、幅 2650・高さ 747 の横長の範囲を切り取っています。横長なのは、横向きの車のシルエットを過不足なく収めるためです。

<g> グループと path・fill・stroke

もう少しだけ SVG の部品を知っておきましょう。あとの章で「色が変わらなかった事件」を読み解くのに必要になります。

  • <g>: 複数の図形を 1 つに グループ化 するタグです (group の g)。まとめて移動・拡大したり、まとめて色を指定したりできます。
  • <path>: 前述の「自由な輪郭」を描く要素。車の線画は、いくつもの <path> でできています。
  • fill: 図形の 内側の塗り色
  • stroke: 図形の 輪郭線の色

ここで覚えておいてほしい性質が 1 つあります。fill 属性が指定されていない図形は、デフォルトで黒で塗られます。逆に言えば、fill 属性が無いということは、あとから CSS で色を上書きできる余地がある、ということです。この伏線は最後の「ハマりどころ」の章で回収します。

currentColor と CSS の色の借り方

この実装の 色まわりの心臓部currentColor です。一度理解すれば、テーマ追従の仕組みが一気に腑に落ちます。

currentColor は CSS のキーワードで、「いま効いている color プロパティの値を借りてくる」 という意味です。color は本来「文字の色」を決めるプロパティですが、currentColor と書くと、文字以外のプロパティ (fillbackground-color やグラデーション) でも同じ色を使い回せます。

たとえばこう書くと:

.box {
color: green; /* この要素の「現在の色」は緑 */
border: 2px solid currentColor; /* 枠線も緑(color を借りる) */
fill: currentColor; /* SVG の塗りも緑 */
}

color を 1 か所変えるだけで、currentColor を使っているすべての場所の色がまとめて変わります。

この hero では、.scene (アニメーション全体の親) に color を 1 つ置き、車・タイヤ・雲・ビル・標識・路面の塗りをすべて currentColor で参照しています。だから テーマを切り替えて .scenecolor が変わると、画面全体の色が一斉に追従する のです。「色の指示は 1 か所、あとはみんなそれを借りる」という設計になっています。

CSS アニメーションの基礎

動きの部分に入る前に、CSS で物を動かす道具を 4 つ押さえます。

@keyframes と animation

CSS アニメーションは 2 つの部品でできています。

  • @keyframes: アニメーションの 振り付け。「開始 (from) はこの状態、終了 (to) はこの状態」と書きます。
  • animation: その振り付けを どう再生するか。かける時間・繰り返し回数・速度の変化のしかたを指定します。

最小の例はこうです。

/* 振り付け: 左から右へ動く */
@keyframes slide {
from { transform: translateX(0); }
to { transform: translateX(100px); }
}

/* 再生: slide を 2 秒かけて・無限に・一定速度で */
.box {
animation: slide 2s linear infinite;
}
  • 2s は 1 回の再生にかける時間。短いほど速く動きます。
  • linear は速度変化のしかた。linear は「最初から最後まで一定速度」という意味で、車や背景のように一定で流れ続けるものに向いています。
  • infinite は「ずっと繰り返す」。

transform移動と回転

transform は要素を 変形 させるプロパティです。この hero では主に 2 つを使います。

  • translate(x, y): 平行移動 (ずらす)。translateX は横だけ、translate3d(x, y, z) は 3 次元版 (後述)。
  • rotate(角度): 回転。rotate(360deg) で 1 回転、マイナスを付けると逆回りになります。

回転には どこを軸に回すか という基準点があり、それが transform-origin です。transform-origin: center なら要素の中心を軸に回ります。タイヤをきれいに回すには、この軸が重要になります (次章)。

そしてもう 1 つ、必ず覚えておきたい落とし穴があります。transform は後勝ちで、まるごと上書きされる という性質です。translaterotate の両方を効かせたいなら、transform: translate(...) rotate(...) のように 1 つの transform にまとめて書く 必要があります。片方だけ書くと、もう片方は消えます。これがタイヤ実装でハマるポイントです。

translate3d と GPU 合成

translate3d(x, y, 0)translate の 3 次元版ですが、見た目の動きは translate(x, y) と同じです。ではなぜ 3d を使うのか。

ブラウザは、translate3d のような変形を GPU (画像処理専用の装置) に任せる ことができます。GPU に任せると、ページの再計算 (どこに何を並べ直すか、という重い処理) を起こさずに、要素だけをスッと動かせます。結果として 動きが滑らかになり、カクつきにくくなる のです。

さらに will-change: transform を添えると、「この要素はこれから transform で動きますよ」とブラウザに事前予告でき、最適化を促せます。この hero では、流れる背景と回るタイヤすべてに translate3d + will-change を使っています。

prefers-reduced-motion動きを減らす配慮

最後に、見落としがちですが大切な配慮です。OS には 「視差効果を減らす」「動きを減らす」 という設定があります (めまいを起こしやすい人などのための機能)。

CSS はこの設定を @media (prefers-reduced-motion: reduce) というメディアクエリで読み取れます。この設定が ON の人には、アニメーションを止めて静止画として見せる——という出し分けができます。実装の最後でこれを使います。

パララックスの仕組み

背景に奥行きを出しているのが パララックス (parallax) です。電車の窓から外を見ると、近くの電柱はビュンと速く流れ、遠くの山はゆっくり動いて見えますよね。あの 「近いものほど速く、遠いものほど遅く動く」 効果がパララックスです。

この hero でも、奥のビルはゆっくり、手前の路面の破線は速く流すことで、平面なのに奥行きを感じさせています。レイヤごとの速さは「1 周で動く距離 ÷ 1 周にかける時間」で決まります (距離と時間の具体値は実装の章で見ます)。

流れる速さ イメージ
奥 遠景ビル 毎秒 約 24px ──→ (いちばんゆっくり)
中景ビル 毎秒 約 40px ────→
雲 毎秒 約 60px ──────→
街路 毎秒 約 93px ──────────→
手前 路面の破線 毎秒 約 233px ──────────────→(いちばん速い)
タイヤ 1.1 秒で 1 回転 ◯ くるくる回転

(これは静的な図です。実際の動きはトップページで確認してください。)

奥ほど遅く、手前ほど速い。これだけで奥行きが生まれます。なお、これらの速さに理論的な正解はなく、実際の画面を見ながら「それらしく」調整した値です。

継ぎ目なく無限ループさせる仕掛け

もう 1 つの工夫が、途切れずに延々と流れ続ける ループです。

背景の各レイヤは、同じ絵 (タイル) を横に繰り返し敷き詰めたものです。これを一定距離だけ横に動かし、動き終わったら開始位置に瞬間的に戻す——を繰り返すと無限ループになります。問題は、開始位置に戻った瞬間、絵がズレて見えないか です。

ここには明確な条件があります。動かす距離が「タイル 1 枚の幅」のちょうど整数倍 なら、戻った後の見た目は戻る前と完全に一致します。同じ絵がタイル幅ごとに繰り返されているので、タイル何枚ぶんズレても見分けがつかないからです。逆に、整数倍からずれた距離で戻すと、その瞬間に背景がカクッと飛びます。

タイル幅 600px の絵を敷き詰め、ちょうど 2 枚ぶん (1200px) 動かして戻す。

開始 │[タイル][タイル][タイル][タイル]…
└── 表示窓 ──┘

1200px 移動 │ [タイル][タイル][タイル]…
└── 表示窓 ──┘
↑ どのタイルも同じ絵なので、2 枚ぶんズレても見た目は同一。
ここで開始位置に瞬間的に戻しても、見分けがつかない。

実は、この hero の最初の実装は「画面幅の 100% ぶん動かして戻す」方式でした。一見問題なさそうですが、画面幅はタイル幅の整数倍とは限りません。たとえば画面幅 1440px・タイル幅 600px なら 1440 ÷ 600 = 2.4 枚ぶん。ループのたびに 0.4 枚ぶん絵が飛び、「背景が一定のタイミングでカクッと動く」バグになっていました。

そこで現在の実装では、動かす距離を タイル幅のちょうど 2 倍の px 値 に固定しています。距離を px で確定させるには、タイル幅も px で確定している必要があります。ビルと街路のタイル (後述する mask) は「レイヤの高さに合わせて拡大縮小」されるため、レイヤの高さを px 固定 にすることでタイル幅を確定させました。% の章で予告した例外がこれです。具体的なコードは実装の章で見ます。

実装SVG をコンポーネント化する

ここからは実際のコードです。まず素材の SVG を React で使えるようにします。

素材は車体の car.svg とタイヤの tire.svg の 2 枚です。Docusaurus の classic preset には svgr という仕組みが同梱されていて、SVG を import するだけ で React コンポーネントとして扱えます。

import React from 'react';
import clsx from 'clsx';
import Car from '@site/static/img/car.svg';
import Tire from '@site/static/img/tire.svg';
import styles from './HeroCarAnimation.module.css';

あとは普通のコンポーネントとして配置します。car.svg は車輪まで描かれた完成車なので、その前後輪の位置に tire.svg を 2 枚重ねて、タイヤだけを回転させる構成にしました。コンポーネント全体はこうです (clsx は複数のクラス名を 1 つにまとめるユーティリティで、ここでは共通の tire と個別の tireRear / tireFront を合成しています)。

export default function HeroCarAnimation(): React.ReactElement {
return (
<div className={styles.scene} aria-hidden="true">
<div className={styles.clouds} />
<div className={styles.buildingsFar} />
<div className={styles.buildingsMid} />
<div className={styles.street} />
<div className={styles.road} />
<div className={styles.car}>
<Car className={styles.carBody} />
<Tire className={clsx(styles.tire, styles.tireRear)} />
<Tire className={clsx(styles.tire, styles.tireFront)} />
</div>
</div>
);
}

返り値の型 React.ReactElement を明示し、<div> 1 つを頂点に、背景レイヤ → 車の順で子要素を並べているだけのシンプルなコンポーネントです。

完成形の章で見た「奥から手前へ」の重なり順が、そのまま HTML の記述順 (clouds → buildingsFar → buildingsMid → street → road → car) になっているのが分かります。ビルが buildingsFar (遠景) と buildingsMid (中景) の 2 層に分かれているのは、流す速さと濃さを変えて奥行きを強めるためです。

*.svg 用の型定義ファイル (svg.d.ts) を自分で書く必要はありませんでした。Docusaurus の型設定にコンポーネント宣言が含まれているため、yarn typecheck もそのまま通ります。

実装テーマ色への追従

currentColor の章で学んだ仕組みを、ここで実際に使います。色の制御は CSS の color 1 か所に集約できるので、.scene (アニメーション全体の親) に color を与えるだけで、配下の車・タイヤ・背景すべてに伝わります。

.scene {
position: relative; /* 背景・車(子要素)の配置基準になる */
width: 100%;
margin: 2.5rem auto 0;
height: clamp(220px, 30vw, 300px); /* 高さを先に予約してレイアウトのガタつきを防ぐ */
color: var(--ifm-color-primary-darker); /* この色を配下の currentColor が借りる */
overflow: hidden; /* 枠からはみ出した背景を隠す */
}

[data-theme='dark'] .scene {
color: var(--ifm-color-primary-light);
}

.scene は色だけでなく、子要素の配置基準 (position: relative)、高さの予約 (height: clamp(...):読み込み時のガタつき防止)、はみ出した背景を隠す overflow: hidden もまとめて担う、このアニメーションの土台です。色について言うと、ライトでは少し濃い sage green (--ifm-color-primary-darker)、ダークでは明るい緑 (--ifm-color-primary-light) を使っています。[data-theme='dark'] は「ダークモードのとき」を表す Docusaurus の指定で、テーマを切り替えると color が差し替わります。どちらも Docusaurus が用意しているテーマ変数をそのまま参照しているだけで、この機能のために新しい色を定義してはいません

そして車体とタイヤの線画は、CSS でこう塗っています。

.carBody path,
.tire path {
fill: currentColor;
}

SVG の path の塗り (fill) を currentColor にしているので、.scenecolor がそのまま線画の色になります。

なぜ CSS で塗る必要があるのか

「SVG の色は SVG ファイル側で決まっているのでは?」と思った人もいるかもしれません。実はここに、この実装でいちばんハマったポイントが隠れています。なぜ fill: currentColor をわざわざ CSS で書くのか の種明かしは、最後の「ハマりどころ」の章で詳しく語ります。いまは「色は .scenecolor 1 つで決まる」とだけ覚えておいてください。

実装タイヤを軸ブレなく回す

タイヤを回すときに気をつけたのは、回転軸がブレないこと です。回転の軸 (中心) がホイールの中心からずれていると、回したときにガタガタ揺れて見えてしまいます。

SVG 基礎の章で学んだ viewBox が、ここで効いてきます。tire.svgviewBox正方形 にクロップしてあります。

static/img/tire.svg
<svg viewBox="682 41 1450 1450" fill-rule="evenodd">

幅も高さも 1450 で正方形です。viewBox が正方形なら、その枠の中心がそのままホイールの中心になります。あとは CSS で「要素の中心を軸に回す」だけで、軸ブレなく回転します。横長の枠だと中心がずれてしまうので、ここは正方形であることが重要です。

説明用の図です
正方形の viewBox(幅 = 高さ)枠の中心 = ホイールの中心 = 回転軸

CSS 側はこうです。

.tire {
position: absolute;
width: 17%;
aspect-ratio: 1 / 1; /* 縦横比を 1:1(正方形)に保つ */
top: 80%;
transform: translate(-50%, -50%); /* 自分の中心を配置基準に合わせる */
transform-origin: center; /* 回転の軸 = 要素の中心 */
animation: hero-tire-spin 1.1s linear infinite;
}

@keyframes hero-tire-spin {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(-360deg); }
}

aspect-ratio: 1 / 1 は「縦と横の比を 1:1 に保つ」指定で、要素自体も正方形に保ちます。transform: translate(-50%, -50%) は、要素の左上ではなく 中心 を配置の基準点に合わせるための定番テクニックです。

そしてここで、CSS アニメーション基礎の章で予告した transform は後勝ち」 の落とし穴に気をつけます。@keyframes の中で rotate() だけを書くと、中心合わせの translate(-50%, -50%) が上書きで消えてしまい、タイヤが画面外へ飛んでいきます。だから 回転のキーフレームでも translate を併記 しています。

回転量は -360deg (マイナスの 1 回転) で、車の進行方向に合う向きにしています (符号を逆にすると逆回りになります)。

前後輪の位置は left の % で指定します。

.tireFront { left: 78%; }
.tireRear { left: 17.5%; }

車体側を width: min(88%, 680px) で可変にしているので (min() は「2 つのうち小さい方を採用」する関数で、画面が広いときは 680px で頭打ち、狭いときは 88% に縮みます)、タイヤも % で配置しておけば、画面幅が変わっても車輪の位置に追従します。

実装背景のパララックス

パララックスの仕組みは基礎の章で見たとおりです。ここでは実際のコードに落とし込みます。

まず「タイル幅の整数倍だけ動かして戻す」ループの振り付けです。

@keyframes hero-scroll {
from { transform: translate3d(calc(-1 * var(--loop)), 0, 0); }
to { transform: translate3d(0, 0, 0); }
}

ここで var(--loop) という書き方が出てきました。--loopCSS カスタムプロパティ (CSS 変数) です。--名前: 値; の形で好きな値に名前を付けて宣言し、var(--名前) でその値を参照できます。ポイントは、同じ @keyframes を使い回しながら、--loop の値だけを各レイヤで変えられる ことです。振り付けは 1 つ、動く距離はレイヤごと、という構成になります。

各レイヤは「自分のタイル幅 × 2」を --loop に設定し、左にその距離だけずらした状態から元の位置へ動かします。ループの章で見たとおり、移動距離がタイル幅の整数倍なので、先頭に戻る瞬間も絵は飛びません。translate3d を使っているのは、基礎の章で説明したとおり GPU 合成に乗せて滑らかに動かすためです (各レイヤに will-change: transform も添えています)。

あとは、この hero-scroll各レイヤで違う距離・違う秒数 で再生するだけです。

レイヤ1 周の距離 (--loop)周期速さ
遠景ビル (.buildingsFar)1200px (タイル 600px × 2)50s24px/s
中景ビル (.buildingsMid)1320px (タイル 660px × 2)33s40px/s
雲 (.clouds)1440px (タイル 720px × 2)24s60px/s
街路 (.street)2688px (タイル 1344px × 2)29s約 93px/s
路面の破線 (.road::after)1400px (破線 70px × 20)6s約 233px/s
タイヤ (.tire)1.1s最前面で回転

ここで「周期が短い = 速い」ではないことに注意してください。速さは距離 ÷ 時間です。たとえば街路 (29s) は雲 (24s) より周期が長いのに、1 周で進む距離が約 2 倍あるので、流れは速くなります。

たとえば雲のレイヤは、こう書いています。

.clouds {
--loop: 1440px; /* 1 周で動く距離 = タイル幅 720px × 2 */
position: absolute;
left: 0;
top: 2%;
width: calc(100% + var(--loop)); /* 動いている間も画面を覆い続ける幅 */
height: 38%;
opacity: 0.22;
background-size: 720px 100%; /* タイル 1 枚の幅を 720px に固定 */
/* 雲の形(radial-gradient)の描き方は次章 */
animation: hero-scroll 24s linear infinite;
will-change: transform;
}

幅の calc(100% + var(--loop)) は「画面幅 + 移動距離」です。最大で --loop ぶん左にずれても背景が途切れないように、あらかじめ移動距離のぶんだけレイヤの幅を広げています。

ビル・街路・路面も骨組みは同じで、違うのは --loop の距離、animation の秒数、背景の描き方だけです。なお、雲は background-size でタイル幅を直接 px 指定できるので高さは % のままですが、ビルと街路は次章の mask で描くため「タイルがレイヤの高さに合わせて拡縮」されます。この 2 種類のレイヤだけ高さを px 固定 (遠景ビル 120px、中景ビル 102px、街路 84px) にして、タイル幅を確定させています。

1 つ細かい注意点があります。流れているのは路面の「破線」だけで、地平線そのものは固定 です。地平線は .road 本体 (width: 100% の固定要素) で描き、その上を流れる破線は .road::after (hero-scroll で流れる) で描いています。地面の線まで一緒に流れると不自然なので、線は止めて破線だけ流しているわけです。破線の --loop は 1400px で、これは破線 1 周期 (塗り + すき間 = 70px) のちょうど 20 個ぶん。ここでも「パターンの整数倍」のルールを守っています。

実装背景を描き分ける(mask / gradient)

背景の雲・ビル・街路・破線は、画像ファイルを 1 枚も使わず、すべて CSS だけで描いています。形の性質に合わせて 3 通りの方法 (mask / radial-gradient / repeating-linear-gradient) を使い分けました。いずれも塗りは currentColor なので、まとめてテーマ色に追従します。

加えて、各レイヤには opacity (透明度) を与えています。いちばん遠いビルを最も薄く (0.15)、中景のビルはやや濃く (0.25)、上空の雲はうっすら (0.22)、線の細い街路はしっかりめ (0.5)、手前の路面はくっきり (0.40.7)。奥ほど薄く・手前ほど濃くすることで、パララックスの速度差と合わせて奥行きを強めています。

下の図が 3 つの方法のイメージです。

説明用の図です
① mask:黒いシルエットの形だけ下地を残す下地(currentColor)+シルエット(mask)=ビルの形だけ残る② radial-gradient:楕円を重ねて雲にする楕円のグラデーションを 3 つ重ねる③ repeating-linear-gradient:等間隔の破線塗り 26px・透明 44px を繰り返す

ビルCSS mask

mask (マスク) は、「シルエット (型抜きの形) を使って、下地の見える部分を切り抜く」 CSS の機能です。不透明に描かれた形の部分だけ下地が残り、それ以外は透明になります。クッキーの型でクッキー生地を抜くイメージです。

ビルは、シルエットを data URI の SVG にして mask に渡し、下地を currentColor で塗っています。

遠景ビルのシルエットは矩形の組み合わせです。ビル本体に加えて、アンテナや給水タンクといった屋上のディテールも細い矩形で添えて、単調な箱の並びにならないようにしています。

遠景ビルのシルエット(mask 用の SVG・抜粋)
<svg viewBox="0 0 1000 200">
<rect x="40" y="96" width="80" height="104" fill="#000"/> <!-- ビル本体 -->
<rect x="78" y="62" width="4" height="34" fill="#000"/> <!-- アンテナ -->
<rect x="64" y="74" width="32" height="4" fill="#000"/> <!-- アンテナの横棒 -->
<rect x="310" y="116" width="110" height="84" fill="#000"/> <!-- ビル本体 -->
<rect x="330" y="68" width="70" height="28" rx="4" fill="#000"/> <!-- 給水タンク -->
<!-- 以下、計 3 棟ぶん続く -->
</svg>

この SVG を data URI の文字列にエンコードして、CSS の mask に渡します。

.buildingsFar {
height: 120px; /* px 固定 → タイル幅が 600px に確定する */
background-color: currentColor; /* 下地をテーマ色で塗る */
/* ↑ のビル SVG を data:image/svg+xml,... の形にエンコードしたもの */
--bg-mask: url("data:image/svg+xml,%3Csvg ... %3C/svg%3E");
-webkit-mask: var(--bg-mask) repeat-x left bottom / auto 100%;
mask: var(--bg-mask) repeat-x left bottom / auto 100%;
}

ここで data URI という言葉が出てきました。これは「画像ファイルを外部に置く代わりに、画像の中身を直接 URL の文字列として埋め込む」方法だと思ってください (data:image/svg+xml,... の部分がそれです)。おかげで .svg ファイルを別途用意しなくても、CSS の中にシルエットを書けます。

repeat-x はシルエットを横に繰り返し敷き詰める指定です。末尾の auto 100% はタイルのサイズ指定で、「高さはレイヤに合わせ、幅は元の縦横比なりに」という意味です。viewBox が 1000×200 のタイルを高さ 120px に合わせると、幅は 1000 × (120 ÷ 200) = 600px。レイヤの高さを px で固定したからこそ、ループの章で使った「タイル幅 600px」が確定します。-webkit-mask を併記しているのは Safari 対応のためです (Safari は -webkit- という接頭辞付きの書き方を必要とする場合があります)。

中景ビルの窓fill-rule: evenodd で穴を抜く

中景のビルには窓があります。といっても窓を描き足したのではなく、シルエットに窓の形の穴を空けています。mask は「不透明な部分だけ下地が残る」仕組みなので、穴の部分は下地が消え、向こう側の背景が抜けて見えます。これが窓になります。

穴空けに使うのが SVG の fill-rule="evenodd" です。1 つの <path> の中に「外形」と「窓」を重ねて描くと、evenodd ルールでは 図形に偶数回囲まれた部分が塗られなくなる ため、外形の内側に重ねた窓の長方形だけが穴になります。

中景ビルのシルエット(窓抜き・抜粋)
<path fill-rule="evenodd" d="
M60 60H220V170H60Z ← ビルの外形(大きな長方形)
M84 78h24v20H84Z ← 窓(外形の内側の小さな長方形 → 穴になる)
M134 78h24v20H134Z ← 窓
...
"/>

街路の線画stroke も mask になる

街路の標識・街灯・街路樹・信号機は、ビルと違って 線画 で描いています。車体 (car.svg) が線画なので、車に近い位置に並ぶオブジェクトはテイストを揃えたかったからです。

mask は「不透明な部分を残す」仕組みなので、塗り (fill) だけでなく 輪郭線 (stroke) でも機能します。線そのものが不透明な部分になり、線画がそのまま型になります。

街路の線画(mask 用の SVG・抜粋)
<svg viewBox="0 0 2400 150">
<g fill="none" stroke="#000" stroke-width="3.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M180 150V78"/> <!-- 標識のポール -->
<circle cx="180" cy="56" r="22"/> <!-- 丸標識 -->
<path d="M760 150V44Q760 26 782 26H796"/> <!-- 街灯のポールとアーム -->
<circle cx="803" cy="26" r="7"/> <!-- 街灯のランプ -->
<!-- 街路樹・信号機と続く -->
</g>
</svg>

fill="none" で塗りを消し、stroke で線だけを描いています。stroke-linecap="round"stroke-linejoin="round" は線の端や角を丸める指定で、車の線画の柔らかい質感に寄せるためのものです。オブジェクト同士の間隔は広めに取っています。最初はもっと密に並べていたのですが、主役の車が背景に埋もれてしまったため、数を減らして間隔を空けました。

radial-gradient

radial-gradient (放射状グラデーション) は、中心から外へ向かって色が変化する円・楕円のグラデーションです。雲は、この楕円のグラデーションを currentColor でいくつか重ねて表現しています。

.clouds {
background-image:
radial-gradient(ellipse 20% 26% at 22% 40%, currentColor 0 60%, transparent 62%),
radial-gradient(ellipse 15% 20% at 42% 52%, currentColor 0 60%, transparent 62%),
radial-gradient(ellipse 12% 16% at 9% 55%, currentColor 0 60%, transparent 62%);
}

ellipse 20% 26% at 22% 40% は「幅 20%・高さ 26% の楕円を、位置 (22%, 40%) に置く」という意味。currentColor 0 60%, transparent 62% は「中心から 60% までは色を塗り、62% で透明にする」指定で、この境目がふわっとした雲の輪郭になります。これらを background-size: 720px 100% で幅 720px のタイルにし、background-repeat: repeat-x で横に繰り返しています (タイル幅を px で固定する理由は、ループの章で説明したとおりです)。

路面の破線repeating-linear-gradient

repeating-linear-gradient (繰り返し直線グラデーション) は、一定のパターンを繰り返す直線グラデーションです。路面の破線はこれ 1 つで作っています。

.road::after {
background: repeating-linear-gradient(to right, currentColor 0 26px, transparent 26px 70px);
}

to right は左から右への向き。currentColor 0 26px で「0 から 26px までは色」、transparent 26px 70px で「26px から 70px まで (= 44px ぶん) は透明」。これが延々と繰り返され、塗り 26px・すき間 44px の破線になります。

シルエットや線画には mask (ベタ塗りは fill、窓抜きは fill-rule: evenodd、線画は stroke)、ふんわりした塊や規則的な線には gradient、と使い分けると、画像ファイルを 1 枚も足さずに背景が組めます。

実装アクセシビリティとレスポンシブ

最後に、いろいろな人・いろいろな画面で気持ちよく見えるための配慮です。

このアニメーションは純粋な装飾なので、スクリーンリーダー (画面の内容を読み上げる支援技術) からは隠しています。

<div className={styles.scene} aria-hidden="true">

aria-hidden="true" は「この要素は支援技術に伝えなくてよい」という印です。hero の見出し (サイト名の <h1>) が内容を担保しているので、装飾の車まで読み上げる必要はありません。

そして CSS アニメーション基礎の章で触れた prefers-reduced-motion を、ここで使います。動きを減らす設定をしている人には、すべてのアニメーションを止めます。

@media (prefers-reduced-motion: reduce) {
.clouds,
.buildingsFar,
.buildingsMid,
.street,
.road::after,
.tire {
animation: none;
}
}

ここで効いてくるのが、「止まっていても 1 枚の絵として成立する」ようにレイアウトを先に組んでおく という設計です。このアニメーションは、まず静止状態 (車が道に佇んでいる絵) を作り、そこに動きを後付けしました。だから動きを止めても、ちゃんと成立した静止画になります。

レスポンシブ (画面幅への対応) も同じ考え方です。背景は画面幅いっぱいに広げ、車は中央でサイズの上限を持たせています。

.car {
position: absolute;
left: 50%;
bottom: 18%;
width: min(88%, 680px); /* 画面が広いときは 680px で頭打ち、狭いときは 88% */
transform: translateX(-50%); /* 中央寄せ */
}

.carBody {
display: block;
width: 100%; /* 車体 SVG を .car の幅いっぱいに広げる */
height: auto;
}

タイヤ位置は % 指定なので車のサイズ変化に追従します。モバイルでは、上の余白を少し詰めるだけでそのまま表示しています。

@media (max-width: 720px) {
.scene {
margin-top: 1.5rem;
}
}

ハマりどころreplaceAttrValues はなぜ効かなかったか

ここからは、この実装でいちばん時間を取られた 色の問題 の種明かしです。テーマ追従の章で「fill: currentColor をなぜ CSS で書くのか、あとで説明する」と予告した、その回収です。

素材の SVG は黒一色の線画 (#000000) でした。これをテーマ色に追従させたい。svgr には属性値を置換する機能があると知っていたので、まず docusaurus.config.ts でこう設定しました。

svgr: {
svgrConfig: {
replaceAttrValues: {'#000000': 'currentColor', '#000': 'currentColor'},
},
},

#000000 という色の指定を currentColor に置き換えれば、あとは CSS の color で色を制御できる——はずでした。ところが、これが効きませんでした。 ビルドしても車は黒いまま。

原因を追うために、ソースの SVG と、ビルドされた後の HTML を見比べてみました。

まずソース (static/img/car.svg)。色は <g> (グループ) の fill="#000000"1 か所だけ あり、その中の <path> には fill 属性がありません。SVG 基礎の章で「fill が無い図形はデフォルトで黒」と説明した、まさにその状態です。(<path>d 属性は実際には数千文字におよぶ 1 本の長大な座標列なので、ここでは先頭だけ示して ... で省略しています。完全な中身は static/img/car.svg で見られます。)

static/img/car.svg (ソース)
<svg viewBox="83 349 2650 747">
<title>car-line-art</title>
<g transform="translate(0,1536) scale(0.05,-0.05)" fill="#000000" stroke="none">
<path d="M31820 23734 c-1590 -37 -2289 -76 ..."/>
<!-- path が続くが、いずれも fill 属性を持たない -->
</g>
</svg>

次にビルド後の HTML。<g> が丸ごと消え、<path><svg> の直下に並んでいます。座標は <g>transform が各 path に計算し直されて畳み込まれ、肝心の fill 属性は付いていません。

ビルド後の DOM (build/index.html)
<svg viewBox="83 349 2650 747" class="carBody_DE43">
<title>car-line-art</title>
<path d="M1591 349.3c-79.5 1.85-114.45 3.8 ..."/>
<!-- g が消滅 / path に fill 属性なし -->
</svg>

何が起きたのか。svgr は内部で svgo という「SVG 最適化ツール」を通してから React コンポーネントに変換します。その最適化の過程で、<g> が畳まれて消え (transform は各 path の座標に適用され)、fill="#000000" の情報は最終的な <path> に残りませんでした

replaceAttrValues は「#000000 という文字列を探して置換する」機能です。ところが、置換すべき #000000 が最適化後の HTML に存在しないので、置換のしようがなく、何も起きなかったのです。下の図が、その流れです。

最終的に効いたのは、CSS のたった数行でした。

/* path に fill 属性が無い(svgo が除去)ので、CSS の fill が SVG デフォルトの黒を上書きできる */
.carBody path,
.tire path {
fill: currentColor;
}

ポイントは、SVG 基礎の章で伏線を張った性質——<path>fill 属性が無いとき、CSS の fill が SVG デフォルトの黒を上書きできる ——です。属性が無いからこそ、CSS で素直に塗れる。皮肉にも、replaceAttrValues が効かなかった理由 (path に fill 属性が残らなかったこと) が、そのまま CSS で解決できる理由になっていました。

config の設定はなぜ残してあるか

上記の経緯で replaceAttrValues は実質的に効いていませんが、docusaurus.config.ts には今も残してあります。理由は 2 つで、(1)「この SVG はテーマ色化したい」という意図を設定として残す価値があること、(2) svgo の最適化結果は素材次第なので、別の SVG を足したときには効く可能性があること。実際の色制御は CSS が担っている、という整理です。

その他のつまずき

色の問題ほどではありませんが、他にもいくつか引っかかった点を、本文で説明した内容のおさらいとして並べておきます。

  • タイヤの軸ブレ: viewBox がいびつだと回転中心がずれてガタつく。viewBox正方形 にクロップして中心を揃えると解決 (「タイヤを軸ブレなく回す」の章)。
  • transform の上書き: @keyframesrotate() だけを書くと、中心合わせの translate(-50%, -50%) が消えてタイヤが飛ぶ。回転キーフレームでも translate併記 する (「CSS アニメーションの基礎」の章)。
  • ループの境目で背景が飛ぶ: 初版は移動距離を「画面幅の 100%」にしていたため、画面幅がタイル幅の整数倍にならず、ループのたびに背景がカクッとずれていた。移動距離をタイル幅の整数倍 (px) に固定して解決 (「継ぎ目なく無限ループさせる仕掛け」の章)。
  • 背景の密度は控えめに: ビルや標識を増やすほど街は賑やかになるが、主役の車が埋もれる。実際に初版は密度が高すぎて、ビルの棟数と街路オブジェクトを減らし、間隔を広げる調整をした。
  • 速度・位置・向きは実測で: パララックスの距離・秒数やタイヤの座標 (%) に理論値はなく、画面を見ながら調整した。回転や流れの向きも符号 1 つで逆転するので、最後は目で見て決めるのが早い。

まとめ・再利用のヒント

画像ファイルを 1 枚も足さず、svgr と純 CSS だけで「車が走る」hero を作りました。同じ構成は他の SVG メインビジュアルにも応用できます。要点を再利用しやすい形でまとめます。

  1. SVG はコンポーネント化: Docusaurus なら svgr が同梱なので import X from './x.svg' で済む。型定義も不要。
  2. 色は currentColor に寄せる: pathfill 属性が無ければ CSS の fill で塗れる。.scenecolor を置き、テーマで切り替える。色の指示は 1 か所に集約する。
  3. 背景は mask / gradient で: シルエットは CSS mask (窓抜きは fill-rule: evenodd、線画は stroke)、ふんわりした塊は radial-gradient、規則的な線は repeating-linear-gradient。画像を増やさずに済む。
  4. 動きは transform だけで: translate3drotate を GPU 合成に乗せ、will-change を添える。レイアウトを揺らさない。transform は後勝ちなので、効かせたい変形は 1 つにまとめて書く。
  5. ループの移動距離はタイル幅の整数倍に: 繰り返し背景は「パターンの整数倍だけ動かして戻す」と境目が見えない。タイル幅と移動距離を px で固定するのが確実。
  6. 止まった状態を先に作る: prefers-reduced-motion で止めても 1 枚の絵になるよう、静止レイアウトを出発点にする。

ロゴの車輪を動かしたいという思いつきから始めましたが、SVG と CSS の素直な性質を組み合わせるだけで、ライブラリなしでここまで作れました。車輪の再発明、なかなか楽しいものです。

記事の感想・フィードバックは X でお気軽にどうぞ

𝕏 で感想をツイート