shadimage
wxcn
componenttide-forecastsource: ok · hit

Tide Forecast

Coastal tide predictions with a theme-aware chart.

npx shadcn add "https://wxcn.dev/r/react/tide-forecast.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

cardbadgechartbutton

Files7 · source cached 10/14/2026, 2:55:13 PM

packages/react/src/components/wxcn/tide-forecast.tsx
'use client';

import { useCallback, useEffect, useMemo, useState, type ComponentProps } from 'react';
import NumberFlow from '@number-flow/react';
import {
	Area,
	AreaChart,
	CartesianGrid,
	ReferenceDot,
	ReferenceLine,
	XAxis,
	YAxis
} from 'recharts';
import type { IconSet } from './forecast-icons';
import { ForecastScreens, type ForecastAction, type OpenForecastDay } from './forecast-screens';
import { forecastDays, type ForecastDay } from '@/lib/wxcn/forecast-days.js';
import type {
	ForecastType,
	LocationInput,
	TidePoint,
	TidePrediction,
	TideReading,
	TideUnit
} from '@/lib/wxcn/types.js';
import { sampleTideSeries, sampleTideTime, sampleTides } from '@/lib/wxcn/tides.js';
import { tideState, tideTimestamp } from '@/lib/wxcn/tide-state.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';

export type TideForecastProps = {
	interactive?: boolean;
	iconType?: IconSet;
	timeZone?: string;
	type?: ForecastType;
	size?: 'sm' | 'default' | 'lg';
	density?: 'compact' | 'comfortable';
	className?: string;
	unit?: TideUnit;
	location?: LocationInput;
	predictions?: TidePrediction[];
	example?: boolean;
	series?: TidePoint[];
	reading?: TideReading | null;
	at?: number;
	sourceLabel?: string;
};

const defaultLocation: LocationInput = {
	label: 'Galveston Pier 21, TX',
	latitude: 29.31,
	longitude: -94.7933,
	station: '8771450',
	timeZone: 'America/Chicago'
};

const chartConfig = {
	height: { label: 'Tide level', color: 'var(--chart-1)' }
};
type ChartPoint = { time: number; height: number };

const formatTime = (value: string | number, timeZone: string) =>
	new Intl.DateTimeFormat('en-US', {
		hour: 'numeric',
		minute: '2-digit',
		timeZone
	}).format(typeof value === 'string' ? tideTimestamp(value) : value);

const formatDateTime = (value: string, timeZone: string) =>
	new Intl.DateTimeFormat('en-US', {
		month: 'short',
		day: 'numeric',
		hour: 'numeric',
		minute: '2-digit',
		timeZone
	}).format(tideTimestamp(value));

function TideTooltipContent({
	onHover,
	displayTimeZone,
	unit,
	...props
}: ComponentProps<typeof ChartTooltipContent> & {
	onHover: (point: ChartPoint | null) => void;
	displayTimeZone: string;
	unit: TideUnit;
}) {
	const payloadPoint = props.payload?.[0]?.payload as Partial<ChartPoint> | undefined;
	useEffect(() => {
		onHover(
			payloadPoint?.time !== undefined && payloadPoint.height !== undefined
				? { time: payloadPoint.time, height: payloadPoint.height }
				: null
		);
	}, [onHover, payloadPoint?.height, payloadPoint?.time]);
	const symbol = unit === 'meter' ? 'm' : 'ft';
	const convertedHeight = (value: number) => (value * (unit === 'meter' ? 0.3048 : 1)).toFixed(1);
	return (
		<ChartTooltipContent
			{...props}
			labelFormatter={() =>
				payloadPoint?.time === undefined
					? 'Tide level'
					: formatTime(payloadPoint.time, displayTimeZone)
			}
			formatter={(value) => `${convertedHeight(Number(value))} ${symbol}`}
		/>
	);
}

