Craft / Animation
Pixel Weather
A compact weather box built from bitmap temperatures and weather icons.
Demo
InteractivePixel weatherSample data
Sunny, 24 degrees Celsius. Orange device. Sample weather data.
Current conditions
Sunny
Condition
Add the shadcn Select (npx shadcn add select), then paste this component into a React app with Tailwind CSS. It uses sample conditions and no image assets. The code below is the exact component shown above.
"use client";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const BLANK = Array.from({ length: 7 }, () => "00000");
const GLYPHS: Record<string, string[]> = {
"0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
"1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
"2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
"3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
"4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
"5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"],
"6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110"],
"7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
"8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
"9": ["01110", "10001", "10001", "01111", "00001", "00010", "01100"],
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
"°": ["01100", "10010", "10010", "01100", "00000", "00000", "00000"],
C: ["01110", "10001", "10000", "10000", "10000", "10001", "01110"],
F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
};
const N = 50;
type Pt = [number, number];
const build = (fn: (x: number, y: number) => boolean) =>
Array.from({ length: N }, (_, y) =>
Array.from({ length: N }, (_, x) => (fn(x + 0.5, y + 0.5) ? "1" : "0")).join("")
);
const inCircle = (x: number, y: number, cx: number, cy: number, r: number) =>
(x - cx) ** 2 + (y - cy) ** 2 <= r * r;
const segDist = (x: number, y: number, ax: number, ay: number, bx: number, by: number) => {
const dx = bx - ax;
const dy = by - ay;
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)));
return Math.hypot(x - (ax + t * dx), y - (ay + t * dy));
};
const inPoly = (x: number, y: number, pts: Pt[]) => {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const [xi, yi] = pts[i];
const [xj, yj] = pts[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
};
const cloud = (x: number, y: number, dy: number) =>
inCircle(x, y, 16, 30 + dy, 8) ||
inCircle(x, y, 26, 24 + dy, 11) ||
inCircle(x, y, 36, 31 + dy, 7) ||
(x >= 16 && x <= 36 && y >= 31 + dy && y <= 38 + dy);
const rad = (deg: number) => (deg * Math.PI) / 180;
const SUN_RAYS = Array.from({ length: 8 }, (_, i) => {
const a = rad(i * 45);
return [25 + 14 * Math.cos(a), 25 + 14 * Math.sin(a), 25 + 22 * Math.cos(a), 25 + 22 * Math.sin(a)];
});
const DROPS = [
[14, 36],
[22, 39],
[30, 36],
[38, 39],
];
const BOLT: Pt[] = [
[27, 34],
[35, 34],
[30, 40],
[36, 40],
[23, 49],
[26, 42],
[20, 42],
];
const FLAKE_SEGS: number[][] = (() => {
const segs: number[][] = [];
for (let i = 0; i < 6; i++) {
const a = rad(i * 60);
const ex = 25 + 21 * Math.cos(a);
const ey = 25 + 21 * Math.sin(a);
segs.push([25, 25, ex, ey]);
for (const [d, len] of [
[11, 6],
[16, 4],
]) {
const bx = 25 + d * Math.cos(a);
const by = 25 + d * Math.sin(a);
for (const off of [-60, 60]) {
const b = rad(i * 60 + off);
segs.push([bx, by, bx + len * Math.cos(b), by + len * Math.sin(b)]);
}
}
}
return segs;
})();
const ICONS: Record<string, string[]> = {
sun: build(
(x, y) =>
inCircle(x, y, 25, 25, 10) || SUN_RAYS.some(([ax, ay, bx, by]) => segDist(x, y, ax, ay, bx, by) < 1.4)
),
cloud: build((x, y) => cloud(x, y, 0)),
rain: build(
(x, y) =>
cloud(x, y, -5) || DROPS.some(([cx, y0]) => segDist(x, y, cx, y0, cx - 3, y0 + 8) < 1.2)
),
storm: build((x, y) => cloud(x, y, -5) || inPoly(x, y, BOLT)),
snow: build(
(x, y) =>
inCircle(x, y, 25, 25, 2.2) ||
FLAKE_SEGS.some(([ax, ay, bx, by]) => segDist(x, y, ax, ay, bx, by) < 1.2)
),
};
const ALL: Record<string, string[]> = { ...GLYPHS, ...ICONS };
export const CONDITIONS = [
{ name: "Sunny", icon: "sun", temperature: 24, color: "text-amber-300" },
{ name: "Cloudy", icon: "cloud", temperature: 22, color: "text-slate-200" },
{ name: "Rainy", icon: "rain", temperature: 18, color: "text-sky-300" },
{ name: "Stormy", icon: "storm", temperature: 16, color: "text-violet-300" },
{ name: "Snowy", icon: "snow", temperature: -2, color: "text-cyan-200" },
] as const;
type Unit = "C" | "F";
const toUnit = (celsius: number, unit: Unit) =>
unit === "C" ? celsius : Math.round((celsius * 9) / 5 + 32);
const GRAIN = `url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="3" stitchTiles="stitch"/></filter><rect width="100%" height="100%" filter="url(#n)"/></svg>`,
)}")`;
type Finish = {
name: string;
swatch: string;
lit: string;
mid: string;
body: string;
deep: string;
base: string;
wellTop: string;
wellBottom: string;
screen: [string, string, string];
key: [string, string, string];
menu: string;
};
const FINISHES: Finish[] = [
{ name: "Black", swatch: "#2a2a2a", lit: "#6e6e6e", mid: "#3c3c3c", body: "#2a2a2a", deep: "#1c1c1c", base: "#121212", wellTop: "#050505", wellBottom: "#242424", screen: ["#2c2c2c", "#161616", "#0a0a0a"], key: ["#4a4a4a", "#2e2e2e", "#1a1a1a"], menu: "#1a1a1a" },
{ name: "Orange", swatch: "#ff4c1a", lit: "#ff8a52", mid: "#ff4e1c", body: "#e83a12", deep: "#c42c0c", base: "#8e220c", wellTop: "#120806", wellBottom: "#4a1c12", screen: ["#3a1c16", "#180e0c", "#0e0908"], key: ["#ff5a2a", "#d63612", "#9a240c"], menu: "#3a140e" },
{ name: "Blue", swatch: "#4e6c84", lit: "#7b94a6", mid: "#4e6c84", body: "#34556c", deep: "#243e54", base: "#183044", wellTop: "#060d14", wellBottom: "#1b3144", screen: ["#244360", "#12283b", "#091522"], key: ["#45627a", "#2a455c", "#1a3348"], menu: "#152a3c" },
{ name: "Green", swatch: "#3f7a56", lit: "#8aab96", mid: "#4f7a5e", body: "#365844", deep: "#243c2e", base: "#18281e", wellTop: "#07110c", wellBottom: "#1c3326", screen: ["#24382c", "#122018", "#0a120e"], key: ["#4d735c", "#2c4a38", "#1a3024"], menu: "#1a2e24" },
{ name: "Red", swatch: "#a33b3b", lit: "#c48a8a", mid: "#8a4545", body: "#6a3030", deep: "#4a2020", base: "#341616", wellTop: "#120808", wellBottom: "#3a1c1c", screen: ["#3a2424", "#1c1212", "#100a0a"], key: ["#8a4545", "#5c2c2c", "#3a1a1a"], menu: "#2e1818" },
];
const BLUE = FINISHES[2];
const ORANGE = FINISHES[1];
const SHELL_SHADOW = [
"inset 0 1px 0 rgba(255,255,255,0.55)",
"inset 0 -3px 6px rgba(0,0,0,0.42)",
"0 1px 0 rgba(0,0,0,0.45)",
"0 22px 40px rgba(0,0,0,0.42)",
"0 4px 10px rgba(0,0,0,0.28)",
].join(", ");
function shellPaint(finish: Finish) {
return {
backgroundColor: finish.base,
backgroundImage: `linear-gradient(180deg, rgba(255,255,255,0.16), transparent 14%), linear-gradient(165deg, ${finish.lit} 0%, ${finish.mid} 7%, ${finish.body} 20%, ${finish.deep} 52%, ${finish.base} 100%)`,
boxShadow: SHELL_SHADOW,
};
}
function wellPaint(finish: Finish) {
return {
background: `linear-gradient(180deg, ${finish.wellTop} 0%, ${finish.wellBottom} 100%)`,
boxShadow:
"inset 0 8px 14px rgba(0,0,0,0.75), inset 0 -1px 0 rgba(255,255,255,0.2), 0 1px 0 rgba(255,255,255,0.08)",
};
}
export function Shell({
children,
className,
finish = BLUE,
}: {
children: React.ReactNode;
className?: string;
finish?: Finish;
}) {
return (
<div style={shellPaint(finish)} className={`relative isolate overflow-hidden text-white ${className ?? ""}`}>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-80 mix-blend-overlay"
style={{ backgroundImage: GRAIN, backgroundSize: "180px 180px" }}
/>
<div className="relative h-full">{children}</div>
</div>
);
}
export const wellStyle = wellPaint(BLUE);
function keyPaint(finish: Finish) {
return {
backgroundImage: `linear-gradient(180deg, ${finish.key[0]} 0%, ${finish.key[1]} 42%, ${finish.key[2]} 100%)`,
};
}
const keyClass =
"border border-black/40 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.32),inset_0_-2px_4px_rgba(0,0,0,0.45),0_1px_2px_rgba(0,0,0,0.4)] transition-[transform,box-shadow,filter] duration-150 ease-out hover:brightness-110 active:translate-y-px active:scale-[0.99] active:shadow-[inset_0_2px_5px_rgba(0,0,0,0.55)] motion-reduce:transition-none";
export function Bitmap({ glyph, large = false, className }: { glyph: string; large?: boolean; className?: string }) {
const rows = ALL[glyph] ?? BLANK;
const cols = rows[0].length;
return (
<svg
viewBox={`0 0 ${cols} ${rows.length}`}
className={className ?? (large ? "w-[clamp(72px,30cqi,190px)]" : "w-[clamp(18px,7cqi,40px)]")}
focusable="false"
>
{rows.join("").split("").map((bit, index) => (
<circle
key={index}
cx={(index % cols) + 0.5}
cy={Math.floor(index / cols) + 0.5}
r={large ? 0.34 : 0.38}
style={{ transitionDelay: `${((index % cols) + Math.floor(index / cols)) * (large ? 6 : 14)}ms` }}
className={`transition-[fill] duration-300 motion-reduce:transition-none ${bit === "1" ? "fill-current" : "fill-white/5"}`}
/>
))}
</svg>
);
}
export function WeatherBitmap() {
const [selected, setSelected] = useState<string>(CONDITIONS[0].name);
const [unit, setUnit] = useState<Unit>("C");
const [finishName, setFinishName] = useState(ORANGE.name);
const weather = CONDITIONS.find((condition) => condition.name === selected) ?? CONDITIONS[0];
const finish = FINISHES.find((item) => item.name === finishName) ?? ORANGE;
const temp = toUnit(weather.temperature, unit);
const chars = [...`${temp}°${unit}`];
return (
<div className="mx-auto flex w-full max-w-2xl flex-col items-center justify-center gap-4 sm:flex-row sm:gap-5">
<div style={{ containerType: "inline-size" }} className="relative min-w-0 w-full max-w-xl">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-12 -bottom-3 h-8 rounded-full bg-black/45 blur-xl" />
<Shell finish={finish} className="rounded-[28px] p-3 sm:p-3.5">
<div style={wellPaint(finish)} className="rounded-[18px] p-[5px]">
<div style={{ background: `radial-gradient(circle at 80% 15%, ${finish.screen[0]} 0%, ${finish.screen[1]} 40%, ${finish.screen[2]} 85%)` }} className="rounded-[14px] border border-black/70 px-5 pt-5 shadow-[inset_0_0_28px_rgba(0,0,0,0.55)] sm:px-7">
<div className="flex items-center justify-between gap-3 font-mono text-[10px] uppercase tracking-[0.2em] text-white/55">
<span>Pixel weather</span>
<span>Sample data</span>
</div>
<p className="sr-only" aria-live="polite">
{weather.name}, {temp} degrees {unit === "C" ? "Celsius" : "Fahrenheit"}. {finish.name} device. Sample weather data.
</p>
<div className="flex min-h-56 items-center justify-between gap-3 py-5 sm:min-h-64 sm:gap-6">
<div className="min-w-0">
<p className="mb-5 font-mono text-[10px] uppercase tracking-[0.18em] text-white/50">Current conditions</p>
<div aria-hidden="true" className="flex gap-1 text-white sm:gap-1.5">
{chars.map((character, index) => (
<Bitmap key={index} glyph={character} />
))}
</div>
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{weather.name}</p>
</div>
<div aria-hidden="true" className={`${weather.color} shrink-0 transition-colors duration-300 motion-reduce:transition-none`}>
<Bitmap glyph={weather.icon} large />
</div>
</div>
</div>
</div>
<div className="flex items-end justify-between gap-4 px-2 pb-1 pt-3.5">
<div className="grid gap-1.5 font-mono text-[10px] uppercase tracking-wider text-white/70">
<span>Condition</span>
<Select value={selected} onValueChange={(value) => value !== null && setSelected(value)}>
<SelectTrigger
aria-label="Condition"
style={keyPaint(finish)}
className={`h-10 min-w-32 rounded-lg px-3 font-mono text-xs [&_svg]:text-white/70 ${keyClass}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent style={{ backgroundColor: finish.menu }} className="border border-white/15 text-white ring-white/15">
{CONDITIONS.map((condition) => (
<SelectItem key={condition.name} value={condition.name} className="focus:bg-white/15 focus:text-white! data-highlighted:bg-white/15 data-highlighted:text-white! focus:**:text-white!">
{condition.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-1.5" role="group" aria-label="Temperature unit">
{(["C", "F"] as const).map((u) => (
<button
key={u}
type="button"
aria-pressed={unit === u}
onClick={() => setUnit(u)}
className={`rounded-lg px-3.5 py-2.5 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-white ${
unit === u
? "border border-black/25 bg-[linear-gradient(180deg,#ffffff_0%,#e4edf3_100%)] text-[#122033] shadow-[inset_0_1px_0_rgba(255,255,255,1),inset_0_-1px_2px_rgba(0,0,0,0.16),0_1px_2px_rgba(0,0,0,0.4)] transition-[transform,box-shadow] duration-150 ease-out active:translate-y-px active:scale-[0.99] motion-reduce:transition-none"
: keyClass
}`}
style={unit === u ? undefined : keyPaint(finish)}
>
°{u}
</button>
))}
</div>
</div>
</Shell>
</div>
<div role="radiogroup" aria-label="Device color" className="flex shrink-0 flex-row gap-3 sm:flex-col">
{FINISHES.map((item) => {
const active = item.name === finish.name;
return (
<button
key={item.name}
type="button"
role="radio"
aria-checked={active}
aria-label={item.name}
onClick={() => setFinishName(item.name)}
style={{ backgroundColor: item.swatch }}
className={`size-4 rounded-full border border-white/30 shadow-[inset_0_1px_1px_rgba(255,255,255,0.45),inset_0_-1px_2px_rgba(0,0,0,0.4)] outline-none transition-transform duration-150 ease-out hover:scale-110 focus-visible:ring-2 focus-visible:ring-white motion-reduce:transition-none ${
active ? "scale-110 ring-2 ring-white ring-offset-2 ring-offset-(--craft-page)" : ""
}`}
/>
);
})}
</div>
</div>
);
}