wxcn
componentmoon-forecastsource: ok · hit
Moon Forecast
Lunar phase card with local cycle estimates.
npx shadcn add "https://wxcn.dev/r/react/moon-forecast.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
cardbutton
Files7 · source cached 10/14/2026, 3:06:42 PM
packages/react/src/components/wxcn/moon-forecast.tsx
'use client';
import * as React from 'react';
import type { IconSet } from './forecast-icons';
import { forecastDays, type ForecastDay } from '@/lib/wxcn/forecast-days.js';
import type { ForecastType, LocationInput, MoonForecast as MoonData } from '@/lib/wxcn/types.js';
import { getMoonForecast, getUpcomingMoonPhases, sampleMoon } from '@/lib/wxcn/moon.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { ForecastScreens } from './forecast-screens';
import MoonDisc from './moon-disc';
export type MoonForecastProps = {
iconType?: IconSet;
interactive?: boolean;
timeZone?: string;
type?: ForecastType;
size?: 'sm' | 'default' | 'lg';
density?: 'compact' | 'comfortable';
className?: string;
location?: LocationInput;
forecast?: MoonData;
sourceLabel?: string;
};
const defaultLocation: LocationInput = {
label: 'Austin, TX',
latitude: 30.2672,
longitude: -97.7431
};
function MoonCardView({
day,
action,
forecast,
date,
type,
size,
density,
sourceLabel,
location
}: {
day?: ForecastDay;
action?: React.ReactNode;
forecast: MoonData;
date: (value: string) => string;
type: ForecastType;
size: 'sm' | 'default' | 'lg';
density: 'compact' | 'comfortable';
sourceLabel: string;
location: LocationInput;
}) {
const view =
day && day.entries[0].time !== Date.parse(forecast.date)
? getMoonForecast(new Date(day.entries[0].time))
: forecast;
return (
<>
<CardHeader>
<CardTitle>{day?.label ?? 'Moon phase'}</CardTitle>
<CardDescription>{location.label}</CardDescription>
{action}
</CardHeader>
<CardContent
className={cn(
'grid px-[var(--card-spacing,var(--wxcn-spacing))]',
density === 'compact' ? 'gap-3' : 'gap-5'
)}
>
<div
className={cn(
'flex items-center gap-4',
size === 'lg' && 'flex-col rounded-lg bg-muted/20 p-5 text-center'
)}
>
<MoonDisc
phase={view.phase ?? view.age / 29.530588853}
label={`${view.phaseName}, ${view.illumination}% illuminated`}
className={
size === 'sm'
? 'size-16 shrink-0'
: size === 'lg'
? 'size-36 shrink-0'
: 'size-24 max-w-[28cqw] shrink-0'
}
/>
<div>
<p
style={{ fontSize: 'clamp(1rem,6cqw,1.5rem)' }}
className={cn('font-medium tracking-tight', size === 'sm' ? 'text-lg' : 'text-2xl')}
>
{view.phaseName}
</p>
<p className="mt-2 text-sm text-muted-foreground">{view.illumination}% illuminated</p>
</div>
</div>
{type !== 'simple' && (
<dl className="moon-data divide-y text-sm">
<div
className={cn(
'flex justify-between gap-4 pb-3',
density === 'compact' && 'py-[0.4rem]'
)}
>
<dt className="text-muted-foreground">Moon age</dt>
<dd>{view.age} days</dd>
</div>
<div
className={cn(
'flex justify-between gap-4 py-3',
density === 'compact' && 'py-[0.4rem]'
)}
>
<dt className="text-muted-foreground">Next full moon</dt>
<dd>{date(view.nextFullMoon)}</dd>
</div>
<div
className={cn(
'flex justify-between gap-4 pt-3',
density === 'compact' && 'py-[0.4rem]'
)}
>
<dt className="text-muted-foreground">Next new moon</dt>
<dd>{date(view.nextNewMoon)}</dd>
</div>
</dl>
)}
{!day && sourceLabel && (
<p className="text-[10px] text-muted-foreground">
{sourceLabel} · {date(view.date)}
</p>
)}
</CardContent>
</>
);
}
export function MoonForecast({
iconType,
interactive = false,
timeZone,
type = 'summary',
size = 'default',
density = 'comfortable',
className,
location = defaultLocation,
forecast = sampleMoon,
sourceLabel = ''
}: MoonForecastProps) {
const [visitorTimeZone, setVisitorTimeZone] = React.useState('UTC');
React.useEffect(() => setVisitorTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone), []);
const displayTimeZone = timeZone ?? location.timeZone ?? visitorTimeZone;
const date = React.useCallback(
(value: string) =>
new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
timeZone: displayTimeZone
}).format(new Date(value)),
[displayTimeZone]
);
const phases = React.useMemo(
() => getUpcomingMoonPhases(new Date(forecast.date)),
[forecast.date]
);
const days = React.useMemo(
() =>
forecastDays(
phases.map((value) => ({
time: Date.parse(value.date),
label: value.phaseName,
summary: `${value.illumination}% illuminated`,
details: ''
})),
displayTimeZone
),
[displayTimeZone, phases]
);
return (
<Card
style={
{
containerType: 'inline-size',
['--wxcn-spacing' as string]: size === 'sm' ? '1rem' : '1.5rem'
} as React.CSSProperties
}
data-density={density}
data-card-size={size}
data-size={size === 'sm' ? 'sm' : 'default'}
className={cn('relative isolate min-w-0 overflow-hidden', className)}
>
<ForecastScreens
interactive={interactive}
iconType={iconType}
days={days}
title="Moon"
density={density}
sourceLabel={sourceLabel}
actionLabel="Next phases"
summaryTitle="Next phases"
summary={(availableHeight) => (
<div
className="grid h-full auto-rows-[minmax(32px,1fr)] overflow-y-auto"
data-slot="upcoming-moon-phases"
>
{phases.map((phase) => (
<div
key={phase.date}
className="flex min-h-0 items-center gap-3 border-b text-xs last:border-0"
>
<div
className="shrink-0"
style={{ width: Math.max(16, Math.min(32, availableHeight / 7 - 4)) }}
>
<MoonDisc phase={phase.phase} label={phase.phaseName} className="size-full" />
</div>
<span className="min-w-0 flex-1 font-medium">{phase.phaseName}</span>
<time dateTime={phase.date} className="shrink-0 text-muted-foreground">
{date(phase.date)}
</time>
</div>
))}
</div>
)}
detail={(day, action) => (
<MoonCardView
day={day}
forecast={forecast}
date={date}
type={type}
size={size}
density={density}
sourceLabel={sourceLabel}
location={location}
action={action(false)}
/>
)}
>
{(_openDay, action) => (
<MoonCardView
forecast={forecast}
date={date}
type={type}
size={size}
density={density}
sourceLabel={sourceLabel}
location={location}
action={action(false)}
/>
)}
</ForecastScreens>
</Card>
);
}
export default MoonForecast;
packages/react/src/components/wxcn/forecast-screens.tsx
'use client';
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ForecastIcon, type IconSet } from './forecast-icons';
import type { ForecastDay } from '@/lib/wxcn/forecast-days.js';
export type ForecastAction = (onSurface: boolean) => ReactNode;
export type OpenForecastDay = (time: string, trigger: HTMLElement) => void;
export interface ForecastScreensProps {
interactive: boolean;
days: ForecastDay[];
title: string;
density: string;
sourceLabel: string;
summary?: (availableHeight: number) => ReactNode;
daySummary?: (day: ForecastDay) => ReactNode;
actionLabel?: string;
summaryTitle?: string;
showWeek?: boolean;
iconType?: IconSet;
flush?: boolean;
detail: (day: ForecastDay, action: ForecastAction) => ReactNode;
children: (
openDay: OpenForecastDay,
action: ForecastAction,
overviewVisible: boolean
) => ReactNode;
}
export function ForecastScreens({
interactive,
days,
title,
summary,
daySummary,
actionLabel = 'View week',
summaryTitle = 'Upcoming tides',
showWeek = true,
iconType,
flush = false,
detail,
children
}: ForecastScreensProps) {
const [screen, setScreen] = useState<'overview' | 'week' | 'day'>('overview');
const [selectedKey, setSelectedKey] = useState('');
const [availableHeight, setAvailableHeight] = useState(0);
const surface = useRef<HTMLDivElement>(null);
const table = useRef<HTMLDivElement>(null);
const host = useRef<HTMLElement | null>(null);
const fromWeek = useRef(false);
const originIndex = useRef(0);
const originTrigger = useRef<HTMLElement | null>(null);
const focusTarget = useRef<'surface' | 'origin' | 'day' | null>(null);
const selected = days.find((day) => day.key === selectedKey);
const active = interactive && (screen !== 'day' || selected) ? screen : 'overview';
useEffect(() => {
if (active !== screen) setScreen('overview');
}, [active, screen]);
useLayoutEffect(() => {
if (active !== 'week' || !table.current) return;
const node = table.current;
const update = () => setAvailableHeight(node.clientHeight);
const observer = new ResizeObserver(update);
observer.observe(node);
update();
return () => observer.disconnect();
}, [active]);
useLayoutEffect(() => {
const target = focusTarget.current;
if (!target) return;
if (target === 'surface') {
surface.current?.focus({ preventScroll: true });
focusTarget.current = null;
return;
}
if ((target === 'day' && active !== 'week') || (target === 'origin' && active !== 'overview'))
return;
// Wait for the retained overview or resized detail surface to become visible.
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
const trigger =
target === 'day'
? host.current?.querySelector<HTMLButtonElement>(`[data-forecast-day="${selectedKey}"]`)
: originTrigger.current?.isConnected
? originTrigger.current
: host.current?.querySelectorAll<HTMLButtonElement>('button')[originIndex.current];
trigger?.focus({ preventScroll: true });
if (document.activeElement === trigger) focusTarget.current = null;
});
});
return () => cancelAnimationFrame(frame);
}, [active, selectedKey, availableHeight]);
function open(next: 'week' | 'day', trigger: HTMLElement, key = '') {
if (active === 'overview') {
host.current = trigger.closest<HTMLElement>('[data-slot=card]');
originTrigger.current = trigger;
originIndex.current = host.current
? [...host.current.querySelectorAll('button')].indexOf(trigger as HTMLButtonElement)
: 0;
}
fromWeek.current = active === 'week';
setSelectedKey(key);
focusTarget.current = 'surface';
setScreen(next);
}
const openDay: OpenForecastDay = (time, trigger) => {
const day = days.find((day) => day.entries.some((entry) => entry.time === Date.parse(time)));
if (day) open('day', trigger, day.key);
};
function back() {
if (active === 'day' && fromWeek.current) {
focusTarget.current = 'day';
setScreen('week');
} else {
focusTarget.current = 'origin';
setScreen('overview');
}
}
const weekAction: ForecastAction = (onSurface) =>
interactive && showWeek ? (
<CardAction>
<Button
type="button"
variant="ghost"
size="sm"
className={`h-6 border border-transparent px-1.5 text-[10px] font-medium ${onSurface ? 'text-white/80 hover:bg-white/10 hover:text-white' : 'text-muted-foreground hover:text-foreground'}`}
aria-label={summary ? actionLabel : `View ${title.toLowerCase()} week`}
onClick={(event) => open('week', event.currentTarget)}
>
{actionLabel}
</Button>
</CardAction>
) : null;
const backAction: ForecastAction = (onSurface) => (
<CardAction>
<Button
type="button"
variant="ghost"
size="sm"
className={`h-6 gap-1 border border-transparent px-1.5 text-[10px] ${onSurface ? 'text-white/80 hover:bg-white/10 hover:text-white' : 'text-muted-foreground'}`}
onClick={back}
>
<ForecastIcon name="arrowDown" iconSet={iconType} className="size-3 rotate-90" />
Back
</Button>
</CardAction>
);
return (
<>
<div
className="contents"
style={{ visibility: active === 'overview' ? 'visible' : 'hidden' }}
inert={active !== 'overview'}
aria-hidden={active !== 'overview'}
>
{children(openDay, weekAction, active === 'overview')}
</div>
{active !== 'overview' && (
<div
ref={surface}
tabIndex={-1}
role="group"
aria-label={
active === 'day'
? `${selected?.label} ${title.toLowerCase()} forecast`
: summary
? summaryTitle
: `${title} • Week`
}
data-slot="forecast-screen"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
back();
}
}}
className={`absolute inset-0 z-10 flex min-h-0 flex-col overflow-hidden rounded-[inherit] bg-card text-card-foreground outline-none ${active === 'day' && flush ? 'gap-0' : active === 'week' ? 'gap-2 py-3' : 'gap-[var(--card-spacing,var(--wxcn-spacing,1.5rem))] py-[var(--card-spacing,var(--wxcn-spacing,1.5rem))]'}`}
>
{active === 'day' && selected ? (
detail(selected, backAction)
) : (
<>
<CardHeader className="shrink-0">
<CardTitle className="truncate">
{summary ? summaryTitle : `${title} • Week`}
</CardTitle>
{backAction(false)}
</CardHeader>
<CardContent className="min-h-0 min-w-0 flex-1">
<div ref={table} className="h-full min-h-0" data-slot="forecast-week-table">
{summary ? (
summary(availableHeight)
) : (
<div
className={`grid h-full content-start gap-x-3 overflow-y-auto ${!daySummary && availableHeight < days.length * 28 ? 'grid-cols-2' : 'grid-cols-1'}`}
>
{days.map((day) => (
<button
key={day.key}
type="button"
data-forecast-day={day.key}
style={{
height: daySummary
? Math.max(32, availableHeight / days.length)
: Math.min(
28,
availableHeight /
(!daySummary && availableHeight < days.length * 28
? Math.ceil(days.length / 2)
: days.length)
),
fontSize: daySummary
? `clamp(12px, min(4.5cqw, ${availableHeight / days.length / 3}px), 20px)`
: undefined
}}
className="flex min-h-0 min-w-0 items-center justify-between gap-2 border-b text-left text-[11px] hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring"
aria-label={`View details for ${day.label}`}
onClick={(event) => open('day', event.currentTarget, day.key)}
>
{daySummary ? (
daySummary(day)
) : (
<>
<span className="shrink-0 font-medium">{day.label}</span>
<span
className="truncate text-muted-foreground"
title={day.entries.map((entry) => entry.summary).join(' · ')}
>
{day.entries.map((entry) => entry.summary).join(' · ')}
</span>
</>
)}
</button>
))}
</div>
)}
</div>
</CardContent>
</>
)}
</div>
)}
</>
);
}
packages/react/src/components/wxcn/moon-disc.tsx
import * as React from 'react';
import { cn } from '@/lib/utils';
export type MoonDiscProps = React.SVGProps<SVGSVGElement> & {
phase?: number;
label?: string;
};
export function MoonDisc({
phase = 0.5,
label = 'Full moon',
className = 'size-24',
...props
}: MoonDiscProps) {
const id = React.useId().replace(/:/g, '');
const p = ((phase % 1) + 1) % 1;
const waxing = p <= 0.5;
const sign = waxing ? 1 : -1;
const terminator = Math.cos(p * Math.PI * 2) * sign;
const points = Array.from({ length: 97 }, (_, i) => {
const y = 1 - i / 48;
return `${50 + 46 * Math.sqrt(Math.max(0, 1 - y * y)) * terminator} ${50 + 46 * y}`;
});
const light = `M50 4 A46 46 0 0 ${waxing ? 1 : 0} 50 96 L${points.join(' L')} Z`;
return (
<svg viewBox="0 0 100 100" className={cn(className)} role="img" aria-label={label} {...props}>
<defs>
<radialGradient id={`${id}-surface`} cx="42%" cy="38%" r="65%">
<stop stopColor="#f1f1ed" />
<stop offset=".72" stopColor="#d9dad5" />
<stop offset="1" stopColor="#a8aca8" />
</radialGradient>
</defs>
<circle cx="50" cy="50" r="46" fill="#30343a" />
<path d={light} fill={`url(#${id}-surface)`} />
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="currentColor"
strokeOpacity=".08"
strokeWidth=".5"
/>
</svg>
);
}
export default MoonDisc;
packages/react/src/icons/forecast-icons.tsx
import { IconPlaceholder } from '@/components/icon-placeholder';
export type IconName = 'arrowUp' | 'arrowDown' | 'weather' | 'sun' | 'moon' | 'tide' | 'wind' | 'rain' | 'snow';
export type IconSet = 'lucide' | 'tabler' | 'phosphor' | 'hugeicons' | 'remixicon';
export type ForecastIconProps = { name: IconName; iconSet?: IconSet; className?: string };
export function ForecastIcon({ name, className = 'size-5' }: ForecastIconProps) {
switch (name) {
case 'arrowUp': return <IconPlaceholder lucide="ArrowUpIcon" tabler="IconArrowUp" phosphor="ArrowUpIcon" hugeicons="ArrowUp02Icon" remixicon="RiArrowUpLine" className={className} aria-hidden="true" />;
case 'arrowDown': return <IconPlaceholder lucide="ArrowDownIcon" tabler="IconArrowDown" phosphor="ArrowDownIcon" hugeicons="ArrowDown02Icon" remixicon="RiArrowDownLine" className={className} aria-hidden="true" />;
case 'weather': return <IconPlaceholder lucide="CloudSunIcon" tabler="IconCloud" phosphor="CloudSunIcon" hugeicons="SunCloud02Icon" remixicon="RiCloudy2Line" className={className} aria-hidden="true" />;
case 'sun': return <IconPlaceholder lucide="SunIcon" tabler="IconSun" phosphor="SunIcon" hugeicons="Sun03Icon" remixicon="RiSunLine" className={className} aria-hidden="true" />;
case 'moon': return <IconPlaceholder lucide="MoonIcon" tabler="IconMoon" phosphor="MoonIcon" hugeicons="Moon02Icon" remixicon="RiMoonLine" className={className} aria-hidden="true" />;
case 'tide': return <IconPlaceholder lucide="WavesIcon" tabler="IconWavesElectricity" phosphor="WavesIcon" hugeicons="WaterfallUp01Icon" remixicon="RiWaterFlashLine" className={className} aria-hidden="true" />;
case 'wind': return <IconPlaceholder lucide="WindIcon" tabler="IconWind" phosphor="WindIcon" hugeicons="WindPower02Icon" remixicon="RiWindyLine" className={className} aria-hidden="true" />;
case 'rain': return <IconPlaceholder lucide="CloudRainIcon" tabler="IconCloudRain" phosphor="CloudRainIcon" hugeicons="CloudRainIcon" remixicon="RiRainyLine" className={className} aria-hidden="true" />;
case 'snow': return <IconPlaceholder lucide="SnowflakeIcon" tabler="IconSnowflake" phosphor="SnowflakeIcon" hugeicons="SnowIcon" remixicon="RiSnowyLine" className={className} aria-hidden="true" />;
}
}
packages/core/src/types.ts
export type CardSize = 'sm' | 'default' | 'lg';
export type CardDensity = 'compact' | 'comfortable';
export type ForecastType = 'summary' | 'detailed' | 'simple';
export type IconSet = 'hugeicons' | 'phosphor-svelte' | 'lucide' | 'tabler' | 'remix';
export type WeatherUnit = 'fahrenheit' | 'celsius';
export type TideUnit = 'ft' | 'meter';
export type LocationInput = {
label?: string;
latitude: number;
longitude: number;
station?: string;
timeZone?: string;
};
export type WeatherPeriod = {
name: string;
startTime: string;
endTime?: string;
temperature: number;
temperatureUnit: string;
windSpeed: string;
windDirection: string;
shortForecast: string;
detailedForecast: string;
isDaytime: boolean;
};
export type TidePrediction = {
time: string;
height: string;
type: 'H' | 'L';
};
export type MoonForecast = {
date: string;
phaseName: string;
/** Astronomical phase cycle: 0 new, 0.25 first quarter, 0.5 full. */
phase?: number;
illumination: number;
age: number;
nextFullMoon: string;
nextNewMoon: string;
};
/** Feet above MLLW. Prefer ISO 8601 timestamps with an explicit offset. */
export type TidePoint = { time: string; height: string };
export type TideReading = TidePoint;
/** Current observed conditions; daily extrema use temperatureUnit and the location's calendar day. */
export type CurrentWeather = WeatherPeriod & {
observedAt: string;
highToday?: number;
lowToday?: number;
};
export type WeatherBackground = 'none' | 'realistic' | 'dithered' | 'gradient';
packages/core/src/forecast-days.ts
export type ForecastEntry = { time: number; label: string; summary: string; details: string };
export type ForecastDay = { key: string; label: string; entries: ForecastEntry[] };
/** Group supplied periods in the location's calendar, without inventing missing days. */
export function forecastDays(entries: ForecastEntry[], timeZone: string): ForecastDay[] {
const key = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
const label = new Intl.DateTimeFormat('en-US', {
timeZone,
weekday: 'short',
month: 'short',
day: 'numeric'
});
const days = new Map<string, ForecastDay>();
for (const entry of entries
.filter((e) => Number.isFinite(e.time))
.toSorted((a, b) => a.time - b.time)) {
const id = key.format(entry.time);
if (!days.has(id)) days.set(id, { key: id, label: label.format(entry.time), entries: [] });
days.get(id)!.entries.push(entry);
}
return [...days.values()].slice(0, 7);
}
/** Resolve noon on a forecast calendar date in the location's time zone, including DST. */
export function forecastDayNoon(key: string, timeZone: string): number {
const noon = Date.parse(`${key}T12:00:00Z`);
const format = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
let instant = noon;
for (let attempt = 0; attempt < 3; attempt++) {
const parts = Object.fromEntries(
format.formatToParts(instant).map(({ type, value }) => [type, value])
);
const wallTime = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second)
);
const correction = noon - wallTime;
instant += correction;
if (!correction) break;
}
return instant;
}
packages/core/src/moon.ts
import { Body, Illumination, MoonPhase, SearchMoonPhase } from 'astronomy-engine';
import type { MoonForecast } from './types.js';
const phaseNames = [
'New Moon',
'Waxing Crescent',
'First Quarter',
'Waxing Gibbous',
'Full Moon',
'Waning Gibbous',
'Last Quarter',
'Waning Crescent'
];
export function getMoonForecast(date = new Date()): MoonForecast {
const phase = MoonPhase(date) / 360;
const previousNew = SearchMoonPhase(0, date, -40)!;
const nextNew = SearchMoonPhase(0, date, 40)!;
const nextFull = SearchMoonPhase(180, date, 40)!;
return {
date: date.toISOString(),
phase,
phaseName: phaseNames[Math.round(phase * 8) % 8],
illumination: Math.round(Illumination(Body.Moon, date).phase_fraction * 100),
age: Number(((date.getTime() - previousNew.date.getTime()) / 86400000).toFixed(1)),
nextFullMoon: nextFull.date.toISOString(),
nextNewMoon: nextNew.date.toISOString()
};
}
/** The next seven named phases, at their exact 45-degree lunar phase crossings. */
export function getUpcomingMoonPhases(date = new Date()): MoonForecast[] {
return phaseNames
.map((phaseName, index) => {
const event = SearchMoonPhase(index * 45, new Date(date.getTime() + 1000), 40)!;
return { ...getMoonForecast(event.date), phase: index / 8, phaseName };
})
.sort((a, b) => Date.parse(a.date) - Date.parse(b.date))
.slice(0, 7);
}
export const sampleMoon = getMoonForecast(new Date('2026-09-06T12:00:00-05:00'));