export function TideForecast({
	interactive = false,
	iconType,
	timeZone,
	type = 'summary',
	size = 'default',
	density = 'comfortable',
	className = '',
	unit = 'ft',
	location = defaultLocation,
	predictions = sampleTides,
	example,
	series,
	reading = null,
	at,
	sourceLabel = 'Sample tides'
}: TideForecastProps) {
	const [visitorTimeZone, setVisitorTimeZone] = useState('UTC');
	const [clock, setClock] = useState<number | null>(null);
	const [hovered, setHovered] = useState<ChartPoint | null>(null);
	const [dayHovered, setDayHovered] = useState<ChartPoint | null>(null);
	useEffect(() => {
		setVisitorTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
		setClock(Date.now());
		const timer = window.setInterval(() => setClock(Date.now()), 60_000);
		return () => window.clearInterval(timer);
	}, []);

	const displayTimeZone = timeZone ?? location.timeZone ?? visitorTimeZone;
	const isSample = example ?? predictions === sampleTides;
	const now = at ?? (isSample ? sampleTideTime : (clock ?? 0));
	const tide = useMemo(
		() => tideState(predictions, series ?? (isSample ? sampleTideSeries : []), reading, now),
		[predictions, series, isSample, reading, now]
	);
	const chartData = useMemo<ChartPoint[]>(() => {
		const curveData = tide.points.filter(
			(point: ChartPoint) =>
				point.time >= now - 12 * 60 * 60 * 1000 && point.time <= now + 18 * 60 * 60 * 1000
		);
		if (tide.predicted !== null && !curveData.some((point: ChartPoint) => point.time === now)) {
			return [...curveData, { time: now, height: tide.predicted }].sort(
				(a: ChartPoint, b: ChartPoint) => a.time - b.time
			);
		}
		return curveData;
	}, [now, tide.points, tide.predicted]);
	const domain = useMemo(() => {
		const values = chartData.map((point: ChartPoint) => point.height);
		if (tide.level !== null) values.push(tide.level);
		if (!values.length) return [0, 1] as [number, number];
		const low = Math.min(...values);
		const high = Math.max(...values);
		const padding = Math.max(0.15, (high - low) * 0.2);
		return [low - padding, high + padding] as [number, number];
	}, [chartData, tide.level]);
	const height = (value: number) => (value * (unit === 'meter' ? 0.3048 : 1)).toFixed(1);
	const symbol = unit === 'meter' ? 'm' : 'ft';
	const chartHeight = size === 'sm' ? 64 : size === 'lg' ? 144 : 112;
	const handleHover = useCallback((point: ChartPoint | null) => setHovered(point), []);
	const handleDayHover = useCallback((point: ChartPoint | null) => setDayHovered(point), []);
	const days = useMemo(
		() =>
			forecastDays(
				tide.events
					.filter((event) => {
						const eventTime = tideTimestamp(event.time);
						return eventTime >= now && eventTime < now + 7 * 86400000;
					})
					.map((event) => ({
						time: tideTimestamp(event.time),
						label: formatTime(event.time, displayTimeZone),
						summary: `${event.type === 'H' ? 'High' : 'Low'} tide · ${height(Number(event.height))} ${symbol}`,
						details: `Predicted ${event.type === 'H' ? 'high' : 'low'} tide, ${height(Number(event.height))} ${symbol} above MLLW.`
					})),
				displayTimeZone
			),
		[now, tide.events, displayTimeZone, symbol, unit]
	);
	if (
		!isSample &&
		(predictions.length > 0 || (series?.length ?? 0) > 0) &&
		at === undefined &&
		clock === null
	) {
		return (
			<Card
				style={{ containerType: 'inline-size' }}
				data-density={density}
				data-card-size={size}
				data-size={size === 'sm' ? 'sm' : 'default'}
				className={`relative isolate min-w-0 overflow-hidden ${className}`}
			>
				<CardHeader>
					<CardTitle>Tides</CardTitle>
					<CardDescription>{location.label}</CardDescription>
				</CardHeader>
				<CardContent>
					<p role="status" className="py-8 text-center text-sm text-muted-foreground">
						Loading current tide time…
					</p>
				</CardContent>
			</Card>
		);
	}
	function pointsForDay(day: ForecastDay) {
		const key = new Intl.DateTimeFormat('en-CA', {
			timeZone: displayTimeZone,
			year: 'numeric',
			month: '2-digit',
			day: '2-digit'
		});
		return tide.points.filter((point) => key.format(point.time) === day.key);
	}
	function dailyDomain(points: ChartPoint[]): [number, number] {
		const values = points.map((point) => point.height);
		if (!values.length) return [0, 1];
		const low = Math.min(...values);
		const high = Math.max(...values);
		const padding = Math.max(0.15, (high - low) * 0.2);
		return [low - padding, high + padding];
	}
	function cardView(
		day: ForecastDay | undefined,
		action: ForecastAction,
		openDay: OpenForecastDay
	) {
		const events = day
			? tide.events.filter((event) =>
					day.entries.some((entry) => entry.time === tideTimestamp(event.time))
				)
			: predictions;
		const viewNow = day ? day.entries[0].time : now;
		const viewTide = day
			? tideState(events, series ?? (isSample ? sampleTideSeries : []), null, viewNow)
			: tide;
		const viewChartData = day ? pointsForDay(day) : chartData;
		const viewDomain = day ? dailyDomain(viewChartData) : domain;
		const viewHovered = day
			? dayHovered && viewChartData.some((point) => point.time === dayHovered.time)
				? dayHovered
				: null
			: hovered;
		const updateHover = day ? handleDayHover : handleHover;
		const viewDisplayedLevel = viewHovered?.height ?? viewTide.level;
		const viewDisplayedTime =
			viewHovered?.time ??
			(day ? viewNow : viewTide.observed ? tideTimestamp(viewTide.observed.time) : now);
		const viewIndicatorTime = viewHovered?.time ?? (day ? viewDisplayedTime : now);
		const viewIndicatorLevel = viewHovered?.height ?? viewTide.predicted;
		const viewTimeParts = new Intl.DateTimeFormat('en-US', {
			hour: 'numeric',
			minute: '2-digit',
			timeZone: displayTimeZone
		}).formatToParts(viewDisplayedTime);
		const neighbors = (
			<div
				data-slot="tide-neighbors"
				className={
					size === 'sm' ? 'grid content-center gap-3' : 'grid grid-cols-2 gap-3 border-t pt-3'
				}
			>
				{[
					{ label: 'Previous', event: viewTide.previous },
					{ label: 'Next', event: viewTide.next }
				].map(({ label, event }) => (
					<div key={label}>
						<p
							className={
								size === 'sm'
									? 'text-[10px] text-muted-foreground'
									: 'text-xs text-muted-foreground'
							}
						>
							{label} {event ? (event.type === 'H' ? 'high tide' : 'low tide') : 'tide'}
						</p>
						<div
							className={
								size === 'sm' ? 'mt-0.5 flex flex-wrap items-baseline gap-x-2' : 'contents'
							}
						>
							<p
								className={
									size === 'sm'
										? 'text-xs font-medium tabular-nums'
										: 'mt-1 text-sm font-medium tabular-nums'
								}
							>
								{event ? formatTime(event.time, displayTimeZone) : 'Unavailable'}
							</p>
							{event ? (
								<p
									className={
										size === 'sm'
											? 'text-[10px] text-muted-foreground'
											: 'mt-1 text-xs text-muted-foreground'
									}
								>
									{height(Number(event.height))} {symbol}
								</p>
							) : null}
						</div>
					</div>
				))}
			</div>
		);
		return (
			<>
				<CardHeader
					className={size === 'sm' ? 'flex items-baseline justify-between gap-2' : undefined}
				>
					<CardTitle>{day?.label ?? 'Tides'}</CardTitle>
					<CardDescription>{location.label}</CardDescription>
					{action(false)}
				</CardHeader>
				<CardContent
					className={`${size === 'sm' || density === 'compact' ? 'grid gap-3' : 'grid gap-5'}`}
				>
					{viewTide.events.length || viewTide.points.length ? (
						<>
							<div className={`flex items-end justify-between gap-3 ${day ? '' : 'flex-wrap'}`}>
								<div className="min-w-0">
									<p className={`mb-1 text-xs text-muted-foreground ${day ? 'truncate' : ''}`}>
										{viewHovered
											? 'Predicted water level'
											: !day && isSample
												? 'Example water level'
												: viewTide.observed
													? 'Current water level'
													: viewTide.predicted !== null
														? 'Predicted water level'
														: 'High/low predictions only'}
									</p>
									<p
										style={
											size === 'sm'
												? { fontSize: 'clamp(1.25rem, 8cqw, 1.875rem)', lineHeight: 1.1 }
												: { fontSize: 'clamp(1.5rem, 12cqw, 2.5rem)' }
										}
										className={`${size === 'sm' ? 'text-3xl' : 'text-4xl'} font-medium tracking-tight tabular-nums`}
									>
										{viewDisplayedLevel === null ? (
											'—'
										) : (
											<NumberFlow
												value={Number(height(viewDisplayedLevel))}
												format={{ minimumFractionDigits: 1, maximumFractionDigits: 1 }}
											/>
										)}
										<span className="ml-1 text-sm text-muted-foreground">{symbol}</span>
									</p>
								</div>
								<div className="text-right text-xs text-muted-foreground">
									<p>
										{viewHovered
											? 'Selected time'
											: viewTide.next
												? viewTide.next.type === 'H'
													? 'Rising toward high tide'
													: 'Falling toward low tide'
												: 'Tide outlook'}
									</p>
									<p className="mt-1 tabular-nums" data-slot="tide-time">
										<span className="sr-only">
											{formatTime(viewDisplayedTime, displayTimeZone)}
										</span>
										<span aria-hidden="true">
											{viewTimeParts.map((part, index) =>
												part.type === 'hour' || part.type === 'minute' ? (
													<NumberFlow
														key={`${part.type}-${index}`}
														value={Number(part.value)}
														format={{
															minimumIntegerDigits: part.type === 'minute' ? 2 : 1,
															useGrouping: false
														}}
													/>
												) : (
													part.value.replace(/[\u00a0\u202f]/g, ' ')
												)
											)}
										</span>
									</p>
								</div>
							</div>
							<div
								data-slot="tide-chart-layout"
								className={
									size === 'sm'
										? 'grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3'
										: 'contents'
								}
							>
								{size === 'sm' ? neighbors : null}
								<div className={size === 'sm' ? 'grid min-w-0 gap-1' : 'contents'}>
									{viewChartData.length > 1 ? (
										<>
											<ChartContainer
												config={chartConfig}
												className={`aspect-auto w-full ${size === 'sm' ? 'h-16' : size === 'lg' ? 'h-36' : 'h-28'}`}
												role="img"
												aria-label="Tide prediction curve with predicted water level marker"
											>
												<AreaChart
													data={viewChartData}
													height={chartHeight}
													margin={{ top: 8, right: 8, bottom: 4, left: 8 }}
													onMouseMove={(state) => {
														const index = Number(state.activeTooltipIndex);
														const point = Number.isInteger(index)
															? viewChartData[index]
															: undefined;
														updateHover(point ?? null);
													}}
													onMouseLeave={() => updateHover(null)}
												>
													<CartesianGrid
														vertical={false}
														stroke="var(--border)"
														strokeDasharray="3 4"
													/>
													<XAxis
														dataKey="time"
														type="number"
														domain={[
															viewChartData[0].time,
															viewChartData[viewChartData.length - 1].time
														]}
														hide
													/>
													<YAxis hide domain={viewDomain} />
													<ChartTooltip
														content={
															<TideTooltipContent
																indicator="line"
																onHover={updateHover}
																displayTimeZone={displayTimeZone}
																unit={unit}
															/>
														}
													/>
													<Area
														type="monotone"
														dataKey="height"
														name="Tide level"
														stroke="var(--chart-1)"
														fill="var(--chart-1)"
														fillOpacity={0.12}
														strokeWidth={2}
														isAnimationActive={false}
													/>
													{viewIndicatorLevel !== null &&
													viewIndicatorTime >= viewChartData[0].time &&
													viewIndicatorTime <= viewChartData[viewChartData.length - 1].time ? (
														<>
															<ReferenceLine
																x={viewIndicatorTime}
																stroke="var(--muted-foreground)"
																strokeDasharray="3 4"
															/>
															<ReferenceDot
																x={viewIndicatorTime}
																y={viewIndicatorLevel}
																r={4.5}
																fill="var(--chart-1)"
																stroke="var(--card)"
																strokeWidth={2}
																data-slot="current-tide-marker"
															/>
														</>
													) : null}
												</AreaChart>
											</ChartContainer>
											<div
												className={`flex justify-between gap-1 text-[10px] text-muted-foreground ${size === 'sm' ? '' : '-mt-2'}`}
											>
												<span>{formatTime(viewChartData[0].time, displayTimeZone)}</span>
												{size !== 'sm' ? (
													<span>
														MLLW · {viewTide.points.length ? 'predicted curve' : 'extrema only'}
													</span>
												) : null}
												<span>{formatTime(viewChartData.at(-1)!.time, displayTimeZone)}</span>
											</div>
										</>
									) : null}
								</div>
							</div>
							{size !== 'sm' ? neighbors : null}
							{type !== 'simple' ? (
								<div className="divide-y border-t">
									{(viewTide.events as TidePrediction[])
										.filter((event: TidePrediction) => tideTimestamp(event.time) > viewNow)
										.slice(0, type === 'detailed' ? 6 : density === 'compact' ? 2 : 4)
										.map((event: TidePrediction) => (
											<div
												key={`${event.time}-${event.type}`}
												className={`grid grid-cols-[auto_1fr_auto] items-center gap-3 text-xs ${density === 'compact' ? 'py-2' : 'py-3'}`}
											>
												<span>{event.type === 'H' ? 'High tide' : 'Low tide'}</span>
												<span className="ml-auto text-muted-foreground">
													{formatDateTime(event.time, displayTimeZone)}
												</span>
												<span className="tabular-nums">
													{height(Number(event.height))} {symbol}
												</span>
											</div>
										))}
								</div>
							) : null}
						</>
					) : (
						<p role="status" className="py-8 text-center text-sm text-muted-foreground">
							No tide predictions available.
						</p>
					)}
					{!day && sourceLabel ? (
						<p className="text-[10px] text-muted-foreground">{sourceLabel}</p>
					) : null}
				</CardContent>
			</>
		);
	}
	const upcomingSummary = (availableHeight: number) => {
		const events = tide.events
			.filter((event) => tideTimestamp(event.time) >= now)
			.slice(0, size === 'lg' ? 12 : Math.max(1, Math.floor(availableHeight / 36)));
		return (
			<div
				className="grid h-full w-full overflow-y-auto"
				style={{ gridAutoRows: `minmax(${size === 'lg' ? 40 : 36}px, 1fr)` }}
				data-slot="upcoming-tides"
			>
				{events.length ? (
					events.map((event) => (
						<div
							className={`grid min-w-0 grid-cols-[auto_1fr_auto] items-center gap-3 border-b last:border-0 ${size === 'lg' ? 'text-sm' : 'text-xs'}`}
							key={`${event.time}-${event.type}`}
						>
							<span className="font-medium">{event.type === 'H' ? 'High tide' : 'Low tide'}</span>
							<time
								dateTime={new Date(tideTimestamp(event.time)).toISOString()}
								className="text-right text-muted-foreground"
							>
								{formatDateTime(event.time, displayTimeZone)}
							</time>
							<span className="text-right tabular-nums">
								{height(Number(event.height))} {symbol}
							</span>
						</div>
					))
				) : (
					<p className="py-4 text-sm text-muted-foreground">
						No upcoming tide predictions available.
					</p>
				)}
			</div>
		);
	};

	return (
		<Card
			style={{ containerType: 'inline-size' }}
			data-density={density}
			data-card-size={size}
			data-size={size === 'sm' ? 'sm' : 'default'}
			className={`relative isolate min-w-0 overflow-hidden ${className}`}
		>
			<ForecastScreens
				interactive={interactive}
				days={days}
				title="Tide"
				density={density}
				sourceLabel={sourceLabel}
				iconType={iconType}
				showWeek
				actionLabel="Upcoming tides"
				summary={upcomingSummary}
				children={(openDay, action) => cardView(undefined, action, openDay)}
				detail={(day, action) => cardView(day, action, () => {})}
			/>
		</Card>
	);
}

