GODRICH

Weather Widget Design — 9 Widgets, No Library

Weather widget design is the sky card on a dashboard — temperature, hour strip, week bars.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine follow the shape of one day rather than a difficulty ladder. They open with now — the current-conditions card (01) and the icons that keep it alive (02) — then widen the timeline through the hourly strip (03) and the five-day list (04). With the day laid out, the sky itself takes over: the sun's path (05), the UV index that scores it (06), and the rain-and-wind pair (07). The last two are about the viewer rather than the weather — the unit toggle that converts every number at once (08) and the live clock whose sky follows the real hour (09). All nine share one set of colors, spacing, and easing values, so a dashboard carrying several of them keeps a single tone.

01Current-conditions card whose sky and copy swap together

Pick one of the four condition buttons and the card swaps as one set: the two stacked sky layers change over, the condition glyph swaps, and the temperature, the state line, and the high and low replace each other in the same frame. The exchange is a steps(1, end) cut rather than a crossfade — two half-transparent skies overlapping is precisely what a poster frame would catch — and the glyph adds a small pop so the cut alone does not leave the capture short of moving frames; the render measured a changed-pixel area of 33.208%, the largest of the nine, across 11 of 24 frames at intensity 58.2. It earns its place as the weather summary atop a dashboard or on travel and outdoor-event pages.

opacitytransitionaria-pressed
.wc__glyph {
  grid-area: 1 / 1;
  display: grid;
  place-items: center;
  color: $subject-blue;
  transition: none;
}
.wc__glyph--sunny { color: #e8960c; }
.wc__glyph--rainy { opacity: 0; }
.wc__glyph--snowy { opacity: 0; color: #4a89c9; }
.wc.is-cloudy .wc__glyph--sunny,
.wc.is-rainy .wc__glyph--sunny,
.wc.is-snowy .wc__glyph--sunny { opacity: 0; }
.wc.is-cloudy .wc__glyph--cloudy { opacity: 1; }
.wc.is-rainy .wc__glyph--rainy { opacity: 1; }
.wc.is-snowy .wc__glyph--snowy { opacity: 1; }
.wc.is-cloudy .wc__sky--a,
.wc.is-rainy .wc__sky--a,
.wc.is-snowy .wc__sky--a { opacity: 0; }
.wc.is-cloudy .wc__sky--b,
.wc.is-rainy .wc__sky--b,
.wc.is-snowy .wc__sky--b { opacity: 1; }

02Weather icons that never sit still — sun, cloud, rain, snow

Six tiles each run a loop of their own inside one grid: the sun's rays make a full turn, clouds sway sideways, three raindrops fall on a 1s cycle staggered by .33s delays, snowflakes fall while rotating 160deg, and the bolt rests dim and then flashes twice. The comment at the top of the source notes that every period divides the 2s loop evenly, so one full cycle always fits inside the capture window. The grid measured only 2.443% changed area but movement in 23 of 24 frames — among the busiest frame counts here, which is what an icon row should be: always alive, never loud; condition icons in forecast lists or a weather app's first screen.

animation-delaystepsinfinite
// Three raindrops: a 1s cycle, staggered by delays — the fade-out at the end is part of it
@keyframes wi-dropfall {
  0% { transform: translateY(0); opacity: 0; }
  18% { opacity: 1; }
  72% { opacity: 1; }
  100% { transform: translateY(12px); opacity: 0; }
}
.wi__drop {
  position: absolute;
  top: 20px;
  color: $subject-blue;
  animation-name: wi-dropfall;
  animation-duration: 1s;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-fill-mode: backwards;
}
.wi__drop--1 { left: 14px; }
.wi__drop--2 { left: 27px; animation-delay: .33s; }
.wi__drop--3 { left: 40px; animation-delay: .66s; }

03Hourly strip whose current-hour cell lights up

Hour cells run left to right into the night, and the cell closest to the real hour gets a lit corner dot, a sky tint, and a 2px lift while the rest stay flat. The scroll box carries scroll-snap-type: x mandatory with scroll-snap-align: start on each cell, so a flick settles exactly on a cell, and a few lines of script pick the nearest data-hour and flag it with aria-current. The preview slid the track one cell over and back, measuring 10.719% changed area across 7 of 24 frames at intensity 50.8; hourly forecasts and school-run timing guides.

scroll-snap-typearia-currentscroll-snap-align
.hs__viewport {
  margin-top: $sp-2;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  &::-webkit-scrollbar { display: none; }
  &:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; border-radius: $r-control; }
}
.hs__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 52px;
  gap: $sp-1;
  margin: 0;
  padding: 0;
  list-style: none;
}
for (var i = 0; i < hsCells.length; i++) {
  var gap = Math.abs(Number(hsCells[i].getAttribute('data-hour')) - hsNow);
  if (gap < hsGap) { hsGap = gap; hsBest = i; }
}
var hsTarget = hsCells[hsBest];
hsTarget.classList.add('is-now');
hsTarget.setAttribute('aria-current', 'true');

