I built a running-car animation in the hero
When you open the top page of this site, "Reinvent Notes," a car running across the screen catches your eye. It is a key visual I built from scratch for the Docusaurus hero: I componentized SVG line art with React and drive the tire rotation and the background parallax entirely with CSS.
I used no flashy animation library — just svgr (bundled with Docusaurus) and pure CSS. It also follows the theme's light / dark switch, and for people who have prefers-reduced-motion enabled, it shows a still image.
I wrote this article with the goal that even someone who just started Web development can follow it to the end. So I explain from the basics, in order — "what is an SVG," "what is viewBox," "what is CSS mask" — and read the implementation on top of that foundation. It gets long, but I broke down each mechanism so it makes sense, so feel free to skip to whichever chapter interests you.
I adjusted the design after publishing, so I updated the text to match the current implementation. There are three main changes: I split the buildings into two layers and punched out the windows; I drew the street's signs, streetlights, and so on as line art like the car; and I fixed a problem where the background jumped at the loop seam. The third one in particular is a complete rewrite of the "infinite loop" chapter.
Why a carReinvent Notes and the inevitability of the logo
This blog's theme is, just as the site name says, "reinventing the wheel." The tagline is also "Learn by reinventing the wheel." Deepening your understanding of how things work by deliberately rebuilding existing tools and concepts yourself — I run this as a place to record that kind of learning (I wrote about how I arrived at this name in I changed the blog domain to notes.rewheel.dev).
This site's logo is, literally, a wheel. It is a simple shape of just an outer rim, a central hub, and four spokes, and I use the same one for the favicon.
One day I suddenly thought, "It's a waste that the logo's wheel stays still." At least on the top page, I wanted to actually spin this wheel. And if the wheel is going to spin, I might as well put it on a body and make it run — which is how I decided to place a "running-car animation" in the hero. It is a decoration that conveys the site's world at a glance, and at the same time a small attempt to express the concept of "reinventing the wheel" through motion.
The finished formwhat is moving
First, let's get a rough grasp of what is happening on screen. I cover the fine numbers in later chapters, so here I only pin down "what parts overlap, and how they move."
In the center there is a line-art car, with its front and rear wheels rotating. The body faces left, and the background clouds, buildings, street signs and streetlights, and the road's dashed lines flow to the right. When the background flows right, the car relatively looks like it is moving left — that is the trick.
The colors follow the theme. In light mode it is a calm sage green; in dark mode a brighter green. The background buildings, street, and clouds are all the same color, so switching the theme changes the whole palette at once.