export default TideForecast;
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/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/tides.ts
import type { LocationInput, TidePrediction } from './types.js';

type NoaaPrediction = {
	t: string;
	v: string;
	type: 'H' | 'L';
};

type NoaaResponse = {
	predictions: NoaaPrediction[];
};

export async function fetchTidePredictions(location: LocationInput, date = new Date()) {
	if (!location.station) {
		throw new Error('NOAA tide predictions require a CO-OPS station id.');
	}

	const begin = date.toISOString().slice(0, 10).replaceAll('-', '');
	const url = new URL('https://api.tidesandcurrents.noaa.gov/api/prod/datagetter');
	url.search = new URLSearchParams({
		product: 'predictions',
		application: 'wxcn',
		begin_date: begin,
		range: '168',
		datum: 'MLLW',
		station: location.station,
		time_zone: 'gmt',
		units: 'english',
		interval: 'hilo',
		format: 'json'
	}).toString();

	const response = await fetch(url);
	if (!response.ok) {
		throw new Error(`NOAA tide lookup failed with ${response.status}`);
	}

	const data = (await response.json()) as NoaaResponse;
	return data.predictions.map((prediction) => ({
		time: prediction.t.replace(' ', 'T') + 'Z',
		height: prediction.v,
		type: prediction.type
	})) satisfies TidePrediction[];
}

