fluid
uibadgesource: ok · hit
Badge
Compact label with solid and dot variants, Tailwind color palette, and three sizes.
npx shadcn add "https://www.fluidfunctionalism.com/r/badge.json"free
Visual style
3d
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightclass-variance-authority
Registry dependencies
utilshttps://www.fluidfunctionalism.com/r/shape-context.jsonhttps://www.fluidfunctionalism.com/r/size-context.jsonhttps://www.fluidfunctionalism.com/r/type-scale.json
Files1 · source cached 10/9/2026, 3:30:14 PM
registry/default/badge.tsx
"use client";
import { forwardRef, type HTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { useShape } from "@/lib/shape-context";
import { useSizeVariant } from "@/lib/size-context";
// ---------------------------------------------------------------------------
// Badge is a short label for status, category or metadata, in 2 variants:
// solid: a light tint of the color behind foreground text.
// dot: a neutral outline and foreground text; only the dot is colored.
// Each color is a single hex. The solid tint is mixed against the page
// background at render time, so the same hex works in light and dark themes.
// ---------------------------------------------------------------------------
// Tailwind's 500 step for each hue (400 for gray). Text never takes the hue:
// it stays on the foreground color, so it keeps its contrast on every color.
const badgeColors = {
gray: "#a3a3a3",
red: "#ef4444",
orange: "#f97316",
amber: "#f59e0b",
yellow: "#eab308",
lime: "#84cc16",
green: "#22c55e",
emerald: "#10b981",
teal: "#14b8a6",
cyan: "#06b6d4",
blue: "#3b82f6",
indigo: "#6366f1",
violet: "#8b5cf6",
purple: "#a855f7",
fuchsia: "#d946ef",
pink: "#ec4899",
rose: "#f43f5e",
} as const;
type BadgeColor = keyof typeof badgeColors;
const badgeVariants = cva(
"inline-flex items-center whitespace-nowrap",
{
variants: {
variant: {
solid: "",
dot: "border border-border text-foreground",
},
// The two-step size ladder shared by every control: 24px tall by
// default, 20px compact. The height is fixed rather than padded so the
// trimmed label below can't shrink the badge.
size: {
default: "h-6 px-2.5 text-[length:var(--fs-caption,12px)] leading-[var(--lh-caption,16px)] gap-1.5",
compact: "h-5 px-2 text-[length:var(--fs-caption-compact,11px)] leading-[var(--lh-caption-compact,14px)] gap-1",
},
},
defaultVariants: {
variant: "solid",
size: "default",
},
}
);
type BadgeSizeCanonical = "default" | "compact";
/** Public size values: the canonical two-size scale plus the pre-sizes-system
* aliases, kept so existing call sites keep compiling. Aliases resolve onto
* the canonical ladder (sm → compact; md/lg → default). */
type BadgeSize = BadgeSizeCanonical | "sm" | "md" | "lg";
const legacySizeAliases: Partial<Record<BadgeSize, BadgeSizeCanonical>> = {
sm: "compact",
md: "default",
lg: "default",
};
interface BadgeProps
extends Omit<HTMLAttributes<HTMLSpanElement>, "color">,
Omit<VariantProps<typeof badgeVariants>, "size"> {
color?: BadgeColor;
/** Omitted, the badge follows the surrounding SizeProvider. Legacy
* sm/md/lg values still resolve. */
size?: BadgeSize;
}
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
(
{
className,
variant = "solid",
size: sizeProp,
color = "gray",
children,
style,
...props
},
ref
) => {
// Corner radius comes from the shape context (pill or rounded), so
// badges follow the rest of the app instead of hard-coding a radius.
const shape = useShape();
// Resolve the size: explicit prop (legacy aliases mapped onto the
// canonical ladder) > surrounding SizeProvider > default.
const contextSize = useSizeVariant();
const size: BadgeSizeCanonical = sizeProp
? legacySizeAliases[sizeProp] ?? (sizeProp as BadgeSizeCanonical)
: contextSize === "compact"
? "compact"
: "default";
const colorValue = badgeColors[color];
const isSolid = variant === "solid";
const dotSize = size === "compact" ? 6 : 7;
// 15% of the hue mixed into the page background: a tint that sits at the
// right depth in either theme, under plain foreground text. Gray skips
// the mix and takes the theme's neutral fill (`--accent`), so a gray
// badge matches the other neutral fills around it.
const colorStyle = isSolid
? color === "gray"
? { backgroundColor: "var(--accent)", color: "var(--foreground)" }
: {
color: "var(--foreground)",
backgroundColor: `color-mix(in srgb, ${colorValue} 15%, var(--background))`,
}
: {};
// The dot carries the full-strength hex. Gray uses the theme's muted text
// color instead, so the neutral dot stays visible in both themes.
const dotColor = color === "gray" ? "var(--muted-foreground)" : colorValue;
return (
<span
ref={ref}
className={cn(badgeVariants({ variant, size }), shape.item, className)}
style={{ ...colorStyle, ...style }}
{...props}
>
{!isSolid && (
<span
className="shrink-0 rounded-full"
style={{
width: dotSize,
height: dotSize,
backgroundColor: dotColor,
}}
/>
)}
{/* text-box needs a block container. The badge root is a flex
container, so the label gets its own span. Height is fixed (h-*),
so trimming only recenters the letterforms. */}
<span className="[text-box:trim-both_cap_alphabetic]">{children}</span>
</span>
);
}
);
Badge.displayName = "Badge";
export { Badge, badgeVariants, badgeColors };
export type { BadgeProps, BadgeColor, BadgeSize };