Componentfeedback2.54 KB gzipZero dependencies
Trend Badge
Dynamic rate and velocity indicator badge with live pulse indicator.
Preview
Live Preview
TICKET DEMAND+24.8%
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add trend-badgeOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { TrendBadge } from "@/components/ui/trend-badge";
<TrendBadge value="+24.8%" label="TICKET DEMAND" live />Usage guidance
Use Trend Badge when a ticket stub surface needs Dynamic rate and velocity indicator badge with live pulse indicator. Keep props minimal and prefer composition with sibling stubs.
Avoid Trend Badge for long form flows or dense data grids where a dedicated layout block fits better.
Source code
trend-badge.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
export type TrendDirection = "up" | "down" | "neutral";
export type TrendVelocityLevel = "low" | "medium" | "high" | "surge" | 1 | 2 | 3 | 4;
export type TrendBadgeVariant = "stamp" | "outline" | "solid" | "subtle";
export type TrendBadgeSize = "sm" | "md" | "lg";
export interface TrendBadgeProps extends React.HTMLAttributes<HTMLDivElement> {
value: number | string;
trend?: TrendDirection;
label?: string;
timeframe?: string;
rate?: string;
velocity?: TrendVelocityLevel;
variant?: TrendBadgeVariant;
size?: TrendBadgeSize;
live?: boolean;
notched?: boolean;
animated?: boolean;
stampAngle?: boolean;
className?: string;
}
export function TrendBadge({
value,
trend,
label,
timeframe,
rate,
velocity,
variant = "stamp",
size = "md",
live = true,
notched = false,
animated = true,
stampAngle = false,
className,
...props
}: TrendBadgeProps) {
// Determine trend direction automatically if not explicitly provided
const resolvedTrend: TrendDirection = React.useMemo(() => {
if (trend) return trend;
if (typeof value === "number") {
if (value > 0) return "up";
if (value < 0) return "down";
return "neutral";
}
const clean = String(value).trim();
if (clean.startsWith("+")) return "up";
if (clean.startsWith("-")) return "down";
return "neutral";
}, [trend, value]);
// Format value with sign & percentage if number
const formattedValue = React.useMemo(() => {
if (typeof value === "number") {
const sign = value > 0 ? "+" : "";
return `${sign}${value}%`;
}
return value;
}, [value]);
// Compute velocity bars count (1 to 4)
const velocityScore = React.useMemo(() => {
if (typeof velocity === "number") {
return Math.max(1, Math.min(4, velocity));
}
switch (velocity) {
case "low":
return 1;
case "medium":
return 2;
case "high":
return 3;
case "surge":
return 4;
default:
return resolvedTrend === "up" ? 3 : resolvedTrend === "down" ? 1 : 2;
}
}, [velocity, resolvedTrend]);
// Styling based on trend direction & variants
const toneStyles: Record<TrendDirection, Record<TrendBadgeVariant, string>> = {
up: {
stamp: "border-success/70 bg-success/10 text-success/80/40",
outline: "border-success/80 bg-transparent text-success",
solid: "border-success bg-success text-white",
subtle: "border-border bg-card text-success",
},
down: {
stamp: "border-destructive/70 bg-destructive/10 text-destructive",
outline: "border-destructive/80 bg-transparent text-destructive",
solid: "border-destructive bg-destructive text-accent-foreground",
subtle: "border-border bg-card text-destructive",
},
neutral: {
stamp: "border-border bg-secondary/70 text-foreground",
outline: "border-border bg-transparent text-muted-foreground",
solid: "border-foreground bg-foreground text-background",
subtle: "border-border bg-card text-muted-foreground",
},
};
const sizeStyles: Record<TrendBadgeSize, {
container: string;
arrow: string;
value: string;
label: string;
notch: string;
gap: string;
}> = {
sm: {
container: "px-2 py-0.5 text-[10px]",
arrow: "size-3",
value: "text-[11px] font-black",
label: "text-[8px] tracking-wider",
notch: "size-2 -left-1 -right-1",
gap: "gap-1.5",
},
md: {
container: "px-3 py-1 text-xs",
arrow: "size-3.5",
value: "text-xs font-black",
label: "text-[9px] tracking-widest",
notch: "size-2.5 -left-1.5 -right-1.5",
gap: "gap-2",
},
lg: {
container: "px-4 py-1.5 text-sm",
arrow: "size-4",
value: "text-sm font-black",
label: "text-[10px] tracking-widest",
notch: "size-3 -left-1.5 -right-1.5",
gap: "gap-2.5",
},
};
const currentSize = sizeStyles[size];
return (
<div
role="status"
aria-label={`Ticket sales trend: ${formattedValue} (${resolvedTrend}), velocity: level ${velocityScore} of 4${
rate ? `, ${rate}` : ""
}`}
className={cn(
// Base badge geometry & ticket stub stamp framing
"group relative inline-flex items-center rounded-xs border-2 border-dashed font-mono font-bold select-none transition-all duration-200",
currentSize.container,
currentSize.gap,
toneStyles[resolvedTrend][variant],
stampAngle && "rotate-[-1.5deg] hover:rotate-0",
className
)}
{...props}
>
{/* Semicircular Ticket Punch Notches */}
{notched && (
<>
<div
aria-hidden="true"
className={cn(
"absolute top-1/2 -translate-y-1/2 rounded-full border border-current bg-background pointer-events-none",
currentSize.notch.includes("size-2") && "size-2 -left-1",
currentSize.notch.includes("size-2.5") && "size-2.5 -left-1.5",
currentSize.notch.includes("size-3") && "size-3 -left-1.5"
)}
/>
<div
aria-hidden="true"
className={cn(
"absolute top-1/2 -translate-y-1/2 rounded-full border border-current bg-background pointer-events-none",
currentSize.notch.includes("size-2") && "size-2 -right-1",
currentSize.notch.includes("size-2.5") && "size-2.5 -right-1.5",
currentSize.notch.includes("size-3") && "size-3 -right-1.5"
)}
/>
</>
)}
{/* Live Sales Velocity Pulsing Beacon */}
{live && (
<span className="relative flex size-2 shrink-0 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-75" />
<span className="relative inline-flex size-1.5 rounded-full bg-current" />
</span>
)}
{/* Micro Eyebrow Label */}
{label && (
<span className={cn("uppercase opacity-80", currentSize.label)}>
{label}
</span>
)}
{/* Upward / Downward Ticker Arrow */}
<span className="shrink-0 flex items-center justify-center">
{resolvedTrend === "up" && (
<svg
aria-hidden="true"
className={cn(
currentSize.arrow,
"stroke-current",
animated && "transition-transform duration-300 group-hover:-translate-y-0.5"
)}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 13.5V2.5" />
<path d="M3.5 7L8 2.5L12.5 7" />
</svg>
)}
{resolvedTrend === "down" && (
<svg
aria-hidden="true"
className={cn(
currentSize.arrow,
"stroke-current",
animated && "transition-transform duration-300 group-hover:translate-y-0.5"
)}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 2.5V13.5" />
<path d="M3.5 9L8 13.5L12.5 9" />
</svg>
)}
{resolvedTrend === "neutral" && (
<svg
aria-hidden="true"
className={cn(currentSize.arrow, "stroke-current")}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2.5 8H13.5" />
</svg>
)}
</span>
{/* Percentage / Delta Readout */}
<span className={cn("tracking-tight font-black", currentSize.value)}>
{formattedValue}
</span>
{/* Mechanical Velocity Meter Bars (4 segments) */}
{velocity !== undefined && (
<div
aria-hidden="true"
className="flex items-end gap-[2px] px-0.5 py-0.5"
title={`Sales velocity: ${velocityScore}/4`}
>
{[1, 2, 3, 4].map((bar) => (
<div
key={bar}
style={{ height: `${bar * 3 + 3}px` }}
className={cn(
"w-[2.5px] rounded-[0.5px] transition-all duration-200",
bar <= velocityScore ? "bg-current opacity-100" : "bg-current/25"
)}
/>
))}
</div>
)}
{/* Live Rate Readout (e.g. '18/min', '420/hr') */}
{rate && (
<span className="text-[9px] uppercase tracking-wider opacity-85 font-mono">
{rate}
</span>
)}
{/* Timeframe Tag (e.g. 'vs last hr', '24h') */}
{timeframe && (
<span className="text-[9px] uppercase tracking-widest opacity-65 font-mono border-l border-dashed border-current/40 ps-1.5 ms-0.5">
{timeframe}
</span>
)}
</div>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| value | number | string | Value or percentage to display |
| trend | "up" | "down" | "neutral" | Direction of the trend |
| label | string | Optional label below value |
| live | boolean | Whether to show live pulsing dot |