Web InnoventixFreeCode

Weather Card

Original · free

A weather widget card with current conditions and a mini forecast.

byWeb InnoventixReact + Tailwind
cardweathercards
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/card-weather.json
card-weather.tsx
"use client";

import { useId, useMemo, useState, type ReactNode } from "react";

type Condition = "sunny" | "partly" | "cloudy" | "rain" | "snow" | "storm";

interface DayForecast {
  key: string;
  label: string;
  condition: Condition;
  high: number;
  low: number;
  feels: number;
  humidity: number;
  wind: number;
  precip: number;
  desc: string;
}

interface CityWeather {
  city: string;
  region: string;
  localTime: string;
  days: DayForecast[];
}

const SAN_FRANCISCO: CityWeather = {
  city: "San Francisco",
  region: "California",
  localTime: "9:42 AM",
  days: [
    { key: "now", label: "Today", condition: "partly", high: 19, low: 12, feels: 16, humidity: 72, wind: 18, precip: 10, desc: "Partly cloudy" },
    { key: "tue", label: "Tue", condition: "sunny", high: 22, low: 13, feels: 21, humidity: 58, wind: 12, precip: 0, desc: "Clear skies" },
    { key: "wed", label: "Wed", condition: "cloudy", high: 18, low: 12, feels: 17, humidity: 76, wind: 15, precip: 20, desc: "Overcast" },
    { key: "thu", label: "Thu", condition: "rain", high: 15, low: 11, feels: 13, humidity: 88, wind: 26, precip: 80, desc: "Steady rain" },
    { key: "fri", label: "Fri", condition: "storm", high: 14, low: 10, feels: 11, humidity: 91, wind: 34, precip: 90, desc: "Thunderstorms" },
    { key: "sat", label: "Sat", condition: "partly", high: 20, low: 12, feels: 19, humidity: 64, wind: 14, precip: 15, desc: "Some clouds" },
  ],
};

const TOKYO: CityWeather = {
  city: "Tokyo",
  region: "Japan",
  localTime: "1:42 AM",
  days: [
    { key: "now", label: "Now", condition: "rain", high: 24, low: 20, feels: 26, humidity: 84, wind: 22, precip: 70, desc: "Light rain" },
    { key: "d1", label: "Wed", condition: "cloudy", high: 26, low: 21, feels: 27, humidity: 70, wind: 16, precip: 30, desc: "Cloudy" },
    { key: "d2", label: "Thu", condition: "sunny", high: 29, low: 22, feels: 31, humidity: 55, wind: 10, precip: 0, desc: "Sunny" },
    { key: "d3", label: "Fri", condition: "partly", high: 28, low: 22, feels: 30, humidity: 60, wind: 12, precip: 10, desc: "Partly cloudy" },
  ],
};

const REYKJAVIK: CityWeather = {
  city: "Reykjavík",
  region: "Iceland",
  localTime: "4:42 PM",
  days: [
    { key: "now", label: "Now", condition: "snow", high: 1, low: -4, feels: -8, humidity: 81, wind: 38, precip: 65, desc: "Snow showers" },
    { key: "d1", label: "Wed", condition: "cloudy", high: 2, low: -3, feels: -5, humidity: 78, wind: 30, precip: 25, desc: "Grey skies" },
    { key: "d2", label: "Thu", condition: "partly", high: 3, low: -2, feels: -1, humidity: 68, wind: 22, precip: 10, desc: "Breaking cloud" },
    { key: "d3", label: "Fri", condition: "snow", high: 0, low: -6, feels: -10, humidity: 85, wind: 41, precip: 70, desc: "Heavy snow" },
  ],
};

const CONDITION_LABEL: Record<Condition, string> = {
  sunny: "Sunny",
  partly: "Partly cloudy",
  cloudy: "Cloudy",
  rain: "Rain",
  snow: "Snow",
  storm: "Storm",
};

function toDisplay(celsius: number, unit: "c" | "f"): number {
  return unit === "c" ? Math.round(celsius) : Math.round((celsius * 9) / 5 + 32);
}

