dashboardcn
blockallocation-cardsource: ok · hit
Allocation Card
A share-of-total card with a period switcher, a big percentage with delta, a tick bar, and a footer stat with an avatar stack.
npx shadcn add "https://dashboardcn.com/r/allocation-card.json"free
Where it goes
footerstats
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
cardtabstooltip
Files6 · source cached 10/14/2026, 3:38:05 PM
registry/dashboardcn/blocks/allocation-card.tsx
"use client"
import * as React from "react"
import { Info } from "lucide-react"
import { cn } from "@/lib/utils"
import { type NumberFormat } from "@/registry/dashboardcn/lib/format"
import {
Card,
CardAction,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { DeltaBadge } from "@/registry/dashboardcn/ui/delta-badge"
import { PeriodTabs, type PeriodOption } from "@/registry/dashboardcn/ui/period-tabs"
import { TickBar } from "@/registry/dashboardcn/ui/tick-bar"
import { MetricValue } from "@/registry/dashboardcn/ui/metric-value"
export interface AllocationCardPerson {
name: string
/** Avatar image. Falls back to the first letter of the name. */
src?: string
}
export interface AllocationCardProps extends React.ComponentProps<typeof Card> {
title: string
/** Shown in a tooltip behind an info icon next to the title. */
description?: string
/** Share of the whole, e.g. 0.46 for 46%. */
value: number
format?: NumberFormat
currency?: string
/** Bar fill from 0 to 100. Defaults to value when format is percent. */
progress?: number
/** Fractional change vs the prior period, e.g. 0.034 for +3.4%. */
delta?: number
/** Text after the delta, e.g. "vs prior period". */
deltaLabel?: string
/** Treat a decrease as good. */
invertDelta?: boolean
/** Any CSS color for the bar. */
color?: string
/** Number of ticks in the bar. */
segments?: number
/** Period options for the switcher. Omit to hide it. */
periods?: PeriodOption[]
period?: string
defaultPeriod?: string
onPeriodChange?: (period: string) => void
/** Footer stat, e.g. { label: "Equities exposure", value: "$4.7M" }. */
stat?: { label: string; value: React.ReactNode }
/** Avatars shown in the footer. */
people?: AllocationCardPerson[]
/** Text next to the avatars, e.g. "6 members". */
peopleLabel?: string
}
/** A share-of-total card with a period switcher, a big percentage with delta, a tick bar, and a footer stat with an avatar stack. */
function AllocationCard({
title,
description,
value,
format = "percent",
currency,
progress,
delta,
deltaLabel,
invertDelta,
color = "var(--color-emerald-500)",
segments = 40,
periods,
period,
defaultPeriod,
onPeriodChange,
stat,
people = [],
peopleLabel,
className,
...props
}: AllocationCardProps) {
const fill = progress ?? (format === "percent" ? value * 100 : value)
return (
<Card data-slot="allocation-card" className={cn("@container/card gap-4", className)} {...props}>
<CardHeader className="@max-sm/card:[grid-template-columns:minmax(0,1fr)]!">
<CardTitle className="flex items-center gap-1.5 text-sm">
<span className="truncate">{title}</span>
{description ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground inline-flex shrink-0 rounded-full"
>
<Info className="size-3.5" />
<span className="sr-only">About {title}</span>
</button>
</TooltipTrigger>
<TooltipContent className="max-w-56">{description}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</CardTitle>
{periods ? (
<CardAction className="@max-sm/card:[grid-area:auto]! @max-sm/card:justify-self-start!">
<PeriodTabs
options={periods}
value={period}
defaultValue={defaultPeriod ?? periods[0]?.value}
onValueChange={onPeriodChange}
/>
</CardAction>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<MetricValue
value={value}
format={format}
currency={currency}
maximumFractionDigits={0}
className="text-4xl font-semibold tracking-tight"
/>
{delta !== undefined ? (
<DeltaBadge delta={delta} invert={invertDelta} variant="text" showIcon={false} className="text-sm" />
) : null}
{deltaLabel ? (
<span className="text-muted-foreground text-sm">{deltaLabel}</span>
) : null}
</div>
<TickBar value={fill} segments={segments} color={color} />
</CardContent>
{stat || people.length || peopleLabel ? (
<CardFooter className="justify-between gap-4 text-sm @max-sm/card:flex-col @max-sm/card:items-start @max-sm/card:gap-2">
{stat ? (
<span className="text-muted-foreground min-w-0 truncate">
{stat.label}:{" "}
<span className="text-foreground font-semibold tabular-nums">{stat.value}</span>
</span>
) : (
<span />
)}
{people.length || peopleLabel ? (
<div className="flex shrink-0 items-center gap-2">
{people.length ? (
<div
className="flex -space-x-2"
aria-label={people.map((p) => p.name).join(", ")}
>
{people.map((person) => (
<span
key={person.name}
title={person.name}
className="bg-muted ring-card text-muted-foreground flex size-7 items-center justify-center overflow-hidden rounded-full text-xs font-medium ring-2"
>
{person.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={person.src}
alt={person.name}
className="size-full object-cover"
/>
) : (
person.name.charAt(0).toUpperCase()
)}
</span>
))}
</div>
) : null}
{peopleLabel ? (
<span className="text-muted-foreground whitespace-nowrap">{peopleLabel}</span>
) : null}
</div>
) : null}
</CardFooter>
) : null}
</Card>
)
}
export { AllocationCard }
registry/dashboardcn/ui/tick-bar.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TickBarProps extends React.ComponentProps<"div"> {
/** Current value, from 0 to max. */
value: number
max?: number
/** Number of ticks. */
segments?: number
/** Any CSS color for the filled ticks. Defaults to chart-1. */
color?: string
/** Any CSS color for the unfilled ticks. Defaults to muted. */
trackColor?: string
/** "tick" draws thin bars; "pill" draws wide rounded ones. */
shape?: "tick" | "pill"
/** Reveal filled ticks in sequence when the bar mounts. */
animate?: boolean
/** Total reveal time in milliseconds. */
animationDuration?: number
}
/** A progress bar drawn as a row of ticks, lit up to the current value. */
function TickBar({
value,
max = 100,
segments = 40,
color = "var(--chart-1)",
trackColor,
shape = "tick",
animate = false,
animationDuration = 700,
className,
...props
}: TickBarProps) {
const fraction = Math.min(1, Math.max(0, value / (max || 1)))
// Any non-zero value lights at least one tick so small values still register.
const filled = fraction === 0 ? 0 : Math.max(1, Math.round(fraction * segments))
return (
<div
data-slot="tick-bar"
data-shape={shape}
role="progressbar"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
className={cn(
"flex w-full",
shape === "tick" ? "h-6 gap-[3px]" : "h-4 gap-1.5",
className
)}
{...props}
>
{Array.from({ length: segments }, (_, index) => {
const lit = index < filled
return (
<span
key={index}
data-filled={lit}
className={cn(
"bg-muted relative h-full min-w-0 flex-1 overflow-hidden transition-colors",
shape === "tick" ? "rounded-[1.5px]" : "rounded-full"
)}
style={{ backgroundColor: trackColor }}
>
{lit ? (
<span
className={cn(
"absolute inset-0",
shape === "tick" ? "rounded-[1.5px]" : "rounded-full",
animate && "motion-safe:animate-in motion-safe:fade-in"
)}
style={{
backgroundColor: color,
...(animate
? {
animationDelay: `${(index / Math.max(1, filled)) * animationDuration * 0.8}ms`,
animationDuration: `${Math.max(120, animationDuration * 0.25)}ms`,
animationFillMode: "both",
}
: null),
}}
/>
) : null}
</span>
)
})}
</div>
)
}
export { TickBar }
registry/dashboardcn/ui/delta-badge.tsx
import * as React from "react"
import { Minus, TrendingDown, TrendingUp } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatDelta } from "@/registry/dashboardcn/lib/format"
export type DeltaDirection = "up" | "down" | "flat"
export function getDeltaDirection(delta: number | undefined): DeltaDirection {
if (delta === undefined || delta === 0 || !Number.isFinite(delta)) return "flat"
return delta > 0 ? "up" : "down"
}
const directionIcon: Record<DeltaDirection, React.ElementType> = {
up: TrendingUp,
down: TrendingDown,
flat: Minus,
}
export interface DeltaBadgeProps extends React.ComponentProps<"span"> {
/** Fractional change, e.g. 0.124 for +12.4%. */
delta: number
/** Treat a decrease as good and an increase as bad (churn, latency, errors). */
invert?: boolean
variant?: "outline" | "soft" | "text"
showIcon?: boolean
}
function DeltaBadge({
delta,
invert = false,
variant = "outline",
showIcon = true,
className,
children,
...props
}: DeltaBadgeProps) {
const direction = getDeltaDirection(delta)
const positive = direction === "flat" ? null : (direction === "up") !== invert
const Icon = directionIcon[direction]
return (
<span
data-slot="delta-badge"
data-direction={direction}
data-positive={positive}
className={cn(
"inline-flex items-center gap-1 text-xs font-medium tabular-nums",
variant === "outline" && "rounded-md border px-1.5 py-0.5",
variant === "soft" && "rounded-md px-1.5 py-0.5",
variant === "soft" && positive === true && "bg-emerald-500/10",
variant === "soft" && positive === false && "bg-red-500/10",
variant === "soft" && positive === null && "bg-muted",
positive === true && "text-emerald-600 dark:text-emerald-400",
positive === false && "text-red-600 dark:text-red-400",
positive === null && "text-muted-foreground",
className
)}
{...props}
>
{showIcon ? <Icon className="size-3" aria-hidden="true" /> : null}
{children ?? formatDelta(delta)}
</span>
)
}
export { DeltaBadge }
registry/dashboardcn/ui/period-tabs.tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
export interface PeriodOption {
value: string
label: string
}
export const defaultPeriods: PeriodOption[] = [
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
{ value: "year", label: "Year" },
]
export interface PeriodTabsProps
extends Omit<React.ComponentProps<typeof Tabs>, "children"> {
options?: PeriodOption[]
size?: "sm" | "default"
/** Slide a single pill between tabs instead of swapping backgrounds. */
animated?: boolean
}
interface IndicatorRect {
left: number
top: number
width: number
height: number
}
/** Small segmented control for switching a chart's time range. */
function PeriodTabs({
options = defaultPeriods,
size = "sm",
animated = false,
className,
value,
defaultValue,
onValueChange,
...props
}: PeriodTabsProps) {
const listRef = React.useRef<HTMLDivElement>(null)
const [internalValue, setInternalValue] = React.useState(
defaultValue ?? options[0]?.value
)
const active = value ?? internalValue
const [rect, setRect] = React.useState<IndicatorRect | null>(null)
const measure = React.useCallback(() => {
const list = listRef.current
if (!list) return
const trigger = list.querySelector<HTMLElement>('[data-state="active"]')
if (!trigger) return
setRect({
left: trigger.offsetLeft,
top: trigger.offsetTop,
width: trigger.offsetWidth,
height: trigger.offsetHeight,
})
}, [])
React.useLayoutEffect(() => {
if (!animated) return
measure()
}, [animated, active, options, size, measure])
React.useEffect(() => {
if (!animated || !listRef.current) return
const observer = new ResizeObserver(measure)
observer.observe(listRef.current)
return () => observer.disconnect()
}, [animated, measure])
return (
<Tabs
data-slot="period-tabs"
className={cn("w-fit", className)}
value={value}
defaultValue={defaultValue}
onValueChange={(next) => {
setInternalValue(next)
onValueChange?.(next)
}}
{...props}
>
<TabsList
ref={listRef}
className={cn(
"relative",
size === "sm" && "group-data-[orientation=horizontal]/tabs:h-8 p-1"
)}
>
{animated && rect ? (
<span
aria-hidden
data-slot="period-tabs-indicator"
className="pointer-events-none absolute rounded-md bg-background shadow-sm transition-[left,top,width,height] duration-300 ease-[cubic-bezier(0.25,1,0.5,1)] motion-reduce:transition-none dark:border dark:border-input dark:bg-input/30"
style={rect}
/>
) : null}
{options.map((option) => (
<TabsTrigger
key={option.value}
value={option.value}
className={cn(
size === "sm" && "h-6 px-2.5 text-xs",
animated &&
"relative z-10 data-[state=active]:bg-transparent group-data-[variant=default]/tabs-list:data-[state=active]:shadow-none dark:data-[state=active]:border-transparent dark:data-[state=active]:bg-transparent"
)}
>
{option.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)
}
export { PeriodTabs }
registry/dashboardcn/ui/metric-value.tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatNumber, type NumberFormat } from "@/registry/dashboardcn/lib/format"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
/** Values at or above this are abbreviated by default. */
export const DEFAULT_COMPACT_FROM = 100_000
export interface MetricValueProps extends Omit<React.ComponentProps<"span">, "children"> {
/** Strings render as-is. */
value: number | string
format?: NumberFormat
currency?: string
maximumFractionDigits?: number
/** Values at or above this are abbreviated (e.g. $158K) with the full value in a tooltip. Set to Infinity to always show the full value. */
compactFrom?: number
/** Text after the number, e.g. a unit. */
suffix?: React.ReactNode
}
/** A formatted number. Large values are abbreviated and reveal the full value on hover. */
function MetricValue({
value,
format,
currency,
maximumFractionDigits,
compactFrom = DEFAULT_COMPACT_FROM,
suffix,
className,
...props
}: MetricValueProps) {
const classes = cn("tabular-nums", className)
if (typeof value !== "number") {
return (
<span data-slot="metric-value" className={classes} {...props}>
{value}
{suffix}
</span>
)
}
const full = formatNumber(value, { format, currency, maximumFractionDigits })
const abbreviate = format !== "percent" && Math.abs(value) >= compactFrom
if (!abbreviate) {
return (
<span data-slot="metric-value" className={classes} {...props}>
{full}
{suffix}
</span>
)
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
data-slot="metric-value"
data-compact=""
className={cn(classes, "cursor-default")}
{...props}
>
{formatNumber(value, { format, currency, compact: true })}
{suffix}
</span>
</TooltipTrigger>
<TooltipContent className="tabular-nums">
{full}
{suffix}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export { MetricValue }
registry/dashboardcn/lib/format.ts
export type NumberFormat = "number" | "compact" | "currency" | "percent"
export interface FormatNumberOptions {
format?: NumberFormat
/** ISO 4217 code, used when format is "currency". Defaults to USD. */
currency?: string
locale?: string
maximumFractionDigits?: number
/** Abbreviate large values, e.g. "$158K" instead of "$158,143". */
compact?: boolean
}
const formatterCache = new Map<string, Intl.NumberFormat>()
function getFormatter(locale: string, options: Intl.NumberFormatOptions) {
const key = locale + JSON.stringify(options)
let formatter = formatterCache.get(key)
if (!formatter) {
formatter = new Intl.NumberFormat(locale, options)
formatterCache.set(key, formatter)
}
return formatter
}
/**
* Format a metric value for display.
*
* formatNumber(1234567) -> "1,234,567"
* formatNumber(1234567, { format: "compact" }) -> "1.2M"
* formatNumber(48.2, { format: "currency" }) -> "$48.20"
* formatNumber(158143, { format: "currency", compact: true }) -> "$158K"
* formatNumber(0.124, { format: "percent" }) -> "12.4%"
*/
export function formatNumber(
value: number,
{
format = "number",
currency = "USD",
locale = "en-US",
maximumFractionDigits,
compact = false,
}: FormatNumberOptions = {}
): string {
if (!Number.isFinite(value)) return "—"
// Compact values keep three significant digits: $158K, $1.23M, 41.2K.
const compactOptions: Intl.NumberFormatOptions = compact
? maximumFractionDigits === undefined
? { notation: "compact", maximumSignificantDigits: 3 }
: { notation: "compact", maximumFractionDigits }
: {}
switch (format) {
case "compact":
return getFormatter(locale, {
notation: "compact",
maximumFractionDigits: maximumFractionDigits ?? 1,
}).format(value)
case "currency":
return getFormatter(locale, {
style: "currency",
currency,
maximumFractionDigits: maximumFractionDigits ?? 2,
...compactOptions,
}).format(value)
case "percent":
return getFormatter(locale, {
style: "percent",
maximumFractionDigits: maximumFractionDigits ?? 1,
}).format(value)
default:
return getFormatter(locale, {
maximumFractionDigits: maximumFractionDigits ?? 0,
...compactOptions,
}).format(value)
}
}
/**
* Format a fractional change as a signed percentage.
*
* formatDelta(0.124) -> "+12.4%"
* formatDelta(-0.03) -> "-3.0%"
* formatDelta(0) -> "0.0%"
*/
export function formatDelta(delta: number, locale = "en-US"): string {
if (!Number.isFinite(delta)) return "—"
return getFormatter(locale, {
style: "percent",
signDisplay: "exceptZero",
maximumFractionDigits: 1,
minimumFractionDigits: 1,
}).format(delta)
}
/**
* Fractional change between two values. Returns 0 when there is no baseline.
*/
export function computeDelta(current: number, previous: number): number {
if (!previous) return 0
return (current - previous) / Math.abs(previous)
}