On screen, the following parts overlap from back to front. Furthest back are the clouds, then the far buildings in front of them, the mid buildings (with windows), the street (signs, streetlights, roadside trees, traffic lights), the road surface, and frontmost the car. Drawn as a diagram, it looks like this.
There is one important point here. This stacking order is decided not by z-index (which specifies stacking in CSS) but by the order written in the HTML. I am simply using the browser's basic rule that elements written later stack in front. That is why I write the clouds first and the car last.
"Why does the HTML order determine stacking" and "why do the parts overlap in the same place instead of stacking vertically" are explained in the next chapter on CSS basics.
CSS layout basics
In this chapter I cover three CSS tools used to "overlap and place the background and the car in the same spot." It is background knowledge that comes up over and over in the implementation, so let's lock it in first.
position and element stacking
Normally, HTML elements are "stacked" in order from top to bottom. If you write two paragraphs, they line up in two tiers, right?
But in this hero, I want the clouds, buildings, and car all overlapped in the same place. What makes that possible is position: absolute.
- An element with
position: relativebecomes a "reference point." In this hero, the parent element.sceneis that. - A child element with
position: absoluteis removed from the usual "stacking" flow and floats at a free position, using the reference point (.scene) as its origin.
The clouds, buildings, street, road surface, and car inside .scene all float with position: absolute. That is why they are not stacked vertically but placed overlapping on the same .scene. And the front-to-back relationship when overlapped is decided by "the order written in the HTML," which I mentioned in the previous chapter.
.scene {
position: relative; /* becomes the reference point for child placement */
overflow: hidden; /* hide the overflowing background outside the frame */
}
.clouds {
position: absolute; /* floats relative to .scene */
}
overflow: hidden is a setting that clips and hides the part overflowing the frame (.scene). The background is made wider than the frame so it can flow, so without this it would spill out sideways.
% is a ratio of what
In this implementation, I specify many positions and sizes in % (percent): things like left: 78%, top: 80%, height: 38%.
In CSS, % is, in principle, a ratio relative to the parent element's size. The cloud layer's height: 38% means "38% of the height of the parent .scene," and a tire's left: 78% means "the position at 78% of the width of the parent .car."
The benefit of using % is that the ratio is preserved even when the screen width changes. On both phone and PC, the tires stick to the same position on the body. Fixed pixels require adjustment per screen size, but with % a single value follows along.
There is an exception, though. In this implementation, only the heights of the building and street layers are deliberately fixed in pixels. I explain why they are not % in the infinite-loop chapter.
the ::after pseudo-element and background
Finally, let me introduce three tools commonly used for drawing backgrounds.
The ::after pseudo-element is a CSS feature that "grows one more virtual child element behind the element" without adding HTML. In this hero, I grow .road::after on the road surface .road to create the "flowing dashed line." Writing content: '' makes an element with empty content appear, to which you can give any appearance.
The background family of properties decides how the background is painted. The four commonly used ones are:
background-color: paints the background a single color.background-image: lays an image or gradient on the background.background-size: the size of one tile of the laid background.background-repeat: whether to repeat the background (repeat-xrepeats horizontally).
And opacity is the transparency of the whole element. 1 is opaque, 0 is fully transparent. Lowering it to something like 0.2 makes that element faintly see-through. In this hero, I use it to make the deeper layers thinner to strengthen the sense of depth.
Combining these, you can draw clouds and dashed lines with CSS alone, without preparing an image file. I cover the details in the background chapter.
First, the basicswhat is an SVG
The material for the body and tires is an image in the SVG format. Since it is the star of the animation, let's first understand what an SVG is.
Vector images and raster images
There are roughly two kinds of images.
- Raster images (JPEG / PNG, etc.): Like photos, they represent a picture with a collection of tiny dots (pixels). Enlarging makes the dots bigger, so it gets blurry or jagged.
- Vector images (SVG): They represent a picture with drawing instructions (coordinates and formulas) like "draw a line from here to here" and "draw a circle of radius N." Enlarging only recomputes and redraws the instructions, so no matter how large you make it, there is no degradation.
SVG stands for Scalable Vector Graphics — exactly "a scalable vector image." Line art like a car logo or an icon is a perfect subject for SVG.
The inside of an SVG is text (XML), and you build a picture by combining shape elements like these.
<rect>: rectangle<circle>: circle<polygon>: polygon<path>: free curves and outlines (the most expressive)
viewBox is "a window that crops the coordinates"
The most important thing in understanding SVG is viewBox. SVG draws shapes with coordinates, and viewBox is the window that decides "which coordinate range to project onto the display frame."
viewBox is specified with four numbers.
viewBox="min-x min-y width height"
- min-x / min-y: the top-left coordinate where projection begins.
- width / height: how much range to project from there toward the right and down.
In other words, it is an instruction to "crop a rectangle of width width and height height with the coordinate (min-x, min-y) at the top-left, and stretch it to fill the display frame." Check it in the diagram below.
The actual body car.svg is viewBox="83 349 2650 747". It crops a wide range of width 2650 and height 747 with the coordinate (83, 349) at the top-left. It is wide because that is needed to fit the silhouette of a sideways-facing car without excess or shortfall.
the <g> group and path / fill / stroke
Let's learn a bit more about SVG parts. We will need them to make sense of the "incident where the color did not change" in a later chapter.
<g>: a tag that groups multiple shapes into one (the g of group). You can move or scale them together, or specify a color for them together.<path>: the element that draws the aforementioned "free outline." The car's line art is made of several<path>s.fill: the interior paint color of a shape.stroke: the outline color of a shape.
There is one property I want you to remember here. A shape with no fill attribute specified is painted black by default. Conversely, the absence of a fill attribute means there is room to override the color later with CSS. This foreshadowing pays off in the final "pitfalls" chapter.
currentColor and borrowing colors in CSS
The heart of the color handling in this implementation is currentColor. Once you understand it, the theme-following mechanism clicks into place all at once.
currentColor is a CSS keyword meaning "borrow the value of the color property currently in effect." color is originally a property that decides "text color," but writing currentColor lets you reuse the same color even in non-text properties (fill, background-color, gradients).
For example, written like this:
.box {
color: green; /* this element's "current color" is green */
border: 2px solid currentColor; /* the border is also green (borrows color) */
fill: currentColor; /* the SVG fill is also green */
}
Changing color in one place changes the color of every place using currentColor, all at once.
In this hero, I put one color on .scene (the parent of the whole animation) and reference the fill of the car, tires, clouds, buildings, signs, and road surface all with currentColor. So when you switch the theme and .scene's color changes, the whole screen's colors follow in unison. The design is "the color instruction is in one place; everyone else borrows it."
CSS animation basics
Before getting into the motion, let's cover four tools for moving things in CSS.
@keyframes and animation
A CSS animation is made of two parts.
@keyframes: the choreography of the animation. You write "the start (from) is this state, the end (to) is this state."animation: how to play back that choreography. You specify the duration, the number of repetitions, and how the speed changes.
The minimal example is this.
/* choreography: move from left to right */
@keyframes slide {
from { transform: translateX(0); }
to { transform: translateX(100px); }
}
/* playback: play slide over 2 seconds, infinitely, at a constant speed */
.box {
animation: slide 2s linear infinite;
}
2sis the duration of one playback. The shorter, the faster the motion.linearis how the speed changes.linearmeans "a constant speed from start to finish," suited to things that flow steadily like the car and the background.infinitemeans "repeat forever."
transformmovement and rotation
transform is a property that transforms an element. In this hero I mainly use two.
translate(x, y): translation (shifting).translateXis horizontal only;translate3d(x, y, z)is the 3D version (below).rotate(angle): rotation.rotate(360deg)is one full turn; adding a minus reverses the direction.
Rotation has a reference point for which point to rotate around, and that is transform-origin. With transform-origin: center, it rotates around the element's center. To spin the tires cleanly, this axis matters (next chapter).
And there is one more pitfall you must remember. transform is last-wins and is overwritten wholesale. If you want both translate and rotate to take effect, you need to write them together in a single transform, like transform: translate(...) rotate(...). Writing only one erases the other. This is the point where you stumble in the tire implementation.
translate3d and GPU compositing
translate3d(x, y, 0) is the 3D version of translate, but the visible motion is the same as translate(x, y). So why use 3d?
The browser can hand off a transform like translate3d to the GPU (the device dedicated to image processing). Handing it to the GPU lets you move just the element smoothly without triggering a page recompute (the heavy process of re-laying-out what goes where). As a result, the motion becomes smoother and less prone to jank.
Adding will-change: transform further lets you give the browser advance notice — "this element is about to move via transform" — encouraging optimization. In this hero, I use translate3d + will-change on all the flowing backgrounds and the spinning tires.
prefers-reduced-motionconsideration for reducing motion
Finally, an easy-to-overlook but important consideration. The OS has settings to "reduce parallax effects" and "reduce motion" (a feature for people prone to dizziness, among others).
CSS can read this setting via the media query @media (prefers-reduced-motion: reduce). For people who have this setting ON, you can stop the animation and show a still image — that kind of conditional output. I use this at the end of the implementation.
How parallax works
What gives the background depth is parallax. When you look outside from a train window, nearby utility poles zip by fast while distant mountains move slowly, right? That effect of "the nearer something is, the faster it moves; the farther, the slower" is parallax.
In this hero too, I flow the far buildings slowly and the front road's dashed line fast, so a flat plane feels like it has depth. The speed of each layer is decided by "the distance moved per cycle / the time per cycle" (the concrete distances and times are in the implementation chapter).
flow speed image
back far bld. ~24px / sec ──> (the slowest)
mid bld. ~40px / sec ────>
clouds ~60px / sec ──────>
street ~93px / sec ──────────>
front road dash ~233px / sec ──────────────> (the fastest)
tires 1 turn / 1.1s O spinning round
(This is a static diagram. Check the actual motion on the top page.)
The deeper, the slower; the nearer, the faster. This alone creates depth. Note that there is no theoretical "correct answer" for these speeds; they are values I adjusted to "look right" while watching the actual screen.
The trick for a seamless infinite loop
Another bit of craft is the loop that flows on and on without breaking.
Each background layer is the same picture (a tile) repeated and laid out horizontally. Moving it a fixed distance horizontally, and snapping it back to the start position when it finishes — repeating this makes an infinite loop. The problem is whether the picture looks shifted at the moment it returns to the start position.
There is a clear condition here. If the distance moved is exactly an integer multiple of "the width of one tile," the appearance after returning matches the appearance before returning perfectly. Because the same picture repeats every tile width, you cannot tell it apart no matter how many tiles it has shifted. Conversely, if you snap back at a distance off from an integer multiple, the background jumps at that instant.
Lay out a picture with a tile width of 600px, move exactly 2 tiles (1200px), and snap back.
start │[tile][tile][tile][tile]…
└── viewport ──┘
move 1200px │ [tile][tile][tile]…
└── viewport ──┘
↑ Every tile is the same picture, so even shifted by 2 tiles the look is identical.
Snapping instantly back to the start here is indistinguishable.
In fact, the first implementation of this hero used the "move and snap back by 100% of the screen width" method. It seems fine at a glance, but the screen width is not necessarily an integer multiple of the tile width. For example, with a screen width of 1440px and a tile width of 600px, that is 1440 / 600 = 2.4 tiles. Every loop, the picture jumped by 0.4 tiles, which was the bug of "the background jerks at a regular interval."
So in the current implementation, I fix the distance moved to a px value of exactly twice the tile width. To fix the distance in px, the tile width also needs to be fixed in px. The building and street tiles (the mask described later) are "scaled to fit the layer's height," so I fixed the tile width by fixing the layer's height in px. This is the exception I foreshadowed in the % chapter. The concrete code is in the implementation chapter.
Implementationcomponentizing the SVG
From here is the actual code. First, let's make the SVG materials usable in React.
The materials are two: the body car.svg and the tire tire.svg. Docusaurus's classic preset bundles a mechanism called svgr, which lets you treat an SVG as a React component just by importing it.
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';
After that, you just place them as ordinary components. car.svg is a complete car drawn down to the wheels, so I overlaid two tire.svgs at the positions of its front and rear wheels and made only the tires rotate. The whole component looks like this (clsx is a utility that combines multiple class names into one; here I compose the shared tire with the individual 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>
);
}
It is a simple component that makes the return type React.ReactElement explicit and, with a single <div> at the top, lines up the child elements in order of background layers -> car.
You can see that the "back to front" stacking order from the finished-form chapter is exactly the order written in the HTML (clouds -> buildingsFar -> buildingsMid -> street -> road -> car). The buildings are split into two layers, buildingsFar (far) and buildingsMid (mid), to strengthen depth by varying the flow speed and density.
I did not need to write a type-definition file for *.svg (svg.d.ts) myself. Because Docusaurus's type config includes the component declaration, yarn typecheck passes as-is.
Implementationfollowing the theme color
Here I actually use the mechanism learned in the currentColor chapter. Color control can be consolidated into one CSS color, so just giving color to .scene (the parent of the whole animation) propagates to the car, tires, and background underneath.
.scene {
position: relative; /* becomes the placement reference for the background and car (child elements) */
width: 100%;
margin: 2.5rem auto 0;
height: clamp(220px, 30vw, 300px); /* reserve the height first to prevent layout shift */
color: var(--ifm-color-primary-darker); /* the currentColor underneath borrows this color */
overflow: hidden; /* hide the background that overflows the frame */
}
[data-theme='dark'] .scene {
color: var(--ifm-color-primary-light);
}
.scene is the foundation of this animation, handling not only color but also the child placement reference (position: relative), the height reservation (height: clamp(...):preventing shift on load), and overflow: hidden to hide the overflowing background. As for color, it uses a slightly deep sage green in light (--ifm-color-primary-darker) and a brighter green in dark (--ifm-color-primary-light). [data-theme='dark'] is Docusaurus's selector for "when in dark mode," and switching the theme swaps the color. Both simply reference theme variables Docusaurus provides, and I did not define a new color for this feature.
And the line art of the body and tires is painted in CSS like this.
.carBody path,
.tire path {
fill: currentColor;
}
Because I set the fill (fill) of the SVG's path to currentColor, .scene's color becomes the line art's color directly.
Some of you may have thought, "Isn't the SVG's color decided on the SVG file's side?" In fact, the point I struggled with most in this implementation is hidden here. The reveal of why I deliberately write fill: currentColor in CSS is told in detail in the final "pitfalls" chapter. For now, just remember that "the color is decided by the single color of .scene."
Implementationspinning the tire without axis wobble
What I was careful about when spinning the tire was that the rotation axis does not wobble. If the rotation axis (center) is off from the wheel's center, it looks shaky when it spins.
The viewBox learned in the SVG-basics chapter takes effect here. The viewBox of tire.svg is cropped to a square.
<svg viewBox="682 41 1450 1450" fill-rule="evenodd">
Both width and height are 1450 — a square. If the viewBox is a square, the center of that frame is exactly the wheel's center. After that, just "rotate around the element's center" in CSS, and it rotates without axis wobble. With a wide frame the center would shift, so being square is important here.
The CSS side is like this.
.tire {
position: absolute;
width: 17%;
aspect-ratio: 1 / 1; /* keep the aspect ratio at 1:1 (square) */
top: 80%;
transform: translate(-50%, -50%); /* align its own center to the placement reference */
transform-origin: center; /* rotation axis = the element's 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 is the setting "keep the height-to-width ratio at 1:1," keeping the element itself square too. transform: translate(-50%, -50%) is the standard technique for aligning the element's center, not its top-left, to the placement reference point.
And here, watch out for the "transform is last-wins" pitfall foreshadowed in the CSS-animation-basics chapter. If you write only rotate() inside @keyframes, the centering translate(-50%, -50%) gets overwritten and erased, and the tire flies off screen. So I also write translate in the rotation keyframes.
The rotation amount is -360deg (a minus full turn), in the direction matching the car's travel direction (flipping the sign reverses the spin).
The front and rear wheel positions are specified in % via left.
.tireFront { left: 78%; }
.tireRear { left: 17.5%; }
Because the body side is made variable with width: min(88%, 680px) (min() is a function that "takes the smaller of the two," capping at 680px when the screen is wide and shrinking to 88% when narrow), keeping the tires placed in % too lets them follow the wheel positions even when the screen width changes.
Implementationthe background parallax
The mechanism of parallax is as seen in the basics chapter. Here I put it into actual code.
First, the choreography of the loop that "moves by an integer multiple of the tile width and snaps back."
@keyframes hero-scroll {
from { transform: translate3d(calc(-1 * var(--loop)), 0, 0); }
to { transform: translate3d(0, 0, 0); }
}
Here the notation var(--loop) appears. --loop is a CSS custom property (CSS variable). You declare a name for any value in the form --name: value; and reference that value with var(--name). The point is that you can reuse the same @keyframes while changing only the value of --loop per layer. The choreography is one; the distance moved is per layer — that is the structure.
Each layer sets "its own tile width x 2" into --loop and moves from a state shifted left by that distance back to the original position. As seen in the loop chapter, because the distance moved is an integer multiple of the tile width, the picture does not jump at the instant it returns to the head. I use translate3d to ride on GPU compositing for smooth motion, as explained in the basics chapter (I also add will-change: transform to each layer).
After that, you just play this hero-scroll at a different distance and a different number of seconds per layer.
| Layer | Distance per cycle (--loop) | Period | Speed |
|---|---|---|---|
Far buildings (.buildingsFar) | 1200px (tile 600px x 2) | 50s | 24px/s |
Mid buildings (.buildingsMid) | 1320px (tile 660px x 2) | 33s | 40px/s |
Clouds (.clouds) | 1440px (tile 720px x 2) | 24s | 60px/s |
Street (.street) | 2688px (tile 1344px x 2) | 29s | ~93px/s |
Road dashes (.road::after) | 1400px (dash 70px x 20) | 6s | ~233px/s |
Tires (.tire) | — | 1.1s | spinning in the foreground |
Note here that "a shorter period = faster" is not the case. Speed is distance / time. For example, the street (29s) has a longer period than the clouds (24s), but because the distance per cycle is about twice as much, it flows faster.
For example, the cloud layer is written like this.
.clouds {
--loop: 1440px; /* distance per cycle = tile width 720px x 2 */
position: absolute;
left: 0;
top: 2%;
width: calc(100% + var(--loop)); /* a width that keeps covering the screen while it moves */
height: 38%;
opacity: 0.22;
background-size: 720px 100%; /* fix the width of one tile to 720px */
/* how to draw the cloud shape (radial-gradient) is in the next chapter */
animation: hero-scroll 24s linear infinite;
will-change: transform;
}
The width calc(100% + var(--loop)) is "screen width + distance moved." So that the background does not break even when shifted left by up to --loop, I widen the layer in advance by the distance moved.
The buildings, street, and road have the same skeleton; what differs is only the --loop distance, the animation seconds, and how the background is drawn. Note that for the clouds I can specify the tile width directly in px with background-size, so the height stays %, but the buildings and street are drawn with the next chapter's mask, so "the tile is scaled to fit the layer's height." Only these two kinds of layers have their height fixed in px (far buildings 120px, mid buildings 102px, street 84px) to fix the tile width.
There is one fine note. Only the road's "dashes" flow; the horizon line itself is fixed. I draw the horizon with the .road body (a fixed element of width: 100%), and draw the dashes flowing over it with .road::after (which flows via hero-scroll). It would look unnatural if the ground line flowed together, so I stop the line and flow only the dashes. The dashes' --loop is 1400px, which is exactly 20 of one dash period (fill + gap = 70px). Here too I follow the "integer multiple of the pattern" rule.
Implementationdrawing the backgrounds differently (mask / gradient)
The background clouds, buildings, street, and dashes are all drawn with CSS alone, without using a single image file. I used three methods (mask / radial-gradient / repeating-linear-gradient) matched to the nature of each shape. In all of them the fill is currentColor, so they follow the theme color together.
In addition, I give each layer an opacity (transparency). The farthest building is the thinnest (0.15), the mid buildings a bit denser (0.25), the clouds in the sky faint (0.22), the thin-lined street solid (0.5), and the front road crisp (0.4 to 0.7). Making the deeper layers thinner and the nearer ones denser, combined with the parallax speed difference, strengthens the depth.
The diagram below is an image of the three methods.
BuildingsCSS mask
mask is a CSS feature that "uses a silhouette (a cutout shape) to crop out the visible part of a base." Only the part drawn opaque keeps the base; the rest becomes transparent. Picture cutting cookie dough with a cookie cutter.
For the buildings, I made the silhouette a data-URI SVG, passed it to mask, and painted the base with currentColor.
The far-building silhouette is a combination of rectangles. In addition to the building bodies, I add rooftop details like antennas and water tanks as thin rectangles, so it does not become a monotonous row of boxes.
<svg viewBox="0 0 1000 200">
<rect x="40" y="96" width="80" height="104" fill="#000"/> <!-- building body -->
<rect x="78" y="62" width="4" height="34" fill="#000"/> <!-- antenna -->
<rect x="64" y="74" width="32" height="4" fill="#000"/> <!-- antenna crossbar -->
<rect x="310" y="116" width="110" height="84" fill="#000"/> <!-- building body -->
<rect x="330" y="68" width="70" height="28" rx="4" fill="#000"/> <!-- water tank -->
<!-- continues for 3 buildings in total -->
</svg>
I encode this SVG into a data-URI string and pass it to CSS mask.
.buildingsFar {
height: 120px; /* px-fixed -> the tile width settles at 600px */
background-color: currentColor; /* paint the base with the theme color */
/* ↑ the building SVG above encoded into the form 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%;
}
Here the term data URI appears. Think of it as a way to "embed the image content directly as a URL string, instead of placing the image file externally" (the data:image/svg+xml,... part is that). Thanks to this, you can write the silhouette inside CSS without preparing a separate .svg file.
repeat-x is the setting to lay the silhouette out repeatedly horizontally. The trailing auto 100% is the tile size specification, meaning "fit the height to the layer, and let the width follow its original aspect ratio." Fitting a tile with a viewBox of 1000x200 to a height of 120px makes the width 1000 x (120 / 200) = 600px. It is precisely because the layer's height is fixed in px that the "tile width 600px" used in the loop chapter settles. I also write -webkit-mask for Safari support (Safari may require the -webkit--prefixed form).
Mid-building windowspunching holes with fill-rule: evenodd
The mid buildings have windows. But rather than drawing the windows in, I punch window-shaped holes in the silhouette. Because mask is a mechanism where "only the opaque part keeps the base," the base disappears at the hole part, and the background behind shows through. That becomes a window.
What I use to punch holes is SVG's fill-rule="evenodd". When you overlap the "outer shape" and the "window" inside a single <path>, the evenodd rule stops painting the part enclosed an even number of times, so only the window rectangle overlaid inside the outer shape becomes a hole.
<path fill-rule="evenodd" d="
M60 60H220V170H60Z ← building outline (large rectangle)
M84 78h24v20H84Z ← window (a small rectangle inside the outline -> becomes a hole)
M134 78h24v20H134Z ← window
...
"/>
Street line artstroke also becomes a mask
The street's signs, streetlights, roadside trees, and traffic lights are drawn as line art, unlike the buildings. Because the body (car.svg) is line art, I wanted to match the taste of objects lined up near the car.
Because mask is a mechanism that "keeps the opaque part," it works not only with the fill (fill) but also with the outline (stroke). The line itself becomes the opaque part, and the line art serves directly as the mold.
<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"/> <!-- sign pole -->
<circle cx="180" cy="56" r="22"/> <!-- round sign -->
<path d="M760 150V44Q760 26 782 26H796"/> <!-- streetlight pole and arm -->
<circle cx="803" cy="26" r="7"/> <!-- streetlight lamp -->
<!-- followed by roadside trees and traffic lights -->
</g>
</svg>
fill="none" removes the fill, and stroke draws only the lines. stroke-linecap="round" and stroke-linejoin="round" are settings to round the line ends and corners, to lean toward the soft texture of the car's line art. I keep the spacing between objects wide. At first I lined them up much more densely, but the star:the car — got buried in the background, so I reduced the count and spaced them out.
Cloudsradial-gradient
radial-gradient is a circular or elliptical gradient where the color changes from the center outward. For the clouds, I express them by overlapping several of these elliptical gradients in 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% means "place an ellipse of width 20% and height 26% at the position (22%, 40%)." currentColor 0 60%, transparent 62% is the setting "paint the color from the center up to 60%, and make it transparent at 62%," and this boundary becomes the soft cloud outline. I make these into a 720px-wide tile with background-size: 720px 100% and repeat them horizontally with background-repeat: repeat-x (the reason for fixing the tile width in px is as explained in the loop chapter).
Road dashesrepeating-linear-gradient
repeating-linear-gradient is a linear gradient that repeats a fixed pattern. I make the road dashes with just this one.
.road::after {
background: repeating-linear-gradient(to right, currentColor 0 26px, transparent 26px 70px);
}
to right is the direction from left to right. currentColor 0 26px is "color from 0 to 26px," and transparent 26px 70px is "transparent from 26px to 70px (= 44px worth)." This repeats on and on, becoming dashes of fill 26px and gap 44px.
For silhouettes and line art use mask (solid fill is fill, window cutouts are fill-rule: evenodd, line art is stroke); for soft blobs and regular lines use gradient — using them apart this way lets you build the background without adding a single image file.
Implementationaccessibility and responsiveness
Finally, considerations so it looks good for various people on various screens.
This animation is purely decorative, so I hide it from screen readers (assistive technology that reads the screen content aloud).
<div className={styles.scene} aria-hidden="true">
aria-hidden="true" is the mark for "this element need not be conveyed to assistive technology." The hero heading (the site name's <h1>) guarantees the content, so there is no need to read the decorative car aloud.
And here I use the prefers-reduced-motion mentioned in the CSS-animation-basics chapter. For people who have the reduce-motion setting, I stop all animation.
@media (prefers-reduced-motion: reduce) {
.clouds,
.buildingsFar,
.buildingsMid,
.street,
.road::after,
.tire {
animation: none;
}
}
What pays off here is the design of "laying out so it holds up as a single picture even when stopped." I made this animation by first creating the still state (a picture of the car resting on the road) and adding motion on top. So even when motion stops, it is a properly composed still image.
Responsiveness (handling screen width) follows the same idea. The background spreads to fill the screen width, and the car is given a size cap at the center.
.car {
position: absolute;
left: 50%;
bottom: 18%;
width: min(88%, 680px); /* caps at 680px when the screen is wide, 88% when narrow */
transform: translateX(-50%); /* center it */
}
.carBody {
display: block;
width: 100%; /* spread the body SVG to the full width of .car */
height: auto;
}
The tire positions are specified in %, so they follow the car's size changes. On mobile, I just tighten the top margin a bit and display it as-is.
@media (max-width: 720px) {
.scene {
margin-top: 1.5rem;
}
}
Pitfallswhy didn't replaceAttrValues work
From here is the reveal of the color problem that ate the most time in this implementation. This is the payoff of "I'll explain later why I write fill: currentColor in CSS," foreshadowed in the theme-following chapter.
The material SVG was line art in a single black color (#000000). I wanted to make this follow the theme color. I knew svgr has a feature to replace attribute values, so I first configured it like this in docusaurus.config.ts.
svgr: {
svgrConfig: {
replaceAttrValues: {'#000000': 'currentColor', '#000': 'currentColor'},
},
},
Replace the color specification #000000 with currentColor, and after that I can control the color with CSS color — that was the plan. But this did not work. Even after building, the car stayed black.
To track down the cause, I compared the source SVG with the built HTML.
First the source (static/img/car.svg). The color is in only one place — the <g> (group)'s fill="#000000" — and the <path>s inside it have no fill attribute. This is exactly the state I described in the SVG-basics chapter as "a shape with no fill is black by default." (The <path>'s d attribute is actually a single enormous coordinate sequence of several thousand characters, so here I show only the beginning and abbreviate with .... You can see the full content in 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 ..."/>
<!-- paths continue, but none have a fill attribute -->
</g>
</svg>
Next, the built HTML. The <g> is gone entirely, and the <path>s line up directly under <svg>. The coordinates have the <g>'s transform recomputed and folded into each path, and crucially, no fill attribute is attached.
<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 vanished / path has no fill attribute -->
</svg>
What happened? svgr internally passes the SVG through svgo, an "SVG optimization tool," before converting it to a React component. In that optimization process, the <g> was folded and removed (the transform was applied to each path's coordinates), and the fill="#000000" information did not survive into the final <path>.
replaceAttrValues is a feature that "searches for the string #000000 and replaces it." But the #000000 that should be replaced does not exist in the optimized HTML, so there was nothing to replace, and nothing happened. The diagram below shows that flow.
What ultimately worked was just a few lines of CSS.
/* path has no fill attribute (svgo removed it), so CSS fill can override the SVG default black */
.carBody path,
.tire path {
fill: currentColor;
}
The point is the property I foreshadowed in the SVG-basics chapter — when a <path> has no fill attribute, CSS fill can override the SVG default black. Precisely because the attribute is absent, you can paint it cleanly with CSS. Ironically, the reason replaceAttrValues did not work (the path keeping no fill attribute) was exactly the reason it could be solved with CSS.
By the above turn of events, replaceAttrValues is effectively not working, but I still leave it in docusaurus.config.ts. There are two reasons: (1) it is worth keeping the intent "I want to make this SVG theme-colorable" as a configuration, and (2) svgo's optimization result depends on the material, so it may work when I add a different SVG later. The actual color control is handled by CSS — that is the summary.
Other stumbles
Less severe than the color problem, but let me line up a few other things I tripped on, as a recap of what the body of the article explained.
- Tire axis wobble: If the
viewBoxis lopsided, the rotation center shifts and it shakes. Crop theviewBoxto a square to align the center, and it is solved (the "spinning the tire without axis wobble" chapter). transformoverwrite: If you write onlyrotate()in@keyframes, the centeringtranslate(-50%, -50%)disappears and the tire flies off. Also writetranslatein the rotation keyframes (the "CSS animation basics" chapter).- Background jumps at the loop seam: The first version set the distance moved to "100% of the screen width," so the screen width was not an integer multiple of the tile width, and the background jerked every loop. Fixing the distance moved to an integer multiple of the tile width (in px) solved it (the "trick for a seamless infinite loop" chapter).
- Keep the background density modest: The more buildings and signs you add, the livelier the city becomes, but the star car gets buried. The first version was actually too dense, so I reduced the number of building stories and street objects and widened the spacing.
- Decide speed, position, and direction by measurement: There are no theoretical values for the parallax distances and seconds or the tire coordinates (%); I adjusted them while watching the screen. The rotation and flow directions also flip with a single sign, so in the end it is fastest to decide by eye.
Wrap-up and reuse tips
Without adding a single image file, I built a "running car" hero with just svgr and pure CSS. The same structure can be applied to other SVG key visuals. Let me summarize the key points in a reusable form.
- Componentize the SVG: With Docusaurus, svgr is bundled, so
import X from './x.svg'is enough. No type definitions needed either. - Lean colors toward
currentColor: Ifpathhas nofillattribute, you can paint it with CSSfill. Putcoloron.sceneand switch it by theme. Consolidate the color instruction into one place. - Draw backgrounds with mask / gradient: For silhouettes use CSS
mask(window cutouts withfill-rule: evenodd, line art withstroke); for soft blobs useradial-gradient; for regular lines userepeating-linear-gradient. No need to add images. - Drive motion with
transformonly: Ridetranslate3dandrotateon GPU compositing and addwill-change. Do not shake the layout.transformis last-wins, so write the transforms you want in effect together as one. - Make the loop's distance an integer multiple of the tile width: For a repeating background, "move and snap back by an integer multiple of the pattern" and the seam is invisible. Fixing the tile width and distance in px is the sure way.
- Build the stopped state first: So that it becomes a single picture even when stopped via
prefers-reduced-motion, start from a still layout.
It started from the whim of wanting to move the logo's wheel, but just by combining the straightforward properties of SVG and CSS, I could build this far without a library. Reinventing the wheel is quite fun.