Map Tiles Load
Tiles fade in out of order over the placeholder grid, the way a real map delivers them.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useMemo } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Map Tiles Load
*
* Tiles arrive the way a real map delivers them: out of order, each
* fading over the placeholder grid as its request comes back, with a
* one-frame accent edge marking the hand-off. The pin drops once the
* last tile has landed.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The map itself is synthesized from CSS gradients — no tile server, no
* asset — while the placeholder grid is mixed from the inherited text
* color so it reads on a light page and on a dark one.
* Works with zero props; tune via `variant`, `columns`, `rows`, `imageSrc`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type MapTilesLoadProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Tiles across. */
columns?: number;
/** Tiles down. */
rows?: number;
/** Tile edge in px. Width and height are derived from it. */
tileSize?: number;
/** Real map or aerial imagery; omit for the drawn stand-in. Every tile
* shows its own window onto the one image, so any picture works. */
imageSrc?: string;
/** Edge-flash and pin color. A literal accent, not a surface. */
accent?: string;
/** Drop a pin once every tile has landed. */
marker?: boolean;
/** Caption on the pin chip. */
markerLabel?: string;
/** Accessible name for the whole viewport. The marker caption is
* appended to it, since the pin itself is decorative. */
label?: string;
};
type VariantConfig = {
/** Gap between two tiles arriving, in seconds. */
step: number;
fadeSeconds: number;
/** Peak opacity of the accent edge as a tile lands. */
flashPeak: number;
flashSeconds: number;
/** How far the pin falls, in px. */
pinDrop: number;
pinSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: tiles change opacity and nothing else. Scaling them would
// open hairline seams between neighbours, and a map that breathes reads as
// broken rather than loading. The pin is the one thing that travels, on a
// spring above critical damping so it lands once.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Tight sequence, no edge flash. For a small inline map beside an
// address, where the load should barely register.
subtle: {
step: 0.035,
fadeSeconds: 0.34,
flashPeak: 0,
flashSeconds: 0.3,
pinDrop: 8,
pinSpring: { type: "spring", stiffness: 560, damping: 44 },
},
// Readable scatter and a faint edge as each tile lands. All-purpose.
default: {
step: 0.06,
fadeSeconds: 0.42,
flashPeak: 0.5,
flashSeconds: 0.44,
pinDrop: 14,
pinSpring: { type: "spring", stiffness: 460, damping: 40 },
},
// A slower delivery with a brighter edge — for a full-bleed map view
// where the load is part of the experience.
playful: {
step: 0.085,
fadeSeconds: 0.5,
flashPeak: 0.75,
flashSeconds: 0.52,
pinDrop: 20,
pinSpring: { type: "spring", stiffness: 380, damping: 35 },
},
};
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral for the placeholder grid and the frame. The map
* colors below stay literal: they stand in for imagery, not for a
* surface, and a map is a light object on light and dark pages alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** The "loaded" tile artwork, built entirely from gradients so the file
* carries no asset: a bay, a park, two avenues, a diagonal arterial, a
* minor street grid and a band of city blocks over the land color. Each
* tile paints the same image and offsets it, so the twelve of them line
* up into one continuous map. */
const MAP_ART = [
"radial-gradient(130px 104px at 94% 106%, #A6C9E8 0 62%, transparent 63%)",
"radial-gradient(58px 42px at 13% 84%, #BCD6BE 0 72%, transparent 73%)",
"linear-gradient(28deg, transparent 0 45.6%, #F0F4F9 45.6% 48.2%, transparent 48.2%)",
"linear-gradient(90deg, transparent 0 30%, #F0F4F9 30% 33.4%, transparent 33.4%)",
"linear-gradient(90deg, transparent 0 70%, #F0F4F9 70% 72.6%, transparent 72.6%)",
"linear-gradient(180deg, transparent 0 26.5%, #F0F4F9 26.5% 30.5%, transparent 30.5%)",
"linear-gradient(180deg, transparent 0 68%, #F0F4F9 68% 71%, transparent 71%)",
"repeating-linear-gradient(90deg, transparent 0 44px, #E4EAF1 44px 47px)",
"repeating-linear-gradient(180deg, transparent 0 37px, #E4EAF1 37px 40px)",
"repeating-linear-gradient(90deg, #C8D2DE 0 44px, #CFD8E3 44px 88px)",
"linear-gradient(#D8DFE7, #D8DFE7)",
].join(", ");
/**
* Deterministic scatter: sorting tile indices by a cheap hash gives an
* out-of-order arrival that is stable across renders and identical on the
* server and the client. `Math.random()` here would re-roll on every
* render and flicker on hydration.
*/
function arrivalSlots(count: number): number[] {
const byHash = Array.from({ length: count }, (_, index) => index).sort(
(a, b) => ((a * 2654435761) % 4093) - ((b * 2654435761) % 4093)
);
const slots = new Array<number>(count);
byHash.forEach((tile, slot) => {
slots[tile] = slot;
});
return slots;
}
export default function MapTilesLoad({
variant = "default",
columns = 4,
rows = 3,
tileSize = 76,
imageSrc,
accent = ACCENT,
marker = true,
markerLabel = "Depot 4 · 1.2 km",
label = "Delivery area map",
}: MapTilesLoadProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const count = columns * rows;
const slots = useMemo(() => arrivalSlots(count), [count]);
const width = columns * tileSize;
const height = rows * tileSize;
const settled = reduceMotion ? 0.2 : (count - 1) * cfg.step + cfg.fadeSeconds;
return (
<div
role="img"
aria-label={marker && markerLabel ? `${label}: ${markerLabel}` : label}
style={{
position: "relative",
width,
height,
borderRadius: 14,
overflow: "hidden",
border: `1px solid ${tone(13)}`,
// The placeholder underneath: a neutral field ruled into the same
// grid the tiles will fill, so the viewport is already the right
// shape before a single tile has arrived.
background: tone(7),
backgroundImage: `repeating-linear-gradient(90deg, transparent 0 ${
tileSize - 1
}px, ${tone(11)} ${tileSize - 1}px ${tileSize}px), repeating-linear-gradient(180deg, transparent 0 ${
tileSize - 1
}px, ${tone(11)} ${tileSize - 1}px ${tileSize}px)`,
}}
>
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
display: "grid",
gridTemplateColumns: `repeat(${columns}, ${tileSize}px)`,
gridTemplateRows: `repeat(${rows}, ${tileSize}px)`,
}}
>
{slots.map((slot, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
const delay = reduceMotion ? 0 : slot * cfg.step;
return (
<div key={index} style={{ position: "relative" }}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: reduceMotion ? 0.2 : cfg.fadeSeconds,
delay,
ease: "easeOut",
}}
style={{
position: "absolute",
inset: 0,
backgroundImage: imageSrc
? `url(${imageSrc}), ${MAP_ART}`
: MAP_ART,
backgroundSize: `${width}px ${height}px`,
// Every tile paints the whole map and shows its own
// window onto it — that is what makes the grid resolve
// into one continuous image instead of twelve swatches.
backgroundPosition: `${-column * tileSize}px ${
-row * tileSize
}px`,
}}
/>
{cfg.flashPeak > 0 && !reduceMotion ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: [0, cfg.flashPeak, 0] }}
transition={{
duration: cfg.flashSeconds,
delay,
times: [0, 0.22, 1],
ease: "easeOut",
}}
style={{
position: "absolute",
inset: 0,
boxShadow: `inset 0 0 0 1px ${accent}`,
}}
/>
) : null}
</div>
);
})}
</div>
{marker ? (
// The centering translate lives on a plain wrapper: motion owns the
// `transform` of anything it animates, so a static one on the same
// element would be overwritten on the first frame.
<div
aria-hidden
style={{
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
}}
>
<motion.div
initial={{ opacity: 0, y: reduceMotion ? 0 : -cfg.pinDrop }}
animate={{ opacity: 1, y: 0 }}
transition={{
opacity: { duration: 0.24, ease: "easeOut", delay: settled },
y: reduceMotion
? { duration: 0 }
: { ...cfg.pinSpring, delay: settled },
}}
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "5px 10px 5px 7px",
borderRadius: 999,
background: "rgba(255, 255, 255, 0.92)",
boxShadow: "0 2px 10px rgba(16, 20, 32, 0.18)",
color: "#1D2230",
fontSize: 11.5,
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">
<path
d="M8 1.6c-2.4 0-4.3 1.9-4.3 4.3 0 3.1 4.3 8.5 4.3 8.5s4.3-5.4 4.3-8.5c0-2.4-1.9-4.3-4.3-4.3Z"
fill={accent}
/>
<circle cx="8" cy="5.9" r="1.6" fill="#FFFFFF" />
</svg>
{markerLabel}
</motion.div>
</div>
) : null}
</div>
);
}About this pattern
A map does not arrive in reading order — twelve requests go out and twelve come back in whatever order the network decides. Reproducing that is what makes the load feel real: each tile fades over the placeholder grid on its own scattered schedule, with a faint accent edge marking the frame it landed on, and the pin drops only once the last one is in. The order comes from a hash of the tile index rather than a random number, so it is identical on the server and the client and cannot flicker on hydration. Tiles change opacity and nothing else — scaling them would open hairline seams between neighbours. The map itself is built from CSS gradients, so the file carries no asset and no tile server.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Map view
Tiles resolve over a flat placeholder grid in whatever order they come back.
Related patterns
- Lazy Section RevealA below-the-fold section fades and lifts the first time it comes into view, and never again after that.
- Skeleton to ContentBreathing placeholders hand off to the real content in one cross-fade and a small lift.
- Table Rows PopulatePlaceholder cells resolve one column at a time, so the eye follows the fill instead of hunting for it.