Weather icons
A forecast card of living weather icons: turning sun, drifting clouds, rain with splashes, snow and a thunder flash.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
type Sky = "clear" | "cloudy" | "rain" | "storm" | "snow";
const DAYS: { day: string; long: string; sky: Sky; high: number; low: number }[] = [
{ day: "Mon", long: "Monday", sky: "clear", high: 18, low: 9 },
{ day: "Tue", long: "Tuesday", sky: "cloudy", high: 15, low: 8 },
{ day: "Wed", long: "Wednesday", sky: "rain", high: 11, low: 6 },
{ day: "Thu", long: "Thursday", sky: "storm", high: 9, low: 4 },
{ day: "Fri", long: "Friday", sky: "snow", high: 1, low: -3 },
];
const LABEL: Record<Sky, string> = { clear: "Clear skies", cloudy: "Partly cloudy", rain: "Light rain", storm: "Thunderstorms", snow: "Snow showers" };
const CLOUD = "M19 46h26a10 10 0 0 0 1-20 14 14 0 0 0-27-3 11.5 11.5 0 0 0 0 23Z";
/** One 64 x 64 icon per sky. Every moving part is a CSS animation keyed by class. */
function Icon({ sky }: { sky: Sky }) {
const cloud = (y = 0) => <path className={styles.cloud} d={CLOUD} transform={`translate(0 ${y})`} />;
return (
<svg viewBox="0 0 64 64" className={styles.svg} aria-hidden="true">
{(sky === "clear" || sky === "cloudy") && (
<g className={sky === "cloudy" ? styles.sunBack : undefined}>
<g className={styles.rays}>
{Array.from({ length: 8 }, (_, i) => (
<path key={i} d="M32 6v6" transform={`rotate(${i * 45} 32 32)`} />
))}
</g>
<circle className={styles.sun} cx="32" cy="32" r="11" />
</g>
)}
{sky === "cloudy" && <g className={styles.drift}>{cloud(6)}</g>}
{(sky === "rain" || sky === "storm" || sky === "snow") && <g className={styles.drift}>{cloud(-4)}</g>}
{sky === "rain" &&
[22, 32, 42].map((x, i) => (
<g key={x}>
<path className={styles.drop} d={`M${x} 47v6`} style={{ animationDelay: `${i * 0.35}s` }} />
<ellipse className={styles.splash} cx={x - 1.5} cy="60" rx="3" ry="1" style={{ animationDelay: `${i * 0.35}s` }} />
</g>
))}
{sky === "storm" && (
<>
<path className={styles.bolt} d="M34 44l-6 9h5l-3 8 8-11h-5l3-6Z" />
<path className={styles.drop} d="M22 47v5" style={{ animationDelay: ".2s" }} />
<path className={styles.drop} d="M44 47v5" style={{ animationDelay: ".6s" }} />
</>
)}
{sky === "snow" &&
[22, 32, 42].map((x, i) => (
<g key={x} transform={`translate(${x} 50)`}>
<path className={styles.flake} d="M0-3V3M-2.6-1.5 2.6 1.5M-2.6 1.5 2.6-1.5" style={{ animationDelay: `${i * 0.7}s` }} />
</g>
))}
</svg>
);
}
/** The number rolls: the old value slides out one way while the new one slides in, up for warmer and down for colder. */
function Temp({ value }: { value: number }) {
const [pair, setPair] = useState({ now: value, was: value });
useEffect(() => setPair((p) => (p.now === value ? p : { now: value, was: p.now })), [value]);
const dir = pair.now >= pair.was ? "up" : "down";
return (
<span className={styles.temp} data-dir={dir} aria-hidden="true">
{pair.was !== pair.now && <span key={`was${pair.was}${pair.now}`} className={styles.out}>{pair.was}</span>}
<span key={`now${pair.now}`} className={styles.in}>{pair.now}</span>
<span className={styles.deg}>°</span>
</span>
);
}
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [active, setActive] = useState(2);
const [touched, setTouched] = useState(false);
const day = DAYS[active];
// Until someone picks a day, the card steps through the week on its own.
useEffect(() => {
if (touched || reduced || !inView) return;
const timer = setTimeout(() => setActive((a) => (a + 1) % DAYS.length), 3200);
return () => clearTimeout(timer);
}, [active, touched, reduced, inView]);
const pick = (i: number) => {
setTouched(true);
setActive(i);
};
return (
<div ref={root} className={styles.root} data-idle={!inView || undefined}>
<section className={styles.card} aria-label="Five day forecast for Oslo">
<div className={styles.now}>
<div className={styles.hero}>
{DAYS.map((d, i) => (
<span key={d.day} className={styles.layer} data-on={i === active || undefined}>
<Icon sky={d.sky} />
</span>
))}
</div>
<div className={styles.read}>
<Temp value={day.high} />
<p className={styles.cond}>
<span key={day.sky} className={styles.condText}>{LABEL[day.sky]}</span>
</p>
<p className={styles.place}>Oslo, {day.long}</p>
<p className={styles.range}>H {day.high}° <span>L {day.low}°</span></p>
</div>
<p className={styles.sr} aria-live={touched ? "polite" : "off"}>
{day.long}: {LABEL[day.sky]}, high {day.high} degrees, low {day.low} degrees.
</p>
</div>
<div className={styles.days}>
{DAYS.map((d, i) => (
<button
key={d.day}
type="button"
className={styles.day}
aria-pressed={i === active}
aria-label={`${d.long}, ${LABEL[d.sky]}, high ${d.high} degrees`}
onClick={() => pick(i)}
>
<span className={styles.dayName}>{d.day}</span>
<span className={styles.mini}><Icon sky={d.sky} /></span>
<span className={styles.hi}>{d.high}°</span>
</button>
))}
</div>
</section>
</div>
);
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 14px;
box-sizing: border-box; background: var(--sky);
}
.root[data-idle] * { animation-play-state: paused !important; }
.card {
width: min(480px, 100%); display: grid; gap: clamp(8px, 4cqh, 18px); padding: clamp(10px, 4cqh, 18px);
box-sizing: border-box; border-radius: 18px; border: 1px solid var(--line); background: var(--white);
box-shadow: 0 18px 40px -28px color-mix(in srgb, var(--ink) 45%, transparent);
}
/* Today: the big icon on the left, the rolling temperature on the right. */
.now { display: flex; align-items: center; gap: clamp(10px, 4cqw, 22px); padding-inline: 4px; }
.hero { position: relative; flex: none; width: clamp(68px, 30cqh, 124px); aspect-ratio: 1; }
.layer {
position: absolute; inset: 0; opacity: 0; scale: .55; rotate: -25deg; filter: blur(6px);
transition: opacity .5s var(--out), scale .6s var(--out), rotate .6s var(--out), filter .5s var(--out);
}
.layer[data-on] { opacity: 1; scale: 1; rotate: 0deg; filter: none; }
.read { min-width: 0; display: grid; gap: 2px; }
.read p { margin: 0; }
.cond { font-size: 14px; font-weight: 650; overflow: hidden; }
.condText { display: inline-block; animation: up .45s var(--out); }
@keyframes up { from { opacity: 0; translate: 0 8px; } }
.place { font-size: 12px; color: var(--muted); }
.temp {
position: relative; display: flex; align-items: flex-start; height: 1em; overflow: hidden; padding-right: .05em;
font-size: clamp(36px, 17cqh, 64px); font-weight: 700; line-height: 1; letter-spacing: -0.04em;
}
.in { display: block; animation: in-up .6s var(--out) both; }
.out { position: absolute; left: 0; top: 0; animation: out-up .6s var(--out) both; }
.temp[data-dir="down"] .in { animation-name: in-down; }
.temp[data-dir="down"] .out { animation-name: out-down; }
@keyframes in-up { from { translate: 0 100%; opacity: 0; } }
@keyframes out-up { to { translate: 0 -100%; opacity: 0; } }
@keyframes in-down { from { translate: 0 -100%; opacity: 0; } }
@keyframes out-down { to { translate: 0 100%; opacity: 0; } }
.deg { font-size: .5em; font-weight: 600; margin: .06em 0 0 .06em; color: var(--muted); }
.range { font-size: 12px; font-weight: 650; font-variant-numeric: tabular-nums; }
.range span { margin-left: 6px; color: var(--muted); }
.days { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
.day {
display: grid; justify-items: center; gap: 2px; min-height: 44px; padding: 7px 2px; border-radius: 12px;
border: 1px solid transparent; background: var(--surface); color: var(--ink); font: inherit; cursor: pointer;
transition: background-color .25s, border-color .25s, transform .18s var(--out);
}
.day:hover { border-color: var(--line); }
.day:active { transform: scale(.95); }
.day[aria-pressed="true"] { background: var(--accent-pale); border-color: var(--accent-soft); }
.day:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.dayName { font-size: 11px; font-weight: 650; color: var(--muted); }
.day[aria-pressed="true"] .dayName { color: var(--accent-text); }
.mini { width: clamp(24px, 11cqh, 34px); aspect-ratio: 1; }
.hi { font-size: 12px; font-weight: 650; font-variant-numeric: tabular-nums; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* The icons: duotone line art drawn in a 64 unit box. */
.svg { display: block; width: 100%; height: 100%; overflow: visible; fill: none; stroke: var(--ink); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.sun { fill: var(--accent); stroke: none; }
.rays { stroke: var(--accent); stroke-width: 3; transform-origin: 32px 32px; animation: spin 14s linear infinite; }
.sunBack { transform: translate(6px, -6px) scale(.72); transform-origin: 46px 14px; }
@keyframes spin { to { rotate: 1turn; } }
.cloud { fill: var(--white); }
.drift { animation: drift 5s ease-in-out infinite alternate; }
@keyframes drift { from { translate: -2.5px 0; } to { translate: 2.5px 0; } }
.drop { stroke: var(--accent-text); stroke-width: 2.4; animation: fall 1.05s linear infinite both; }
@keyframes fall {
0% { translate: 0 -3px; opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { translate: -2px 6px; opacity: 0; }
}
.splash { stroke: var(--accent-text); stroke-width: 1.2; transform-box: fill-box; transform-origin: center; animation: splash 1.05s var(--out) infinite both; }
@keyframes splash {
0%, 74% { scale: .2; opacity: 0; }
82% { scale: .7; opacity: 1; }
100% { scale: 1.5; opacity: 0; }
}
.bolt { fill: var(--accent); stroke: var(--ink); stroke-width: 1.4; animation: flash 2.6s steps(1, end) infinite; }
@keyframes flash {
0%, 58% { opacity: .3; }
60% { opacity: 1; }
64% { opacity: .35; }
67%, 82% { opacity: 1; }
100% { opacity: .3; }
}
.flake { stroke: var(--accent-text); stroke-width: 1.4; animation: snow 2.4s ease-in-out infinite both; }
@keyframes snow {
0% { translate: 0 -2px; opacity: 0; }
20% { opacity: 1; }
50% { translate: 2.5px 5px; }
85% { opacity: 1; }
100% { translate: -1.5px 12px; opacity: 0; }
}
@container (max-height: 280px) {
.root { padding: 8px; }
.day { padding-block: 5px; }
.place { display: none; }
.cond { font-size: 13px; }
}
@container (max-width: 330px) {
.days { gap: 4px; }
.cond { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.rays, .drift, .bolt, .condText { animation: none; }
.drop, .flake { animation: none; opacity: 1; }
.splash { animation: none; opacity: 0; }
.layer { transition: opacity .2s; }
.in, .out { animation: none; }
.out { display: none; }
.layer { scale: 1; rotate: 0deg; filter: none; }
}
Use it for
- Travel, events and logistics products that show conditions
- Assistant and search answers with a weather card
- Small status icons that should feel alive without a library
Avoid
- Dense data tables, where many moving icons compete
- Icons smaller than about 20px, where the motion turns to noise
How it works
Each icon is inline SVG whose parts carry their own CSS keyframes: rotating rays, a cloud that drifts with translate, drops that fall while a splash ellipse scales out on the same 1.05s beat, flakes that sway down and a bolt that flickers with stepped opacity. All five big icons are stacked and the chosen one crossfades in with opacity, scale, rotate and blur. The temperature sits on two digit wheels that slide with translateY, and the tens wheel collapses its width for single digits.
Accessibility
Each day is a button with aria-pressed and a full spoken label, and a visually hidden live line announces the forecast after a choice (never during the auto cycle). Animations pause off screen. Reduced motion stops every icon loop and the auto cycle, and days switch with a plain fade.