04Five-day list where each bar finds its span inside the week

Each day's low-to-high bar lands where it falls inside the week's overall range: one pass reads every row's data-lo and data-hi, finds the week's minimum and maximum, and writes left and width as percentages of that span, so a swing day gets a longer bar further along the track and a calm day a short one. Bars enter through a clip-path wipe rather than a width transition, because animating width re-runs layout and scaleX would flatten the rounded ends. The five wipes stagger by .12s each — the delay is keyed off the list items rather than the buttons inside them, since otherwise every row would count as the first one — and together measured 2.413% changed area at intensity 80.5 over 18 of 24 frames, every number steady on tabular-nums; weekly forecast lists, what-to-pack guides.

leftwidthtabular-nums
var drSpan = drMax - drMin;
for (var j = 0; j < drDays.length; j++) {
  var d = drDays[j];
  var bar = d.row.querySelector('.dr__bar');
  bar.style.left = ((d.lo - drMin) / drSpan * 100) + '%';
  bar.style.width = ((d.hi - d.lo) / drSpan * 100) + '%';
}
@keyframes dr-wipe {
  0% { clip-path: inset(0 100% 0 0); }
  36%, 86% { clip-path: inset(0 0 0 0); }
  88%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes dr-num {
  0% { transform: translateY(6px); opacity: 0; }
  30%, 86% { transform: translateY(0); opacity: 1; }
  88%, 100% { transform: translateY(6px); opacity: 0; }
}

05The day's sun riding an arc from sunrise to sunset

A sun rides a half-ellipse arc from the 6am horizon to the 8pm one: offset-path puts it on the curve, and offset-distance, driven by the hour slider, moves it along, so there is no top/left trigonometry anywhere in the file. The path borrows the radius and the end points of the SVG arc drawn beneath it, with the y values raised 9px — half the sun icon — so the icon's center rides the drawn line instead of hanging below it; path() coordinates are fixed pixels that do not scale with the box, so the narrow-width version is written out by hand in the 340px media query. Beneath it, the morning, noon, and evening bands trade places as the hour changes; one sunrise-to-sunset pass logged 16.797% changed area at intensity 79.1 over 23 of 24 frames, and the card fits sunrise-sunset displays and golden-hour planning.

offset-rotatestrokearia-valuenow
.sa__sun {
  position: absolute;
  left: 50%;
  bottom: $sp-1;
  width: 240px;
  height: 90px;
  margin-left: -120px;
  pointer-events: none;
  color: #e8960c;
  // The path the sun follows — same radius and same start/end x as the SVG arc (M 20 80 A 120 60 0 0 1 220 80),
  // with only y raised 9px (half of the 18px sun icon). The sun's center sits on the path, so the icon rides the drawn line.
  // The narrow-screen (340px) path is written out by hand the same way in the media query below — it does not follow automatically
  offset-path: path("M 20 71 A 120 60 0 0 1 220 71");
  offset-distance: 57%;
  offset-rotate: 0deg;
  display: grid;
  place-items: center;
  filter: drop-shadow(0 2px 2px rgba(23, 20, 26, .25));
}
function saApply(hour) {
  var t = (hour - 6) / 14;
  saSun.style.offsetDistance = (t * 100) + '%';
  saRoot.classList.remove('is-morning', 'is-noon', 'is-evening');
  saRoot.classList.add(saBandClass(t));
  saTime.textContent = saHours[hour - 6];
  saRange.setAttribute('aria-valuenow', String(hour));
}

06UV gauge whose needle settles on the band it means

One conic-gradient paints the five bands — from green low to red extreme, 36deg each starting from 270deg — across a disc that a clip cuts down to a half dial, and the needle is a 2px bar whose transform-origin sits at the center of the hub, 1px above the bar's own foot, so moving the value rotates nothing but the needle. The dial and the readout sit on a two-column grid, and the value figure swaps with a steps(1, end) cut while the grade name and the advice line follow. The needle's swing loop measured 2.125% changed area at intensity 74.1 over 20 of 24 frames; UV and air-index cards, outdoor-activity guidance.

conic-gradientgridtransform-origin
// Half dial — a square disc rounded with border-radius: 50%, painted with five conic grade bands,
// then clipped with clip-path to show only the top half (cut to the center line so no sliver is left below the horizon)
.uv__dial {
  position: relative;
  width: 120px;
  height: 64px;
}
.uv__disc {
  position: absolute;
  top: 0;
  left: 0;
  width: 120px;
  height: 120px;
  border-radius: 50%;
  background: conic-gradient(from 270deg at 50% 50%,
    #7ed957 0 36deg, #ffe45c 36deg 72deg, #ffb020 72deg 108deg, #f2691f 108deg 144deg, #d7263d 144deg 180deg,
    rgba(255, 247, 230, 0) 180deg 360deg);
  clip-path: inset(0 0 60px 0);
}

07Precipitation bars that grow, and a compass that points into the wind

Six chance-of-rain bars grow upward in a .08s stagger — each bar's height is a static --h custom property and only transform: scaleY animates, anchored by transform-origin: bottom — while the compass needle swings to the wind's bearing and a button walks through eight directions, renaming it beside the speed figure. The needle is two colors in one gradient, red head over gray tail, so a single rotation reads as direction rather than a spinning stick. Growth and swing together logged 3.967% changed area at intensity 105.1 over 23 of 24 frames; rain-chance and wind cards, laundry and outdoor planning.

scaleYrotatetransform-origin
.pw__bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: var(--h);
  border-radius: $r-xs $r-xs 0 0;
  // The anchor point matters — the bar grows from its foot (the default 50% 50% opens it from the middle)
  transform-origin: bottom;
  background: linear-gradient(180deg, #4a9fe8, $subject-blue);
}
@keyframes pw-grow {
  0% { transform: scaleY(0); }
  40%, 86% { transform: scaleY(1); }
  88%, 100% { transform: scaleY(0); }
}
@keyframes pw-va { 0%, 44% { opacity: 1; } 45%, 87% { opacity: 0; } 88%, 100% { opacity: 1; } }
@keyframes pw-vb { 0%, 44% { opacity: 0; } 45%, 87% { opacity: 1; } 88%, 100% { opacity: 0; } }
@keyframes pw-swing {
  0%, 30% { transform: rotate(315deg); }
  55% { transform: rotate(45deg); }
  88%, 100% { transform: rotate(315deg); }
}

08Unit toggle that converts every number on the card at once

Choosing °C or °F converts the big figure, the four hourly cells, and the week's low-high pairs in the same tick: every value carries data-c, one loop writes Math.round(c * 9 / 5 + 32) into each, the suffixes flip, and the segment thumb slides to the picked side with aria-pressed following. The pick is stored in localStorage inside a try/catch — the note inside the catch says that in a window where saving is blocked, only the current screen keeps the choice. The loop measured 1.576% changed area at intensity 114.6, the highest intensity of the nine, over 8 of 24 frames; unit settings on global services, weather cards for travelers.

localStoragearia-pressedMath.round
function utShow(unit) {
  for (var i = 0; i < utCells.length; i++) {
    var c = Number(utCells[i].getAttribute('data-c'));
    utCells[i].textContent = unit === 'f' ? String(Math.round(c * 9 / 5 + 32)) : String(c);
  }
  for (var j = 0; j < utSuffixes.length; j++) {
    utSuffixes[j].textContent = unit === 'f' ? '°F' : '°C';
  }
  for (var k = 0; k < utButtons.length; k++) {
    var on = utButtons[k].getAttribute('data-unit') === unit;
    utButtons[k].classList.toggle('is-on', on);
    utButtons[k].setAttribute('aria-pressed', on ? 'true' : 'false');
  }
  utRoot.classList.toggle('is-f', unit === 'f');
}
function utRemember(unit) {
  try { localStorage.setItem('ut-unit', unit); } catch (err) { /* in a window where saving is blocked, this screen only */ }
}

09Clock widget whose sky and greeting follow the real hour

The clock reads the device time: setInterval fires every second, but the digits rewrite only when the minute actually changes, and each new hour hands the sky band, the sun-or-moon orb, and the greeting to the next slot. The clock element carries role="timer" with aria-live="off" — a polite live region here would announce the time once a second — and the digits sit on tabular-nums so the colon never jumps. One minute-change handover logged 11.155% changed area at intensity 84.3 over 16 of 24 frames; clock and greeting atop dashboards, kiosk wait screens.

setIntervalaria-livetabular-nums
function lcPad(n) { return n < 10 ? '0' + n : String(n); }
function lcTick() {
  var d = new Date();
  if (d.getMinutes() !== lcLastMinute) {
    lcLastMinute = d.getMinutes();
    lcNow.textContent = lcPad(d.getHours()) + ':' + lcPad(d.getMinutes());
    lcDate.textContent = lcWeek[d.getDay()] + ' · ' + (d.getMonth() + 1) + '/' + d.getDate();
    lcHi.textContent = lcGreeting(d.getHours());
    lcRoot.classList.remove('is-morning', 'is-noon', 'is-evening', 'is-night');
    lcRoot.classList.add(lcTheme(d.getHours()));
    // The next-minute text stacked underneath for the grid loop is built from the real clock too — no wrong time is left on screen
    var nx = new Date(d.getTime() + 60000);
    var alt = lcRoot.querySelector('.lc__swap .lc__alt');
    if (alt) { alt.textContent = lcPad(nx.getHours()) + ':' + lcPad(nx.getMinutes()); }
  }
}
setInterval(lcTick, 1000);
lcTick();

Where does it break — the trap

The first break was in the UV dial's paint. The five bands come from a single conic-gradient that colors only the top half of a 120px disc and leaves the bottom half transparent, and the first version relied on the 64px-tall dial window to hide the unused half. A window crops at its own box edge — 4px below the disc's center — and along that stray strip the gradient's edge anti-aliasing left thin slivers of green and red sitting below the horizon line. Cutting the disc itself to its own center line, clip-path: inset(0 0 60px 0), removed the strip, because the clip and the disc now share the same 60px half.

The second was a number that never existed. The unit toggle's suffix first swapped through a ::after pseudo-element fading out while the figure cut over with steps(1, end); on the poster frame the figure had already cut to 75 while the suffix was still mid-fade, and the frame read 75°C — a state the real click path never produces, since it rewrites figure and suffix in the same loop. Both suffixes now sit stacked like the figures, .ut__v over .ut__alt, and swap with the same cut, so number and unit change inside one frame.

The third was the hourly strip fighting itself. Its preview slides the track with a transform, but the viewport carries scroll-snap-type: x mandatory, and mandatory snapping re-grabs a transformed track and pulls it back to the snapped cell, so the motion is swallowed and the strip jumps in cell-sized steps. Snap switches off only while the demo class is on — .hs.is-demo .hs__viewport { scroll-snap-type: none; } — and the first pointer touch drops the class, handing the strip back to its cell-perfect scrolling.

The fourth only shows in frame 0. The five-day wipes and the rain bars stagger their loops with animation-delay (.12s steps in 04, .08s in 07), and during a delay an element waits in its unanimated state — for these bars that is the fully grown look, since each bar's height is static. The capture's first frame showed every bar already full before they collapsed and grew again: a flash of the ending at the start. animation-fill-mode: backwards holds the 0% keyframe through the delay, so frame 0 is the honest start; the icon file records the same fix for its delayed drops.

Three properties in this set are newer than the rest, and their support numbers (caniuse, measured 2026-09-12) are the same in every language edition of this article: offset-path — Chrome 46+, Safari 16+, Firefox 72+, Edge 79+, 96.35% of global traffic (caniuse.com/css-motion-paths); scroll-snap-type — Chrome 69+, Safari 11+, Firefox 68+, Edge 79+, 96.8% (caniuse.com/css-snappoints); conic-gradient — Chrome 69+, Safari 12.1+, Firefox 83+, Edge 79+, 96.44% (caniuse.com/css-conic-gradients). Everything else the nine use is older still. The zip carries the exact sources behind all nine — SCSS, HTML, and the scripts quoted above — and the password that opens it is 639h7wyh.

Three variations, one value each

Name The one value changed What it feels like
Fast-forward day $dur-loop 2s → 1s The sun crosses in one second and the drops fall twice as often — kiosk energy
Instrument easing $ease-springlinear (06, 07) Needle and compass travel like gauges instead of springs
Golden hour Clear-sky blue #bfe3ff → orange #f2a76c (01) The same card reads as late evening, borrowing the evening band's own orange

Accessibility (reduced-motion)

Under prefers-reduced-motion: reduce, self-running motion stops and the state the viewer chose stays put: 01 holds the sunny sky with its current numbers, 02 parks the drops, flakes, and bolt fully visible instead of half-faded, 03 keeps the lit now-cell, 04 shows all five bars grown with their numbers, 05 pins the sun on the arc under the noon band, 06 holds the needle at the angle the slider last set, which is its 8deg start angle until someone moves it, 07 shows grown bars and the needle at its bearing, 08 stops on Celsius with the thumb parked, and 09 stops on the current hour's sky and greeting. Every declaration in these reduce blocks carries !important, because the preview-loop rules stack an .is-demo class on top of the base selectors and would otherwise win on specificity.

For screen readers, the live clock is the one to get right: it carries role="timer" with aria-live="off", so the time is read once with the card rather than announced every second. The condition buttons (01) and the unit buttons (08) are toggle buttons with aria-pressed, the hourly strip marks its now-cell with aria-current, the sun-arc slider mirrors each step through aria-valuenow, and each five-day row carries a full low-and-high label of its own. :focus-visible outlines are on every button, the scrollable strip, and both sliders.

The roundups next door: embed widget UI patterns is the collection these widgets belong to, activity feed widgets covers the feed-shaped neighbor, and standalone dashboard widgets the dashboard frame they sit in. The about page describes how each demo is rendered and measured before an article is written.

FAQ

Does this need a weather API?

No. Every number in the nine is hardcoded sample data, and the widgets are the presentation layer: when a real forecast arrives, you write it into the DOM exactly the way the unit toggle writes converted values into the cells that carry data-c. The only thing any of the demos reads live is the device clock in 09.

Does offset-path work in Safari?

Yes, from Safari 16. Measured on caniuse, 2026-09-12: Chrome 46+, Safari 16+, Firefox 72+, Edge 79+, and 96.35% of global traffic. The demo already drives the sun by setting offsetDistance from the slider, which is the same hook a left/top fallback for older browsers would use.

How does the unit preference persist?

In localStorage under the key ut-unit, written on click and read on load — both wrapped in try/catch, because private windows and locked-down embeds throw on access. If storage is blocked, the card still converts every number for the current view and quietly opens on Celsius the next time.

Enter the archive password

The password is inside this article. You will find it as you read.