voraui
blockaltseason-gaugesource: ok · refreshed
Altseason Gauge
Altcoin Season index gauge computed client-side from CoinPaprika data.
npx shadcn add "https://voraui.vercel.app/r/altseason-gauge.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
Registry dependencies
utils
Files5 · source cached 10/8/2026, 1:04:02 AM
registry/voraui/altseason-gauge/index.ts
export { AltseasonGauge, type AltseasonGaugeProps } from "./altseason-gauge";
export { AltseasonGaugeSkeleton } from "./components/skeleton";
export type { AltseasonData, AltseasonWindow } from "./lib/altseason";
registry/voraui/altseason-gauge/altseason-gauge.tsx
"use client";
import { motion } from "framer-motion";
import NumberFlow from "@number-flow/react";
import { cn } from "@/lib/utils";
import { AltseasonGaugeSkeleton } from "./components/skeleton";
import { useAltseason } from "./hooks/use-altseason";
import type { AltseasonData, AltseasonWindow } from "./lib/altseason";
export interface AltseasonGaugeProps {
/** Provide your own data to bypass the bundled CoinPaprika fetcher. */
data?: AltseasonData;
/** Comparison window for the bundled fetcher. Ignored when data is set. */
window?: AltseasonWindow;
/** "meter" (default) three-zone track with a thumb; "bars" one bar per compared altcoin. */
variant?: "meter" | "bars";
/** Spring the thumb in from center on first render instead of snapping. */
animateOnLoad?: boolean;
className?: string;
}
// Bars fallback when custom data has a score but no per-coin counts.
const BARS_FALLBACK_TOTAL = 50;
// Classic altseason spectrum (blockchaincenter.net): amber BTC pole, blue
// mixed center, red alt pole. Both variants draw from this one ramp.
const SEASON_RAMP_STOPS: { t: number; rgb: [number, number, number] }[] = [
{ t: 0, rgb: [245, 158, 11] },
{ t: 0.25, rgb: [250, 204, 21] },
{ t: 0.4, rgb: [74, 222, 128] },
{ t: 0.5, rgb: [56, 189, 248] },
{ t: 0.6, rgb: [74, 222, 128] },
{ t: 0.75, rgb: [250, 204, 21] },
{ t: 0.88, rgb: [245, 158, 11] },
{ t: 1, rgb: [239, 68, 68] },
];
const TRACK_GRADIENT = `linear-gradient(to right, ${SEASON_RAMP_STOPS.map(
(s) => `rgb(${s.rgb.join(" ")}) ${s.t * 100}%`,
).join(", ")})`;
function seasonRampColor(t: number): string {
for (let i = 0; i < SEASON_RAMP_STOPS.length - 1; i++) {
const from = SEASON_RAMP_STOPS[i];
const to = SEASON_RAMP_STOPS[i + 1];
if (t >= from.t && t <= to.t) {
const k = (t - from.t) / (to.t - from.t);
const [r, g, b] = from.rgb.map((c, ch) => Math.round(c + (to.rgb[ch] - c) * k));
return `rgb(${r} ${g} ${b})`;
}
}
const [r, g, b] = SEASON_RAMP_STOPS[SEASON_RAMP_STOPS.length - 1].rgb;
return `rgb(${r} ${g} ${b})`;
}
export function AltseasonGauge({
data,
window: windowProp = "7d",
variant = "meter",
animateOnLoad = true,
className,
}: AltseasonGaugeProps) {
const fetched = useAltseason(windowProp, { enabled: data === undefined });
const resolved = data ?? fetched.data;
if (!resolved) {
if (fetched.error) {
return (
<div className={cn("flex h-[180px] items-center justify-center", className)}>
<p role="alert" className="text-xs text-muted-foreground">Altseason data is unavailable.</p>
</div>
);
}
return <AltseasonGaugeSkeleton variant={variant} className={className} />;
}
const hasScore = resolved.score !== null;
const score = resolved.score ?? 50;
// Edge padding so a 0/100 score doesn't push the thumb half-off the track.
const markerLeft = Math.min(Math.max(score, 3), 97);
const label = resolved.label;
const isBtc = label === "Bitcoin Season";
const isAlt = label === "Altcoin Season";
const isMixed = label === "Mixed";
const barsTotal = resolved.compared > 0 ? resolved.compared : BARS_FALLBACK_TOTAL;
const barsFilled =
resolved.compared > 0
? Math.min(resolved.outperforming, barsTotal)
: Math.round((score / 100) * BARS_FALLBACK_TOTAL);
return (
<div className={cn("flex flex-col gap-4", className)}>
<div className="flex items-end gap-3">
<p className="text-4xl font-bold leading-none tabular-nums text-foreground">
{hasScore ? <NumberFlow value={Math.round(resolved.score as number)} /> : "-"}
</p>
<p className="pb-1 text-xs text-muted-foreground">/ 100</p>
<span
className={cn(
"ml-auto rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider",
isBtc && "bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-300",
isAlt && "bg-red-500/10 text-red-600 dark:bg-red-400/10 dark:text-red-300",
isMixed && "bg-sky-500/10 text-sky-600 dark:bg-sky-400/10 dark:text-sky-300",
!isBtc && !isAlt && !isMixed && "bg-muted text-muted-foreground",
)}
>
{label}
</span>
</div>
{!hasScore ? (
<div className="flex h-[120px] items-center justify-center">
<p className="text-xs text-muted-foreground">Altseason score is unavailable.</p>
</div>
) : variant === "bars" ? (
<div className="flex flex-col gap-3">
<div
className="flex h-14 items-stretch gap-[3px]"
role="img"
aria-label={`${barsFilled} of ${barsTotal} altcoins outperforming Bitcoin`}
>
{Array.from({ length: barsTotal }, (_, i) => (
<div
key={i}
className={cn(
"flex-1 rounded-[2px] transition-opacity duration-200",
i < barsFilled ? "opacity-100" : "opacity-25",
)}
style={{ backgroundColor: seasonRampColor(barsTotal > 1 ? i / (barsTotal - 1) : 0) }}
/>
))}
</div>
<p className="text-xs text-muted-foreground">
<span className="font-medium tabular-nums text-foreground">{barsFilled}</span> of{" "}
<span className="tabular-nums">{barsTotal}</span> top alts outperforming BTC
{resolved.compared > 0 && <> · {resolved.window}</>}
</p>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="relative">
<div
className="h-3 w-full rounded-full"
style={{ background: TRACK_GRADIENT }}
aria-hidden="true"
/>
<motion.div
className="pointer-events-none absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
initial={animateOnLoad ? { left: "50%" } : false}
animate={{ left: `${markerLeft}%` }}
transition={{ type: "spring", stiffness: 100 }}
aria-hidden
>
<div className="h-6 w-1 rounded-full bg-foreground ring-1 ring-background" />
</motion.div>
</div>
<div className="flex justify-between text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
<span className={cn(isBtc && "text-amber-600 dark:text-amber-300")}>BTC season</span>
<span className={cn(isMixed && "text-sky-600 dark:text-sky-300")}>Mixed</span>
<span className={cn(isAlt && "text-red-600 dark:text-red-300")}>Altseason</span>
</div>
</div>
)}
</div>
);
}
registry/voraui/altseason-gauge/components/skeleton.tsx
"use client";
import { cn } from "@/lib/utils";
export interface AltseasonGaugeSkeletonProps {
/** Matches AltseasonGaugeProps["variant"] so the ghost lines up with the real gauge. */
variant?: "meter" | "bars";
className?: string;
}
const BARS_GHOST_COUNT = 24;
export function AltseasonGaugeSkeleton({ variant = "meter", className }: AltseasonGaugeSkeletonProps) {
return (
<div
role="status"
className={cn("voraui-altseason-gauge-skeleton-shimmer relative flex flex-col gap-4 overflow-hidden", className)}
>
<style href="voraui-altseason-gauge-skeleton" precedence="low">{`
@keyframes voraui-altseason-gauge-skeleton-shimmer {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.voraui-altseason-gauge-skeleton-shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
animation: voraui-altseason-gauge-skeleton-shimmer 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.voraui-altseason-gauge-skeleton-shimmer::after {
animation: none;
}
}
`}</style>
<div className="flex items-end gap-3">
<div className="h-9 w-14 rounded-md bg-muted" />
<div className="h-4 w-8 rounded bg-muted" />
<div className="ml-auto h-6 w-24 rounded-full bg-muted" />
</div>
{variant === "bars" ? (
<div className="flex flex-col gap-3">
<div className="flex h-14 items-stretch gap-[3px]">
{Array.from({ length: BARS_GHOST_COUNT }, (_, i) => (
<div
key={i}
className={cn("flex-1 rounded-[2px] bg-muted", i % 2 === 0 ? "opacity-60" : "opacity-25")}
/>
))}
</div>
<div className="h-4 w-40 rounded bg-muted" />
</div>
) : (
<div className="flex flex-col gap-3">
<div className="h-3 w-full rounded-full bg-muted" />
<div className="flex justify-between">
<div className="h-3 w-16 rounded bg-muted" />
<div className="h-3 w-12 rounded bg-muted" />
<div className="h-3 w-16 rounded bg-muted" />
</div>
</div>
)}
<span className="sr-only">Loading Altseason Index</span>
</div>
);
}
registry/voraui/altseason-gauge/hooks/use-altseason.ts
"use client";
import { useEffect, useState } from "react";
import {
computeAltseason,
type AltseasonData,
type AltseasonWindow,
type PaprikaTicker,
} from "../lib/altseason";
// CoinPaprika public tickers. Free, no API key; large response but fetched rarely.
const TICKERS_URL = "https://api.coinpaprika.com/v1/tickers?quotes=USD";
async function fetchTickersFromCoinPaprika(): Promise<PaprikaTicker[]> {
const res = await fetch(TICKERS_URL);
if (!res.ok) throw new Error(`CoinPaprika request failed: ${res.status}`);
const raw = (await res.json()) as PaprikaTicker[];
return Array.isArray(raw) ? raw : [];
}
/** Retry transient CoinPaprika failures a couple of times before giving up. */
export async function fetchTickersWithRetry(
fetchTickers: () => Promise<PaprikaTicker[]> = fetchTickersFromCoinPaprika,
attempts = 3,
retryDelayMs = 1000,
): Promise<PaprikaTicker[]> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await fetchTickers();
} catch (err) {
lastError = err;
if (attempt < attempts) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}
}
throw lastError;
}
export function useAltseason(
window: AltseasonWindow = "7d",
options: { enabled?: boolean; refreshInterval?: number } = {},
) {
const { enabled = true, refreshInterval = 1_800_000 } = options;
const [data, setData] = useState<AltseasonData | null>(null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
const load = async () => {
try {
const raw = await fetchTickersWithRetry();
if (cancelled) return;
setData(computeAltseason(raw, window));
setError(null);
setLoading(false);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "CoinPaprika request failed");
setLoading(false);
}
};
load();
const timer = setInterval(load, refreshInterval);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [enabled, refreshInterval, window]);
return { data, loading, error };
}
registry/voraui/altseason-gauge/lib/altseason.ts
export type AltseasonWindow = "24h" | "7d";
export type AltseasonLabel = "Altcoin Season" | "Bitcoin Season" | "Mixed" | "Unknown";
export interface PaprikaTicker {
symbol?: string | null;
rank?: number | null;
quotes?: { USD?: Partial<Record<string, number | null>> } | null;
}
export interface AltseasonData {
/** 0-100 score, or null when it cannot be computed. */
score: number | null;
label: AltseasonLabel;
/** BTC change over the window, in percent. */
btcChangePct: number | null;
window: AltseasonWindow;
/** Number of alts included in the comparison (up to 50). */
compared: number;
/** Number of those alts outperforming BTC. */
outperforming: number;
}
/** CoinPaprika column suffixes under quotes.USD, per window. */
export const WINDOW_KEYS: Record<AltseasonWindow, string> = {
"24h": "percent_change_24h",
"7d": "percent_change_7d",
};
/** Stablecoins and wrapped/liquid-staked BTC/ETH derivatives, which would double-count. */
export const ALTSEASON_EXCLUDED_SYMBOLS = new Set([
"USDT", "USDC", "DAI", "BUSD", "TUSD", "FDUSD", "PYUSD", "USDD", "USDE", "USDS",
"WBTC", "BTCB", "WETH", "STETH", "WSTETH", "RETH", "WEETH", "CBBTC", "TBTC",
]);
/** Altcoin Season Index: % of the top-50 clean alts outperforming BTC over the
* window. >= 75 is Altcoin Season, <= 25 is Bitcoin Season (blockchaincenter.net). */
export function computeAltseason(
tickers: PaprikaTicker[],
window: AltseasonWindow = "7d",
): AltseasonData {
const key = WINDOW_KEYS[window];
const rows = [...tickers].sort((a, b) => (a.rank ?? 1_000_000) - (b.rank ?? 1_000_000));
const changeOf = (row: PaprikaTicker): number | null => {
const value = row.quotes?.USD?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : null;
};
let btcChange: number | null = null;
for (const row of rows) {
if ((row.symbol ?? "").toUpperCase() === "BTC") {
btcChange = changeOf(row);
break;
}
}
if (btcChange === null) {
return { score: null, label: "Unknown", btcChangePct: null, window, compared: 0, outperforming: 0 };
}
const clean = rows
.filter((row) => {
const symbol = (row.symbol ?? "").toUpperCase();
return symbol !== "BTC" && !ALTSEASON_EXCLUDED_SYMBOLS.has(symbol) && changeOf(row) !== null;
})
.slice(0, 50);
// An all-zero column is a dead upstream column, not a flat market; treat as unknown.
if (btcChange === 0 && clean.every((row) => changeOf(row) === 0)) {
return { score: null, label: "Unknown", btcChangePct: null, window, compared: 0, outperforming: 0 };
}
const outperforming = clean.filter((row) => (changeOf(row) as number) > (btcChange as number)).length;
const compared = clean.length;
const score = compared > 0 ? (outperforming / compared) * 100 : null;
const label: AltseasonLabel =
score === null ? "Unknown" : score >= 75 ? "Altcoin Season" : score <= 25 ? "Bitcoin Season" : "Mixed";
return {
score: score === null ? null : Math.round(score * 10) / 10,
label,
btcChangePct: Math.round(btcChange * 100) / 100,
window,
compared,
outperforming,
};
}