shadimage
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 · medium
animationframer-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 && <> &middot; {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,
  };
}

More from voraui

Browse all components in this library →