voraui
blockfear-greed-gaugesource: ok · refreshed
Fear & Greed Gauge
Crypto Fear & Greed index dial with live data from alternative.me.
npx shadcn add "https://voraui.vercel.app/r/fear-greed-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, 4:42:33 AM
registry/voraui/fear-greed-gauge/index.ts
export { FearGreedGauge, type FearGreedGaugeProps } from "./fear-greed-gauge";
export { FearGreedGaugeSkeleton } from "./components/skeleton";
export type { FearGreedData } from "./hooks/use-fear-greed";
registry/voraui/fear-greed-gauge/fear-greed-gauge.tsx
"use client";
import { useEffect, useId, useRef } from "react";
import { animate, useMotionValue } from "framer-motion";
import NumberFlow from "@number-flow/react";
import { cn } from "@/lib/utils";
import { FearGreedGaugeSkeleton } from "./components/skeleton";
import { useFearGreed, type FearGreedData } from "./hooks/use-fear-greed";
import {
DEFAULT_FEAR_GREED_BANDS,
GAUGE_CENTER_X,
GAUGE_CENTER_Y,
GRADIENT_STOPS,
WEDGE_GAP,
WEDGE_HUB_RADIUS,
WEDGE_INNER_RADIUS,
WEDGE_OUTER_RADIUS,
WEDGES_VIEWBOX_HEIGHT,
angleForValue,
arcPoint,
colorForValue,
describeArc,
describeWedge,
equalizedValue,
findFearGreedBand,
} from "./lib/fear-greed-bands";
export interface FearGreedGaugeProps {
/** Provide your own data to bypass the bundled alternative.me fetcher. */
data?: FearGreedData;
/** "gradient" (default) smooth blend; "minimal" 5 discrete bands; "ticks" 100 tick marks; "wedges" CNN-style zone sectors. */
variant?: "minimal" | "ticks" | "gradient" | "wedges";
/** Spring the needle in from neutral on first render instead of snapping. */
animateOnLoad?: boolean;
className?: string;
}
const TICKS_MAJOR_VALUES = [0, 25, 50, 75, 100];
const TICKS_FINE_VALUES = Array.from({ length: 100 }, (_, i) => i + 1);
const WEDGE_LABEL_RADIUS = (WEDGE_OUTER_RADIUS + WEDGE_INNER_RADIUS) / 2;
const WEDGE_BAND_SHARE = 100 / DEFAULT_FEAR_GREED_BANDS.length;
// Equalized wedge corners are real band boundaries, so numbers sit at the corners.
const WEDGE_SCALE_NUMBER_VALUES = [0, 25, 45, 55, 75, 100];
// Decorative dots at thirds between the corner numbers (display positions, not values).
const WEDGE_SCALE_DOT_DISPLAY_VALUES = DEFAULT_FEAR_GREED_BANDS.flatMap((_, i) => [
(i + 1 / 3) * WEDGE_BAND_SHARE,
(i + 2 / 3) * WEDGE_BAND_SHARE,
]);
const WEDGE_SCALE_DOT_RADIUS = 48;
const WEDGE_SCALE_NUMBER_RADIUS = 56;
function labelAnchor(value: number): "start" | "middle" | "end" {
if (value < 45) return "start";
if (value > 55) return "end";
return "middle";
}
export function FearGreedGauge({
data,
variant = "gradient",
animateOnLoad = true,
className,
}: FearGreedGaugeProps) {
const gradientId = useId();
const fetched = useFearGreed({ enabled: data === undefined });
const resolved = data ?? fetched.data;
const loading = data === undefined && fetched.loading;
const value = resolved?.value ?? null;
const label = resolved?.label ?? "Unknown";
const hasError = data === undefined && Boolean(fetched.error);
// The wedges dial is drawn in equalized space; remap the needle to match.
const dialValue = value !== null && variant === "wedges" ? equalizedValue(value) : value;
const needleRotation = dialValue !== null ? 90 - angleForValue(dialValue) : 0;
const activeBand = variant === "wedges" && value !== null ? findFearGreedBand(value) : null;
// Drive the SVG rotate() attribute imperatively: framer's transform pipeline
// drops 3-arg SVG rotate() and would pivot around the wrong origin.
const needleGroupRef = useRef<SVGGElement | null>(null);
const needleMV = useMotionValue(needleRotation);
useEffect(() => {
return needleMV.on("change", (r) => {
needleGroupRef.current?.setAttribute("transform", `rotate(${r} ${GAUGE_CENTER_X} ${GAUGE_CENTER_Y})`);
});
}, [needleMV]);
const hasLoadedOnceRef = useRef(false);
useEffect(() => {
const justLoaded = value !== null && !hasLoadedOnceRef.current;
if (value !== null) hasLoadedOnceRef.current = true;
if (justLoaded && !animateOnLoad) {
needleMV.set(needleRotation);
return;
}
const controls = animate(needleMV, needleRotation, { type: "spring", stiffness: 100 });
return controls.stop;
}, [needleRotation, value, animateOnLoad, needleMV]);
if (loading) {
return <FearGreedGaugeSkeleton variant={variant} className={className} />;
}
return (
<div className={cn("relative flex flex-col items-center", className)}>
<div className="relative w-full max-w-[300px]">
<svg
viewBox={variant === "wedges" ? `0 0 260 ${WEDGES_VIEWBOX_HEIGHT}` : "0 0 260 158"}
className="w-full"
aria-hidden="true"
>
{variant === "minimal" &&
DEFAULT_FEAR_GREED_BANDS.map((band) => (
<path
key={band.key}
d={describeArc(90, band.min, band.max)}
fill="none"
stroke={band.color}
strokeWidth={12}
strokeLinecap="round"
/>
))}
{variant === "gradient" && (
<>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
{GRADIENT_STOPS.map((stop) => (
<stop key={stop.value} offset={`${stop.value}%`} stopColor={stop.color} />
))}
</linearGradient>
</defs>
<path
d={describeArc(90, 0, 100)}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={12}
strokeLinecap="round"
/>
</>
)}
{variant === "ticks" && (
<>
{TICKS_FINE_VALUES.map((v) => {
const inner = arcPoint(96, v);
const outer = arcPoint(103, v);
return (
<line
key={`fine-${v}`}
x1={inner.x}
y1={inner.y}
x2={outer.x}
y2={outer.y}
strokeWidth={1.2}
stroke={colorForValue(v)}
/>
);
})}
{TICKS_MAJOR_VALUES.map((v) => {
const inner = arcPoint(92, v);
const outer = arcPoint(107, v);
return (
<line
key={`major-${v}`}
x1={inner.x}
y1={inner.y}
x2={outer.x}
y2={outer.y}
strokeWidth={2}
stroke={colorForValue(v)}
/>
);
})}
{TICKS_MAJOR_VALUES.map((v) => {
const p = arcPoint(118, v);
// The horizontal 0/100 end labels overlap their ticks; push them down a line.
const isEdge = v === 0 || v === 100;
return (
<text
key={`num-${v}`}
x={p.x}
y={isEdge ? p.y + 14 : p.y}
textAnchor={labelAnchor(v)}
dominantBaseline="middle"
className="fill-muted-foreground text-[9px] font-medium tabular-nums"
>
{v}
</text>
);
})}
</>
)}
{variant === "wedges" && (
<>
{DEFAULT_FEAR_GREED_BANDS.map((band) => {
const isActive = activeBand?.key === band.key;
return (
<path
key={band.key}
d={describeWedge(
WEDGE_OUTER_RADIUS,
WEDGE_INNER_RADIUS,
equalizedValue(band.min) + WEDGE_GAP,
equalizedValue(band.max) - WEDGE_GAP,
)}
fill={isActive ? band.color : undefined}
fillOpacity={isActive ? 0.25 : undefined}
stroke={isActive ? band.color : undefined}
strokeWidth={isActive ? 1.5 : undefined}
className={isActive ? undefined : "fill-muted stroke-border"}
/>
);
})}
{DEFAULT_FEAR_GREED_BANDS.map((band) => {
const mid = equalizedValue((band.min + band.max) / 2);
// textPath clips long labels; rotated text with stacked tspans doesn't.
const rotation = 90 - angleForValue(mid);
const p = arcPoint(WEDGE_LABEL_RADIUS, mid);
const words = band.label.toUpperCase().split(" ");
return (
<text
key={band.key}
textAnchor="middle"
dominantBaseline="central"
transform={`rotate(${rotation} ${p.x} ${p.y})`}
className="fill-foreground text-[7.5px] font-extrabold uppercase tracking-wider"
>
{words.map((word, i) => (
<tspan key={word} x={p.x} y={p.y} dy={`${(i - (words.length - 1) / 2) * 1.3}em`}>
{word}
</tspan>
))}
</text>
);
})}
{WEDGE_SCALE_DOT_DISPLAY_VALUES.map((d) => {
const p = arcPoint(WEDGE_SCALE_DOT_RADIUS, d);
return <circle key={`dot-${d}`} cx={p.x} cy={p.y} r={1} className="fill-muted-foreground/50" />;
})}
{WEDGE_SCALE_NUMBER_VALUES.map((v) => {
const isEdge = v === 0 || v === 100;
const p = arcPoint(WEDGE_SCALE_NUMBER_RADIUS, equalizedValue(v));
return (
<text
key={`scale-${v}`}
x={p.x}
y={isEdge ? p.y - 3 : p.y}
textAnchor={labelAnchor(equalizedValue(v))}
dominantBaseline="middle"
className="fill-muted-foreground text-[8px] font-medium tabular-nums"
>
{v}
</text>
);
})}
</>
)}
{value !== null && (
<g ref={needleGroupRef} transform={`rotate(${needleMV.get()} ${GAUGE_CENTER_X} ${GAUGE_CENTER_Y})`}>
{variant === "wedges" ? (
<polygon
points={`${GAUGE_CENTER_X - 4},${GAUGE_CENTER_Y - 58} ${GAUGE_CENTER_X},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X + 4},${GAUGE_CENTER_Y - 58} ${GAUGE_CENTER_X + 4},${GAUGE_CENTER_Y + 20} ${GAUGE_CENTER_X - 4},${GAUGE_CENTER_Y + 20}`}
fill={activeBand?.color}
className={activeBand ? undefined : "fill-foreground"}
/>
) : (
<polygon
points={`${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X},${GAUGE_CENTER_Y - 72} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y + 15} ${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y + 15}`}
className="fill-foreground"
/>
)}
{variant !== "wedges" && (
<>
<circle cx={GAUGE_CENTER_X} cy={GAUGE_CENTER_Y} r={6} className="fill-foreground" />
<circle cx={GAUGE_CENTER_X} cy={GAUGE_CENTER_Y} r={2.5} className="fill-background" />
</>
)}
</g>
)}
{variant === "wedges" && (
<circle
cx={GAUGE_CENTER_X}
cy={GAUGE_CENTER_Y}
r={WEDGE_HUB_RADIUS}
strokeWidth={1}
className="fill-background stroke-border"
/>
)}
</svg>
{variant === "wedges" && (
<div
className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2 text-center"
style={{ top: `${(GAUGE_CENTER_Y / WEDGES_VIEWBOX_HEIGHT) * 100}%` }}
>
<p className="text-3xl font-bold tabular-nums text-foreground">
{value !== null ? <NumberFlow value={value} /> : "-"}
{/* The zone name only exists in the aria-hidden SVG; repeat it for screen readers. */}
{!hasError && <span className="sr-only"> {label}</span>}
</p>
</div>
)}
</div>
{variant === "wedges" ? (
hasError && (
<p role="alert" className="text-xs font-medium text-muted-foreground">
Fear & Greed data is unavailable.
</p>
)
) : (
<div className="-mt-4 text-center">
<p className="text-3xl font-bold tabular-nums text-foreground">
{value !== null ? <NumberFlow value={value} /> : "-"}
</p>
{hasError ? (
<p role="alert" className="text-xs font-medium text-muted-foreground">
Fear & Greed data is unavailable.
</p>
) : (
<p className="text-xs font-medium text-muted-foreground">{label}</p>
)}
</div>
)}
</div>
);
}
registry/voraui/fear-greed-gauge/components/skeleton.tsx
"use client";
import { cn } from "@/lib/utils";
import {
DEFAULT_FEAR_GREED_BANDS,
GAUGE_CENTER_X,
GAUGE_CENTER_Y,
WEDGE_GAP,
WEDGE_HUB_RADIUS,
WEDGE_INNER_RADIUS,
WEDGE_OUTER_RADIUS,
WEDGES_VIEWBOX_HEIGHT,
describeArc,
describeWedge,
} from "../lib/fear-greed-bands";
export interface FearGreedGaugeSkeletonProps {
/** Matches FearGreedGaugeProps["variant"] so the ghost lines up with the real dial. */
variant?: "minimal" | "ticks" | "gradient" | "wedges";
className?: string;
}
const WEDGE_COUNT = DEFAULT_FEAR_GREED_BANDS.length;
export function FearGreedGaugeSkeleton({ variant = "gradient", className }: FearGreedGaugeSkeletonProps) {
const isWedges = variant === "wedges";
return (
<div
role="status"
className={cn("voraui-fear-greed-gauge-skeleton-shimmer relative flex flex-col items-center overflow-hidden", className)}
>
<style href="voraui-fear-greed-gauge-skeleton" precedence="low">{`
@keyframes voraui-fear-greed-gauge-skeleton-shimmer {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.voraui-fear-greed-gauge-skeleton-shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
animation: voraui-fear-greed-gauge-skeleton-shimmer 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.voraui-fear-greed-gauge-skeleton-shimmer::after {
animation: none;
}
}
`}</style>
<div className="relative w-full max-w-[300px]">
<svg
viewBox={isWedges ? `0 0 260 ${WEDGES_VIEWBOX_HEIGHT}` : "0 0 260 158"}
className="w-full"
aria-hidden="true"
>
{isWedges ? (
Array.from({ length: WEDGE_COUNT }, (_, i) => {
const share = 100 / WEDGE_COUNT;
const from = i * share + WEDGE_GAP;
const to = (i + 1) * share - WEDGE_GAP;
return (
<path
key={i}
d={describeWedge(WEDGE_OUTER_RADIUS, WEDGE_INNER_RADIUS, from, to)}
className="fill-muted"
/>
);
})
) : (
<path
d={describeArc(90, 0, 100)}
fill="none"
stroke="currentColor"
strokeWidth={12}
strokeLinecap="round"
className="text-muted"
/>
)}
<polygon
points={`${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X},${GAUGE_CENTER_Y - 72} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y + 15} ${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y + 15}`}
className="fill-muted-foreground/40"
/>
{isWedges && (
<circle
cx={GAUGE_CENTER_X}
cy={GAUGE_CENTER_Y}
r={WEDGE_HUB_RADIUS}
strokeWidth={1}
className="fill-background stroke-border"
/>
)}
</svg>
{isWedges && (
<div
className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2"
style={{ top: `${(GAUGE_CENTER_Y / WEDGES_VIEWBOX_HEIGHT) * 100}%` }}
>
<div className="mx-auto h-9 w-14 rounded-md bg-muted" />
</div>
)}
</div>
{!isWedges && (
<div className="-mt-4 flex flex-col items-center">
<div className="h-9 w-16 rounded-md bg-muted" />
<div className="h-4 w-20 rounded-full bg-muted" />
</div>
)}
<span className="sr-only">Loading Fear & Greed Index</span>
</div>
);
}
registry/voraui/fear-greed-gauge/hooks/use-fear-greed.ts
"use client";
import { useEffect, useState } from "react";
export interface FearGreedData {
/** 0-100 index value, or null when the upstream has no data. */
value: number | null;
/** Upstream classification, e.g. "Fear", "Neutral", "Greed". */
label: string;
/** Upstream unix-seconds timestamp string, if provided. */
updatedAt: string | null;
}
const FEAR_GREED_URL = "https://api.alternative.me/fng/?limit=1";
export function parseFngResponse(raw: unknown): FearGreedData {
const rows = (raw as { data?: Array<Record<string, unknown>> } | null | undefined)?.data ?? [];
const row = rows[0];
if (!row) return { value: null, label: "Unknown", updatedAt: null };
const parsed = Number(row.value);
return {
value: Number.isFinite(parsed) ? parsed : null,
label: typeof row.value_classification === "string" ? row.value_classification : "Neutral",
updatedAt: typeof row.timestamp === "string" ? row.timestamp : null,
};
}
export function useFearGreed(
options: { enabled?: boolean; refreshInterval?: number } = {},
) {
const { enabled = true, refreshInterval = 600_000 } = options;
const [data, setData] = useState<FearGreedData | 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 res = await fetch(FEAR_GREED_URL);
if (!res.ok) throw new Error(`Fear & Greed request failed: ${res.status}`);
const raw = await res.json();
if (cancelled) return;
setData(parseFngResponse(raw));
setError(null);
setLoading(false);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Fear & Greed request failed");
setLoading(false);
}
};
load();
const timer = setInterval(load, refreshInterval);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [enabled, refreshInterval]);
return { data, loading, error };
}
registry/voraui/fear-greed-gauge/lib/fear-greed-bands.ts
export interface FearGreedBand {
key: string;
label: string;
min: number;
max: number;
color: string;
}
/** Boundaries mirror alternative.me's value_classification thresholds. */
export const DEFAULT_FEAR_GREED_BANDS: FearGreedBand[] = [
{ key: "extreme-fear", label: "Extreme Fear", min: 0, max: 24, color: "#c0392b" },
{ key: "fear", label: "Fear", min: 25, max: 44, color: "#e0672b" },
{ key: "neutral", label: "Neutral", min: 45, max: 55, color: "#f0c929" },
{ key: "greed", label: "Greed", min: 56, max: 75, color: "#4caf50" },
{ key: "extreme-greed", label: "Extreme Greed", min: 76, max: 100, color: "#2e7d32" },
];
export const GAUGE_CENTER_X = 130;
export const GAUGE_CENTER_Y = 130;
/** Angle in degrees for a 0-100 value along the top semicircle (180 at 0, 0 at 100). */
export function angleForValue(value: number): number {
const clamped = Math.min(Math.max(value, 0), 100);
return 180 - (clamped / 100) * 180;
}
/** Point at radius for a 0-100 value; rounded to 4 decimals to avoid hydration mismatches. */
export function arcPoint(radius: number, value: number): { x: number; y: number } {
const rad = (angleForValue(value) * Math.PI) / 180;
return {
x: Math.round((GAUGE_CENTER_X + radius * Math.cos(rad)) * 10000) / 10000,
y: Math.round((GAUGE_CENTER_Y - radius * Math.sin(rad)) * 10000) / 10000,
};
}
/** SVG arc path between two values along the top semicircle. */
export function describeArc(radius: number, fromValue: number, toValue: number): string {
const start = arcPoint(radius, fromValue);
const end = arcPoint(radius, toValue);
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 0 1 ${end.x} ${end.y}`;
}
/** SVG path for an annular wedge between two radii and two values. */
export function describeWedge(
outerRadius: number,
innerRadius: number,
fromValue: number,
toValue: number,
): string {
const outerStart = arcPoint(outerRadius, fromValue);
const outerEnd = arcPoint(outerRadius, toValue);
const innerEnd = arcPoint(innerRadius, toValue);
const innerStart = arcPoint(innerRadius, fromValue);
return (
`M ${outerStart.x} ${outerStart.y} A ${outerRadius} ${outerRadius} 0 0 1 ${outerEnd.x} ${outerEnd.y} ` +
`L ${innerEnd.x} ${innerEnd.y} A ${innerRadius} ${innerRadius} 0 0 0 ${innerStart.x} ${innerStart.y} Z`
);
}
/** Band for a 0-100 value; out-of-range clamps to the nearest edge band. */
export function findFearGreedBand(
value: number,
bands: FearGreedBand[] = DEFAULT_FEAR_GREED_BANDS,
): FearGreedBand {
const clamped = Math.min(Math.max(value, 0), 100);
return bands.find((band) => clamped >= band.min && clamped <= band.max) ?? bands[bands.length - 1];
}
/** Remap a 0-100 value onto a CNN-style dial where every band gets an equal
* angular share; the needle, dots, and scale numbers all use this mapping. */
export function equalizedValue(
value: number,
bands: FearGreedBand[] = DEFAULT_FEAR_GREED_BANDS,
): number {
const clamped = Math.min(Math.max(value, 0), 100);
const band = findFearGreedBand(clamped, bands);
const index = bands.indexOf(band);
const share = 100 / bands.length;
return index * share + ((clamped - band.min) / (band.max - band.min)) * share;
}
export interface GradientStop {
value: number;
color: string;
}
/** Evenly-spaced band colors used to interpolate the per-tick gradient. */
export const GRADIENT_STOPS: GradientStop[] = [
{ value: 0, color: "#c0392b" },
{ value: 25, color: "#e0672b" },
{ value: 50, color: "#f0c929" },
{ value: 75, color: "#4caf50" },
{ value: 100, color: "#2e7d32" },
];
function hexToRgb(hex: string): [number, number, number] {
const clean = hex.replace("#", "");
const num = parseInt(clean, 16);
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}
function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
}
/** Interpolated hex color for a 0-100 value between GRADIENT_STOPS. */
export function colorForValue(value: number, stops: GradientStop[] = GRADIENT_STOPS): string {
const clamped = Math.min(Math.max(value, 0), 100);
for (let i = 0; i < stops.length - 1; i++) {
const from = stops[i];
const to = stops[i + 1];
if (clamped >= from.value && clamped <= to.value) {
const t = (clamped - from.value) / (to.value - from.value);
const [r1, g1, b1] = hexToRgb(from.color);
const [r2, g2, b2] = hexToRgb(to.color);
return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
}
}
return stops[stops.length - 1].color;
}
// Wedge geometry, shared with the skeleton so the ghost dial lines up.
export const WEDGE_OUTER_RADIUS = 104;
export const WEDGE_INNER_RADIUS = 64;
export const WEDGE_GAP = 1.2;
export const WEDGE_HUB_RADIUS = 40;
// Fits the hub circle plus margin; also sizes the centered value overlay.
export const WEDGES_VIEWBOX_HEIGHT = 180;