diff --git a/.changeset/linked-badges-base.md b/.changeset/linked-badges-base.md
new file mode 100644
index 0000000000..1823a12037
--- /dev/null
+++ b/.changeset/linked-badges-base.md
@@ -0,0 +1,5 @@
+---
+"@cloudflare/kumo": minor
+---
+
+Add icon support to filled Badge variants through the `icon` prop, add linked-badge hover styles, and use the base surface background for outline badges.
diff --git a/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx b/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx
index a87582c517..1fdf87f8e5 100644
--- a/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx
+++ b/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx
@@ -1,4 +1,5 @@
-import { Badge } from "@cloudflare/kumo";
+import { Badge, Link } from "@cloudflare/kumo";
+import { CheckCircleIcon } from "@phosphor-icons/react";
export function BadgeSemanticVariantsDemo() {
return (
@@ -38,6 +39,22 @@ export function BadgeInSentenceDemo() {
);
}
+export function BadgeIconDemo() {
+ return (
+
+ Verified
+
+ );
+}
+
+export function BadgeLinkDemo() {
+ return (
+
+ View changelog
+
+ );
+}
+
export function BadgeDotDemo() {
return (
diff --git a/packages/kumo-docs-astro/src/pages/components/badge.mdx b/packages/kumo-docs-astro/src/pages/components/badge.mdx
index 6470cddae9..06466d281e 100644
--- a/packages/kumo-docs-astro/src/pages/components/badge.mdx
+++ b/packages/kumo-docs-astro/src/pages/components/badge.mdx
@@ -11,6 +11,8 @@ import {
BadgeSemanticVariantsDemo,
BadgeColorVariantsDemo,
BadgeInSentenceDemo,
+ BadgeIconDemo,
+ BadgeLinkDemo,
BadgeDotDemo,
} from "~/components/demos/BadgeDemo";
@@ -73,6 +75,22 @@ Use `appearance="dot"` for a subtle status indicator with a colored dot. Support
+### With an icon
+
+Filled badges accept a Phosphor icon component or React element through `icon`. Dot badges use their status dot and do not accept icons.
+
+
+
+
+
+### Linked badge
+
+Wrap a badge in [`Link`](/components/link) to make it navigable. The badge adds a ring when its ancestor link is hovered.
+
+
+
+
+
## API Reference
diff --git a/packages/kumo-docs-astro/src/pages/tests/badge.astro b/packages/kumo-docs-astro/src/pages/tests/badge.astro
new file mode 100644
index 0000000000..7c719832c6
--- /dev/null
+++ b/packages/kumo-docs-astro/src/pages/tests/badge.astro
@@ -0,0 +1,160 @@
+---
+import { Badge, Link, type BadgeVariant } from "@cloudflare/kumo";
+import { CheckCircleIcon } from "@phosphor-icons/react";
+import BaseLayout from "../../layouts/BaseLayout.astro";
+
+const variants = [
+ { value: "primary", label: "Primary" },
+ { value: "secondary", label: "Secondary" },
+ { value: "error", label: "Error" },
+ { value: "warning", label: "Warning" },
+ { value: "success", label: "Success" },
+ { value: "destructive", label: "Destructive" },
+ { value: "info", label: "Info" },
+ { value: "beta", label: "Beta" },
+ { value: "outline", label: "Outline" },
+ { value: "red", label: "Red" },
+ { value: "green", label: "Green" },
+ { value: "neutral", label: "Neutral" },
+ { value: "orange", label: "Orange" },
+ { value: "purple", label: "Purple" },
+ { value: "teal", label: "Teal" },
+ { value: "teal-subtle", label: "Teal subtle" },
+ { value: "blue", label: "Blue" },
+] satisfies Array<{ value: BadgeVariant; label: string }>;
+
+const dotVariants = [
+ { value: "success", label: "Success" },
+ { value: "warning", label: "Warning" },
+ { value: "error", label: "Error" },
+ { value: "neutral", label: "Neutral" },
+] satisfies Array<{ value: BadgeVariant; label: string }>;
+---
+
+
+
+
+
+
+
+
+ Filled variants
+
+
+ Every color variant rendered plain, with an icon, and inside a link.
+
+
+
+
+
+ Variant
+ Plain
+ Icon
+ Linked
+
+
+ {
+ variants.map(({ value, label }) => (
+
+
{label}
+
{label}
+
+ {label}
+
+
+
{label}
+
+
+ ))
+ }
+
+
+
+
+
+
Dot variants
+
+ Supported dot colors rendered plain and inside a link. Dot badges do
+ not accept icons.
+
+
+
+
+ {
+ dotVariants.map(({ value, label }) => (
+
+
+ {label}
+
+
+
+ {label}
+
+
+
+ ))
+ }
+
+
+
+
+
+
+ Content combinations
+
+
+ Default props, nested content, a custom class, and combined icon and
+ link treatments.
+
+
+
+
+ Default
+ Nested content
+ Custom padding
+
+
+ Linked with icon
+
+
+
+
+
+
diff --git a/packages/kumo/src/components/badge/badge.test.tsx b/packages/kumo/src/components/badge/badge.test.tsx
index 939007d1d4..d9fb4b640e 100644
--- a/packages/kumo/src/components/badge/badge.test.tsx
+++ b/packages/kumo/src/components/badge/badge.test.tsx
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { render, screen } from "@testing-library/react";
+import { ArrowRightIcon } from "@phosphor-icons/react";
import { Badge, badgeVariants, KUMO_BADGE_VARIANTS } from "./badge";
describe("Badge", () => {
@@ -20,6 +21,22 @@ describe("Badge", () => {
expect(el.className).toContain("my-custom");
});
+ it("shows a ring when an ancestor link is hovered", () => {
+ render(
+
+ Docs
+ ,
+ );
+ const badge = screen.getByText("Docs");
+ expect(badge.classList.contains("[a:hover_&]:ring")).toBe(true);
+ expect(badge.classList.contains("[a:hover_&]:ring-current")).toBe(true);
+ });
+
+ it("renders an icon", () => {
+ render(
Next);
+ expect(screen.getByText("Next").querySelector("svg")).toBeTruthy();
+ });
+
describe("filled appearance (default)", () => {
it("applies variant classes for filled badges", () => {
render(
Error);
diff --git a/packages/kumo/src/components/badge/badge.tsx b/packages/kumo/src/components/badge/badge.tsx
index 92aeaa7863..f9b6355097 100644
--- a/packages/kumo/src/components/badge/badge.tsx
+++ b/packages/kumo/src/components/badge/badge.tsx
@@ -1,10 +1,11 @@
-import type { ReactNode } from "react";
+import React, { type ReactNode } from "react";
+import type { Icon } from "@phosphor-icons/react";
import { cn } from "../../utils/cn";
import { resolveVariant } from "../../utils/resolve-variant";
/** Base styles applied to all badge variants. */
export const KUMO_BADGE_BASE_STYLES =
- "inline-flex w-fit flex-none shrink-0 items-center justify-self-start rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap";
+ "inline-flex w-fit flex-none shrink-0 items-center justify-self-start gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap [a:hover_&]:ring [a:hover_&]:ring-current";
/** Badge variant definitions mapping variant names to their Tailwind classes and descriptions. */
export const KUMO_BADGE_VARIANTS = {
@@ -44,8 +45,8 @@ export const KUMO_BADGE_VARIANTS = {
description: "Indicates beta or experimental features",
},
outline: {
- classes: "border border-kumo-fill bg-transparent text-kumo-default",
- description: "Bordered badge with transparent background",
+ classes: "border border-kumo-fill bg-kumo-base text-kumo-default",
+ description: "Bordered badge with base background",
},
/** Other color token variants */
@@ -162,6 +163,24 @@ export function badgeVariants({
// Legacy type alias for backwards compatibility
export type BadgeVariant = KumoBadgeVariant;
+const renderIconNode = (IconComponent?: Icon | ReactNode) => {
+ if (!IconComponent) return null;
+ const Component = IconComponent as React.ComponentType<
+ Record
+ >;
+ const icon = React.isValidElement(IconComponent) ? (
+ IconComponent
+ ) : (
+
+ );
+
+ return (
+
+ {icon}
+
+ );
+};
+
/**
* Badge component props.
*
@@ -173,7 +192,7 @@ export type BadgeVariant = KumoBadgeVariant;
* Healthy
* ```
*/
-export interface BadgeProps {
+interface BadgeBaseProps {
/**
* Color variant of the badge.
* Recommended semantic variants:
@@ -188,26 +207,47 @@ export interface BadgeProps {
* - `"red"`, `"orange"`, `"green"`, `"teal"`, `"blue"`, `"purple"`, `"neutral"`
* - `"teal-subtle"`, `"neutral-subtle"`
* - `"inverted"`
- * - `"outline"` — Bordered badge with transparent background
+ * - `"outline"` — Bordered badge with the base background
* - `"beta"` — Dashed-border badge for beta/experimental features
* @default "primary"
*/
variant?: KumoBadgeVariant;
+ /** Additional CSS classes merged via `cn()`. */
+ className?: string;
+ /** Content rendered inside the badge. */
+ children: ReactNode;
+}
+
+interface FilledBadgeProps extends BadgeBaseProps {
/**
* Visual appearance of the badge.
* - `"filled"` — Filled background using the variant color (default)
* - `"dot"` — Outlined badge with a colored circle dot. Only `success`,
- * `warning`, `error`, and `neutral` variants show a dot; other variants
- * render the badge without a dot.
+ * `warning`, `error`, and `neutral` variants show a dot. Dot badges do not
+ * accept icons.
* @default "filled"
*/
- appearance?: KumoBadgeAppearance;
- /** Additional CSS classes merged via `cn()`. */
- className?: string;
- /** Content rendered inside the badge. */
- children: ReactNode;
+ appearance?: "filled";
+ /** Icon from `@phosphor-icons/react` or a React element. Rendered before children. */
+ icon?: Icon | ReactNode;
}
+interface DotBadgeProps extends BadgeBaseProps {
+ /**
+ * Visual appearance of the badge.
+ * - `"filled"` — Filled background using the variant color (default)
+ * - `"dot"` — Outlined badge with a colored circle dot. Only `success`,
+ * `warning`, `error`, and `neutral` variants show a dot. Dot badges do not
+ * accept icons.
+ * @default "filled"
+ */
+ appearance: "dot";
+ /** Dot badges use their status dot instead of an icon. */
+ icon?: never;
+}
+
+export type BadgeProps = FilledBadgeProps | DotBadgeProps;
+
/**
* Small status label for categorizing or highlighting content.
*
@@ -221,6 +261,7 @@ export function Badge({
variant = KUMO_BADGE_DEFAULT_VARIANTS.variant,
appearance = KUMO_BADGE_DEFAULT_VARIANTS.appearance,
className,
+ icon,
children,
}: BadgeProps) {
// Crash-safe dot-color lookup via resolveVariant — unknown variants fall
@@ -233,15 +274,21 @@ export function Badge({
KUMO_BADGE_DEFAULT_VARIANTS.dotColor,
).classes
: "";
-
return (
-
+
{dotColor ? (
) : null}
+ {renderIconNode(icon)}
{children}
);
diff --git a/packages/kumo/tests/badge-types.test-d.tsx b/packages/kumo/tests/badge-types.test-d.tsx
new file mode 100644
index 0000000000..864cb3be2d
--- /dev/null
+++ b/packages/kumo/tests/badge-types.test-d.tsx
@@ -0,0 +1,43 @@
+import { CheckCircleIcon } from "@phosphor-icons/react";
+import { expectTypeOf } from "vite-plus/test";
+import { Badge, type BadgeProps } from "../src/components/badge/badge";
+
+type DotBadgeProps = Extract;
+
+expectTypeOf["icon"]>().toEqualTypeOf();
+
+function FilledBadgeWithIcon() {
+ return Verified;
+}
+
+function ExplicitFilledBadgeWithIcon() {
+ return (
+
+ Verified
+
+ );
+}
+
+function DotBadgeWithoutIcon() {
+ return (
+
+ Healthy
+
+ );
+}
+
+function DotBadgeWithIcon() {
+ return (
+ // @ts-expect-error Dot badges use their status dot instead of an icon.
+
+ Healthy
+
+ );
+}
+
+export {
+ DotBadgeWithIcon,
+ DotBadgeWithoutIcon,
+ ExplicitFilledBadgeWithIcon,
+ FilledBadgeWithIcon,
+};