function WeatherGlyph({ condition, size = 24, animate }: { condition: Condition; size?: number; animate: boolean }) {
  const a = (cls: string) => (animate ? cls : "");
  const common = { width: size, height: size, viewBox: "0 0 48 48", fill: "none", "aria-hidden": true } as const;

  if (condition === "sunny") {
    return (
      <svg {...common}>
        <g className={a("cw-spin")} style={{ transformOrigin: "24px 24px" }}>
          {[...Array(8)].map((_, i) => (
            <line
              key={i}
              x1={24}
              y1={5}
              x2={24}
              y2={11}
              stroke="currentColor"
              strokeWidth={2.6}
              strokeLinecap="round"
              transform={`rotate(${i * 45} 24 24)`}
            />
          ))}
        </g>
        <circle cx={24} cy={24} r={8.5} fill="currentColor" />
      </svg>
    );
  }
  if (condition === "partly") {
    return (
      <svg {...common}>
        <g className={a("cw-spin")} style={{ transformOrigin: "18px 17px" }}>
          {[...Array(8)].map((_, i) => (
            <line key={i} x1={18} y1={4} x2={18} y2={8.5} stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" transform={`rotate(${i * 45} 18 17)`} />
          ))}
        </g>
        <circle cx={18} cy={17} r={6.5} fill="currentColor" />
        <g className={a("cw-drift")}>
          <path d="M15 34a7 7 0 0 1 .8-13.9 9 9 0 0 1 17 1.6A6.5 6.5 0 0 1 33 34Z" fill="currentColor" opacity={0.85} />
        </g>
      </svg>
    );
  }
  if (condition === "cloudy") {
    return (
      <svg {...common}>
        <g className={a("cw-drift")}>
          <path d="M14 33a8 8 0 0 1 1-16 10 10 0 0 1 19 2 7.5 7.5 0 0 1-1 14Z" fill="currentColor" opacity={0.9} />
        </g>
      </svg>
    );
  }
  if (condition === "rain") {
    return (
      <svg {...common}>
        <path d="M14 29a8 8 0 0 1 1-16 10 10 0 0 1 19 2 7.5 7.5 0 0 1-1 14H15Z" fill="currentColor" opacity={0.9} />
        {[13, 22, 31].map((x, i) => (
          <line key={x} x1={x} y1={33} x2={x - 2} y2={42} stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" className={a("cw-rain")} style={{ animationDelay: `${i * 0.22}s` }} />
        ))}
      </svg>
    );
  }
  if (condition === "snow") {
    return (
      <svg {...common}>
        <path d="M14 29a8 8 0 0 1 1-16 10 10 0 0 1 19 2 7.5 7.5 0 0 1-1 14H15Z" fill="currentColor" opacity={0.9} />
        {[13, 22, 31].map((x, i) => (
          <circle key={x} cx={x} cy={37} r={2} fill="currentColor" className={a("cw-flake")} style={{ animationDelay: `${i * 0.3}s` }} />
        ))}
      </svg>
    );
  }
  return (
    <svg {...common}>
      <path d="M14 28a8 8 0 0 1 1-16 10 10 0 0 1 19 2 7.5 7.5 0 0 1-1 14H15Z" fill="currentColor" opacity={0.9} />
      <path d="M24 30l-5 8h4l-2 7 8-10h-5l3-5Z" fill="#f59e0b" className={a("cw-flash")} />
    </svg>
  );
}