export const sampleTides: TidePrediction[] = [
	{ time: '2026-09-06T02:12:00-05:00', height: '1.8', type: 'H' },
	{ time: '2026-09-06T08:28:00-05:00', height: '0.4', type: 'L' },
	{ time: '2026-09-06T14:49:00-05:00', height: '1.5', type: 'H' },
	{ time: '2026-09-06T21:07:00-05:00', height: '0.3', type: 'L' },
	{ time: '2026-09-07T02:49:00-05:00', height: '1.8', type: 'H' },
	{ time: '2026-09-07T09:05:00-05:00', height: '0.4', type: 'L' },
	{ time: '2026-09-07T15:26:00-05:00', height: '1.5', type: 'H' },
	{ time: '2026-09-07T21:44:00-05:00', height: '0.3', type: 'L' },
	{ time: '2026-09-08T03:26:00-05:00', height: '1.8', type: 'H' },
	{ time: '2026-09-08T09:42:00-05:00', height: '0.4', type: 'L' },
	{ time: '2026-09-08T16:03:00-05:00', height: '1.5', type: 'H' },
	{ time: '2026-09-08T22:21:00-05:00', height: '0.3', type: 'L' },
	{ time: '2026-09-09T04:03:00-05:00', height: '1.8', type: 'H' },
	{ time: '2026-09-09T10:19:00-05:00', height: '0.4', type: 'L' },
	{ time: '2026-09-09T16:40:00-05:00', height: '1.5', type: 'H' },
	{ time: '2026-09-09T22:58:00-05:00', height: '0.3', type: 'L' }
];

export const sampleTideTime = Date.parse('2026-09-06T16:30:00Z');
// Illustrative six-minute samples, used only by the labeled coastal example.
export const sampleTideSeries = Array.from({ length: 241 }, (_, i) => {
	const time = Date.parse('2026-09-06T05:00:00Z') + i * 360000;
	return {
		time: new Date(time).toISOString(),
		height: (
			0.95 +
			0.65 *
				Math.cos(((time - Date.parse('2026-09-06T07:12:00Z')) / (12.6 * 3600000)) * 2 * Math.PI)
		).toFixed(3)
	};
});
packages/core/src/tide-state.ts
import type { TidePrediction, TideReading, TidePoint } from './types.js';

// Offset-free legacy NOAA values are station wall times; all new API data uses UTC.
export function tideTimestamp(time: string) {
	return Date.parse(/[zZ]|[+-]\d\d:\d\d$/.test(time) ? time : time.replace(' ', 'T') + 'Z');
}
export function tideState(
	predictions: TidePrediction[],
	series: TidePoint[],
	reading: TideReading | null,
	now: number
) {
	const events = predictions
		.filter((p) => Number.isFinite(tideTimestamp(p.time)) && Number.isFinite(Number(p.height)))
		.toSorted((a, b) => tideTimestamp(a.time) - tideTimestamp(b.time));
	const previous = events.filter((p) => tideTimestamp(p.time) <= now).at(-1);
	const next = events.find((p) => tideTimestamp(p.time) > now);
	const points = series
		.map((p) => ({ time: tideTimestamp(p.time), height: Number(p.height) }))
		.filter((p) => Number.isFinite(p.time) && Number.isFinite(p.height))
		.toSorted((a, b) => a.time - b.time);
	const before = points.filter((p) => p.time <= now).at(-1),
		after = points.find((p) => p.time >= now);
	const predicted =
		before && after
			? before.height +
				(after.height - before.height) *
					(after.time === before.time ? 0 : (now - before.time) / (after.time - before.time))
			: null;
	const observed =
		reading &&
		reading.height.trim() &&
		Number.isFinite(Number(reading.height)) &&
		now - tideTimestamp(reading.time) >= 0 &&
		now - tideTimestamp(reading.time) <= 30 * 60000
			? reading
			: null;
	return {
		events,
		previous,
		next,
		points,
		predicted,
		observed,
		level: observed ? Number(observed.height) : predicted
	};
}

More from wxcn

Browse all components in this library →