function StatChip({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
  return (
    <div className="flex items-center gap-2.5 rounded-2xl bg-white/60 px-3 py-2.5 ring-1 ring-slate-900/5 dark:bg-white/5 dark:ring-white/10">
      <span className="text-sky-500 dark:text-sky-300">{icon}</span>
      <div className="min-w-0 leading-tight">
        <div className="text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">{label}</div>
        <div className="text-sm font-semibold text-slate-700 dark:text-slate-100">{value}</div>
      </div>
    </div>
  );
}

const DropIcon = (
  <svg width={16} height={16} viewBox="0 0 24 24" fill="none" aria-hidden="true">
    <path d="M12 3s6 6.5 6 11a6 6 0 1 1-12 0c0-4.5 6-11 6-11Z" fill="currentColor" opacity={0.85} />
  </svg>
);
const WindIcon = (
  <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
    <path d="M3 8h11a3 3 0 1 0-3-3M3 16h15a3 3 0 1 1-3 3M3 12h9" />
  </svg>
);
const FeelsIcon = (
  <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
    <path d="M12 14V5a2 2 0 0 0-4 0v9a4 4 0 1 0 4 0Z" />
  </svg>
);
const PrecipIcon = (
  <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
    <path d="M8 19v2M12 19v3M16 19v2M6 16a5 5 0 0 1 .5-9.9A7 7 0 0 1 20 8a4.5 4.5 0 0 1-.5 8" />
  </svg>
);

function UnitToggle({ unit, onChange }: { unit: "c" | "f"; onChange: (u: "c" | "f") => void }) {
  return (
    <div className="inline-flex items-center rounded-full bg-slate-900/5 p-0.5 ring-1 ring-slate-900/5 dark:bg-white/10 dark:ring-white/10">
      {(["c", "f"] as const).map((u) => (
        <button
          key={u}
          type="button"
          aria-pressed={unit === u}
          onClick={() => onChange(u)}
          className={`rounded-full px-3 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
            unit === u ? "bg-white text-sky-600 shadow-sm dark:bg-slate-700 dark:text-sky-300" : "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
          }`}
        >
          °{u.toUpperCase()}
        </button>
      ))}
    </div>
  );
}

function PinButton({ pinned, onToggle }: { pinned: boolean; onToggle: () => void }) {
  return (
    <button
      type="button"
      onClick={onToggle}
      aria-pressed={pinned}
      aria-label={pinned ? "Unpin location" : "Pin location"}
      className={`grid h-9 w-9 place-items-center rounded-full ring-1 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
        pinned
          ? "bg-amber-400/90 text-amber-950 ring-amber-500/40"
          : "bg-white/70 text-slate-500 ring-slate-900/10 hover:text-amber-500 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10 dark:hover:text-amber-300"
      }`}
    >
      <svg width={16} height={16} viewBox="0 0 24 24" fill={pinned ? "currentColor" : "none"} stroke="currentColor" strokeWidth={2} strokeLinejoin="round" aria-hidden="true">
        <path d="M12 21s-7-5.2-7-11a7 7 0 0 1 14 0c0 5.8-7 11-7 11Z" />
        <circle cx={12} cy={10} r={2.4} fill={pinned ? "#fff7ed" : "none"} />
      </svg>
    </button>
  );
}

function FeaturedWeatherCard({ data, unit, onUnitChange, animate }: { data: CityWeather; unit: "c" | "f"; onUnitChange: (u: "c" | "f") => void; animate: boolean }) {
  const [selected, setSelected] = useState(data.days[0].key);
  const [pinned, setPinned] = useState(true);
  const [loading, setLoading] = useState(false);
  const [minutesAgo, setMinutesAgo] = useState(4);
  const groupId = useId();

  const active = useMemo(() => data.days.find((d) => d.key === selected) ?? data.days[0], [data.days, selected]);

  const refresh = () => {
    if (loading) return;
    setLoading(true);
    window.setTimeout(() => {
      setLoading(false);
      setMinutesAgo(0);
    }, 900);
  };

  return (
    <article className="w-full overflow-hidden rounded-3xl bg-gradient-to-br from-sky-50 via-white to-indigo-50 shadow-xl shadow-sky-900/5 ring-1 ring-slate-900/5 dark:from-slate-900 dark:via-slate-900 dark:to-indigo-950/60 dark:shadow-black/40 dark:ring-white/10">
      <div className="flex items-start justify-between gap-3 p-6 pb-3">
        <div className="min-w-0">
          <div className="flex items-center gap-2">
            <h3 className="truncate text-lg font-bold text-slate-800 dark:text-white">{data.city}</h3>
            <PinButton pinned={pinned} onToggle={() => setPinned((p) => !p)} />
          </div>
          <p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
            {data.region} · {data.localTime} local
          </p>
        </div>
        <div className="flex items-center gap-2">
          <UnitToggle unit={unit} onChange={onUnitChange} />
          <button
            type="button"
            onClick={refresh}
            disabled={loading}
            aria-label="Refresh forecast"
            className="grid h-9 w-9 place-items-center rounded-full bg-white/70 text-slate-500 ring-1 ring-slate-900/10 transition hover:text-sky-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:opacity-60 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10 dark:hover:text-sky-300 dark:focus-visible:ring-offset-slate-900"
          >
            <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={loading && animate ? "cw-spin-fast" : ""} style={{ transformOrigin: "12px 12px" }}>
              <path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 3v5h-5M21 12a9 9 0 0 1-15 6.7L3 16M3 21v-5h5" />
            </svg>
          </button>
        </div>
      </div>

      <div key={selected} className={animate ? "cw-fade" : ""}>
        <div className="flex items-center gap-5 px-6 pb-5">
          <div className="text-sky-500 dark:text-sky-300">
            <WeatherGlyph condition={active.condition} size={80} animate={animate} />
          </div>
          <div className="min-w-0">
            <div className="flex items-start">
              <span className="text-6xl font-bold leading-none tracking-tight text-slate-800 dark:text-white">{toDisplay(active.high, unit)}</span>
              <span className="mt-1 text-2xl font-semibold text-slate-400 dark:text-slate-500">°</span>
            </div>
            <p className="mt-1 text-sm font-medium text-slate-600 dark:text-slate-300">{active.desc}</p>
            <p className="text-sm text-slate-400 dark:text-slate-500">
              H {toDisplay(active.high, unit)}° · L {toDisplay(active.low, unit)}° · Feels {toDisplay(active.feels, unit)}°
            </p>
          </div>
        </div>

        <div className="grid grid-cols-2 gap-2.5 px-6 sm:grid-cols-4">
          <StatChip icon={FeelsIcon} label="Feels" value={`${toDisplay(active.feels, unit)}°`} />
          <StatChip icon={DropIcon} label="Humidity" value={`${active.humidity}%`} />
          <StatChip icon={WindIcon} label="Wind" value={`${active.wind} km/h`} />
          <StatChip icon={PrecipIcon} label="Precip" value={`${active.precip}%`} />
        </div>
      </div>

      <div className="mt-5 border-t border-slate-900/5 p-3 dark:border-white/10">
        <div className="flex items-center justify-between px-3 pb-2">
          <span className="text-xs font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">6-day forecast</span>
          <span className="text-xs text-slate-400 dark:text-slate-500" aria-live="polite">
            {loading ? "Updating…" : minutesAgo === 0 ? "Updated just now" : `Updated ${minutesAgo} min ago`}
          </span>
        </div>
        <div role="group" aria-label={`Forecast days for ${data.city}`} className="grid grid-cols-3 gap-1.5 sm:grid-cols-6">
          {data.days.map((day) => {
            const isSel = day.key === selected;
            return (
              <button
                key={day.key}
                type="button"
                onClick={() => setSelected(day.key)}
                aria-pressed={isSel}
                aria-label={`${day.label}: ${CONDITION_LABEL[day.condition]}, high ${toDisplay(day.high, unit)} degrees, low ${toDisplay(day.low, unit)} degrees`}
                className={`flex flex-col items-center gap-1.5 rounded-2xl px-1 py-3 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                  isSel ? "bg-sky-500/10 ring-1 ring-sky-500/40 dark:bg-sky-400/10" : "hover:bg-slate-900/[0.04] dark:hover:bg-white/5"
                }`}
              >
                <span className={`text-xs font-semibold ${isSel ? "text-sky-600 dark:text-sky-300" : "text-slate-500 dark:text-slate-400"}`}>{day.label}</span>
                <span className={isSel ? "text-sky-500 dark:text-sky-300" : "text-slate-400 dark:text-slate-500"}>
                  <WeatherGlyph condition={day.condition} size={26} animate={false} />
                </span>
                <span className="text-xs font-bold text-slate-700 dark:text-slate-100">{toDisplay(day.high, unit)}°</span>
                <span className="text-[11px] text-slate-400 dark:text-slate-500">{toDisplay(day.low, unit)}°</span>
              </button>
            );
          })}
        </div>
        <p className="sr-only" id={groupId}>
          Select a day to update the current conditions panel.
        </p>
      </div>
    </article>
  );
}

function CompactWeatherCard({ data, unit, animate }: { data: CityWeather; unit: "c" | "f"; animate: boolean }) {
  const [pinned, setPinned] = useState(false);
  const now = data.days[0];

  return (
    <article className="flex w-full flex-col overflow-hidden rounded-3xl bg-white shadow-lg shadow-slate-900/5 ring-1 ring-slate-900/5 dark:bg-slate-900 dark:shadow-black/30 dark:ring-white/10">
      <div className="flex items-center justify-between gap-2 px-5 pt-5">
        <div className="min-w-0">
          <h3 className="truncate text-base font-bold text-slate-800 dark:text-white">{data.city}</h3>
          <p className="text-xs text-slate-400 dark:text-slate-500">{data.localTime} local</p>
        </div>
        <PinButton pinned={pinned} onToggle={() => setPinned((p) => !p)} />
      </div>

      <div className="flex items-center justify-between gap-3 px-5 py-4">
        <div className="flex items-baseline">
          <span className="text-4xl font-bold tracking-tight text-slate-800 dark:text-white">{toDisplay(now.feels, unit)}</span>
          <span className="text-lg font-semibold text-slate-400 dark:text-slate-500">°{unit.toUpperCase()}</span>
        </div>
        <div className="text-right">
          <div className="text-sky-500 dark:text-sky-300">
            <WeatherGlyph condition={now.condition} size={44} animate={animate} />
          </div>
          <p className="mt-0.5 text-xs font-medium text-slate-500 dark:text-slate-400">{now.desc}</p>
        </div>
      </div>

      <div className="mt-auto flex items-center justify-between gap-1 border-t border-slate-900/5 px-3 py-3 dark:border-white/10">
        {data.days.slice(1, 4).map((day) => (
          <div key={day.key} className="flex flex-1 flex-col items-center gap-1 rounded-xl py-1">
            <span className="text-[11px] font-semibold text-slate-400 dark:text-slate-500">{day.label}</span>
            <span className="text-slate-400 dark:text-slate-500">
              <WeatherGlyph condition={day.condition} size={22} animate={false} />
            </span>
            <span className="text-xs font-bold text-slate-700 dark:text-slate-200">{toDisplay(day.high, unit)}°</span>
          </div>
        ))}
      </div>
    </article>
  );
}

export default function WeatherWidgetCard() {
  const [unit, setUnit] = useState<"c" | "f">("c");
  const prefersReduced = typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false;
  const animate = !prefersReduced;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:px-6 sm:py-24">
      <style>{`
        @keyframes cw-spin { to { transform: rotate(360deg); } }
        @keyframes cw-spin-fast { to { transform: rotate(360deg); } }
        @keyframes cw-drift { 0%,100% { transform: translateX(0); } 50% { transform: translateX(2px); } }
        @keyframes cw-rain { 0% { opacity: 0; transform: translateY(-4px); } 30% { opacity: 1; } 100% { opacity: 0; transform: translateY(6px); } }
        @keyframes cw-flake { 0% { opacity: 0; transform: translateY(-3px); } 40% { opacity: 1; } 100% { opacity: 0; transform: translateY(7px); } }
        @keyframes cw-flash { 0%,45%,100% { opacity: 1; } 50%,60% { opacity: 0.25; } }
        @keyframes cw-fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
        .cw-spin { animation: cw-spin 14s linear infinite; }
        .cw-spin-fast { animation: cw-spin-fast 0.7s linear infinite; }
        .cw-drift { animation: cw-drift 5s ease-in-out infinite; }
        .cw-rain { animation: cw-rain 1.1s ease-in infinite; }
        .cw-flake { animation: cw-flake 2s ease-in infinite; }
        .cw-flash { animation: cw-flash 3s ease-in-out infinite; }
        .cw-fade { animation: cw-fade 0.35s ease-out; }
        @media (prefers-reduced-motion: reduce) {
          .cw-spin, .cw-spin-fast, .cw-drift, .cw-rain, .cw-flake, .cw-flash, .cw-fade { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div className="mb-10 text-center">
          <h2 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-3xl">Weather</h2>
          <p className="mx-auto mt-2 max-w-md text-sm text-slate-500 dark:text-slate-400">
            Live conditions and a six-day outlook. Tap a day to see its details; the °C/°F switch applies everywhere.
          </p>
        </div>

        <div className="grid gap-6 lg:grid-cols-5">
          <div className="lg:col-span-3">
            <FeaturedWeatherCard data={SAN_FRANCISCO} unit={unit} onUnitChange={setUnit} animate={animate} />
          </div>
          <div className="grid gap-6 sm:grid-cols-2 lg:col-span-2 lg:grid-cols-1">
            <CompactWeatherCard data={TOKYO} unit={unit} animate={animate} />
            <CompactWeatherCard data={REYKJAVIK} unit={unit} animate={animate} />
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →